Windows と macOS でメニューを出し分ける

macOS はアプリ名のメニュー、Windows・Linux は「ファイル」から始める慣習に合わせてメニューを組む。os プラグインの platform() と Rust の cfg での判定、About・設定・終了の置き場所を示す。

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

同じアプリでも、メニューの並べ方は OS ごとに慣習が違います。macOS ではメニューバーが画面上部に 1 つだけで、先頭のサブメニューは名前に関係なくアプリ名のメニューになり、「このアプリについて」「設定」「終了」はそこに置きます。Windows と Linux ではメニューバーがウィンドウごとに付き、「ファイル」から始めて「終了」はその末尾、「バージョン情報」は「ヘルプ」に置くのが普通です。ここでは OS の判定方法と、項目を OS ごとに振り分ける組み立て方を説明します。メニュー自体の作り方は menu-001、標準項目の一覧は menu-007 を参照してください。

前提条件

Rust で組み立てるなら、OS はビルド時に決まるのでプラグインは不要です。JS で組み立てる場合は、OS の判定に os プラグインを使います。

npm run tauri add os

このコマンドで lib.rs に .plugin(tauri_plugin_os::init()) が追加されます。capability に os:default が無ければ足します(platform() の権限 os:allow-platform はこれに含まれます)。メニューの権限は core:default に含まれています。

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

主な項目の置き場所は次のとおりです。Tauri の既定のメニュー(Menu.default())も、「このアプリについて」を macOS ではアプリ名のメニュー、Windows と Linux では「Help」に入れています。

項目macOSWindows / Linux
このアプリについてアプリ名のメニューの先頭「ヘルプ」の末尾
設定アプリ名のメニュー(Cmd+,)「ファイル」や「ツール」
終了アプリ名のメニューの末尾(Cmd+Q)「ファイル」の末尾(Linux は標準項目の Quit が使えない)
「ウィンドウ」メニュー置く(開いているウィンドウの一覧を OS が足す)置かないことが多い
アクセスキー (&F)付けない(macOS のメニューにはアクセスキーが無い)付ける

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

os プラグインの platform() は、await なしで 'macos'・'windows'・'linux' などを返します。値はビルド時に決まっていて、呼ぶたびに問い合わせることはありません。OS ごとに別のメニューを丸ごと書くより、置き場所の変わる項目を先に 1 回だけ定義し、どこに入れるかだけを分岐させると、項目を足したときの書き忘れが減ります。

import { Menu, Submenu, type MenuItemOptions, type PredefinedMenuItemOptions } from '@tauri-apps/api/menu';
import { platform } from '@tauri-apps/plugin-os';

const os = platform(); // 'macos' | 'windows' | 'linux' など
const isMac = os === 'macos';
const APP = 'My Editor';

// macOS ではアクセスキーの「(&F)」を付けない
const label = (text: string, key: string) => (isMac ? text : `${text}(&${key})`);
const run = (id: string) => console.log('menu:', id); // 振り分けは menu-005

// OS によって置き場所が変わる項目は、先に 1 回だけ定義する
const about: PredefinedMenuItemOptions = {
  item: { About: { name: APP, version: '1.0.0' } },
  text: isMac ? `${APP} について` : 'バージョン情報(&A)',
};
const settings: MenuItemOptions = { id: 'app:settings', text: `${label('設定', 'O')}...`, accelerator: 'CmdOrCtrl+,', action: run };
const separator: PredefinedMenuItemOptions = { item: 'Separator' };

const fileItems: Array<MenuItemOptions | PredefinedMenuItemOptions> = [
  { id: 'file:new', text: label('新規作成', 'N'), accelerator: 'CmdOrCtrl+N', action: run },
];
if (!isMac) fileItems.push(separator, settings);
if (os === 'windows') fileItems.push(separator, { item: 'Quit', text: '終了(&X)' }); // Linux は Quit が非対応

const submenus: Submenu[] = [];
if (isMac) {
  // 先頭のサブメニューは、text に関係なくアプリ名のメニューになる
  submenus.push(await Submenu.new({
    text: APP,
    items: [about, separator, settings, separator, { item: 'Quit', text: `${APP} を終了` }],
  }));
}
submenus.push(await Submenu.new({ text: label('ファイル', 'F'), items: fileItems }));
submenus.push(await Submenu.new({
  text: label('編集', 'E'), // macOS でコピーなどのキーを効かせるにも必要(menu-007)
  items: [
    { item: 'Cut', text: label('切り取り', 'T') },
    { item: 'Copy', text: label('コピー', 'C') },
    { item: 'Paste', text: label('貼り付け', 'P') },
  ],
}));
const windowMenu = isMac
  ? await Submenu.new({ text: 'ウィンドウ', items: [{ item: 'Minimize', text: 'しまう' }, { item: 'Maximize', text: '拡大/縮小' }] })
  : null;
if (windowMenu) submenus.push(windowMenu);
const helpMenu = await Submenu.new({
  text: label('ヘルプ', 'H'),
  items: isMac ? [{ id: 'help:docs', text: `${APP} ヘルプ`, action: run }] : [{ id: 'help:docs', text: 'ヘルプを表示(&D)', action: run }, separator, about],
});
submenus.push(helpMenu);

await (await Menu.new({ items: submenus })).setAsAppMenu();
if (isMac) {
  await windowMenu?.setAsWindowsMenuForNSApp(); // ウィンドウの一覧を OS が足す
  await helpMenu.setAsHelpMenuForNSApp(); // ヘルプに検索欄が付く
}

setAsWindowsMenuForNSApp() と setAsHelpMenuForNSApp() は Windows と Linux では非対応で、呼んでも何も変わりません。

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

Rust では cfg!(target_os = "macos") で分岐できます。cfg!() はどの OS でも両方の分岐をコンパイルするので書き間違いに気付きやすい一方、macOS にしかないメソッド(Submenu::set_as_windows_menu_for_nsapp() など)を分岐の中で呼ぶと、Windows でビルドが通りません。そうしたメソッドは #[cfg(target_os = "macos")] を付けたブロックで呼ぶか、下の例のようにサブメニューへ特別な id(WINDOW_SUBMENU_ID / HELP_SUBMENU_ID)を付けます。この id のサブメニューをメニューの最上位に置くと、macOS では「ウィンドウ」「ヘルプ」メニューとして扱われ、ほかの OS では普通のサブメニューになります。

use tauri::menu::{AboutMetadata, Menu, MenuBuilder, MenuItemBuilder, SubmenuBuilder, HELP_SUBMENU_ID, WINDOW_SUBMENU_ID};
use tauri::{AppHandle, Runtime};

/// macOS ではアクセスキーの「(&F)」を付けない
fn label(text: &str, key: char) -> String {
    if cfg!(target_os = "macos") {
        text.to_string()
    } else {
        format!("{text}(&{key})")
    }
}

fn os_menu<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<Menu<R>> {
    let mac = cfg!(target_os = "macos");
    let name = app.package_info().name.clone();
    let about = AboutMetadata {
        name: Some(name.clone()),
        version: Some(app.package_info().version.to_string()),
        ..Default::default()
    };
    // 置き場所が OS で変わる「設定」は 1 回だけ作る
    let settings = MenuItemBuilder::with_id("app:settings", format!("{}...", label("設定", 'O')))
        .accelerator("CmdOrCtrl+,")
        .build(app)?;

    let mut menu = MenuBuilder::new(app);
    if mac {
        let app_menu = SubmenuBuilder::new(app, &name) // 先頭なのでアプリ名のメニューになる
            .about_with_text(format!("{name} について"), Some(about.clone()))
            .separator()
            .item(&settings)
            .separator()
            .quit_with_text(format!("{name} を終了"))
            .build()?;
        menu = menu.item(&app_menu);
    }

    let mut file = SubmenuBuilder::new(app, label("ファイル", 'F')).text("file:new", label("新規作成", 'N'));
    if !mac {
        file = file.separator().item(&settings).separator();
        file = if cfg!(target_os = "linux") {
            file.text("app:quit", "終了(&X)") // Linux は標準の Quit が使えないので自前の項目にする
        } else {
            file.quit_with_text("終了(&X)")
        };
    }
    let edit = SubmenuBuilder::new(app, label("編集", 'E'))
        .cut_with_text(label("切り取り", 'T'))
        .copy_with_text(label("コピー", 'C'))
        .paste_with_text(label("貼り付け", 'P'))
        .build()?;
    menu = menu.item(&file.build()?).item(&edit);

    if mac {
        let window = SubmenuBuilder::with_id(app, WINDOW_SUBMENU_ID, "ウィンドウ")
            .minimize_with_text("しまう")
            .maximize_with_text("拡大/縮小")
            .build()?;
        menu = menu.item(&window);
    }
    let mut help = SubmenuBuilder::with_id(app, HELP_SUBMENU_ID, label("ヘルプ", 'H')).text("help:docs", label("ヘルプを表示", 'D'));
    if !mac {
        help = help.separator().about_with_text("バージョン情報(&A)", Some(about));
    }
    menu.item(&help.build()?).build()
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .menu(|app| os_menu(app))
        .on_menu_event(|app, event| match event.id().as_ref() {
            "app:quit" => app.exit(0), // Linux 用の自前の「終了」
            id => println!("menu: {id}"),
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

動作確認

npm run tauri dev で起動します。

  • Windows: タイトルバーの下に「ファイル(F)」「編集(E)」「ヘルプ(H)」が並びます。「ファイル」の末尾に「設定(O)...」と「終了(X)」、「ヘルプ」の末尾に「バージョン情報(A)」が入り、Alt+F で「ファイル」が開きます。
  • macOS: 画面上部にアプリ名・「ファイル」・「編集」・「ウィンドウ」・「ヘルプ」が並びます。アプリ名のメニューに「My Editor について」「設定...」「My Editor を終了」が入り、「ウィンドウ」の末尾には開いているウィンドウの名前が、「ヘルプ」には検索欄が出ます。
  • Linux: Windows と同じ並びで、Rust 版の「終了」を選ぶとアプリが終わります。

Rust 版で「設定...」を選ぶと、ターミナルに次のように出ます。

menu: app:settings

よくあるエラーと対処法

  • macOS で「ファイル」の中身がアプリ名のメニューに入ってしまう: 先頭のサブメニューは名前に関係なくアプリ名のメニューになります。macOS ではアプリ用のサブメニューを先頭に足します。
  • Windows のビルドで set_as_windows_menu_for_nsapp が見つからないという趣旨のエラー: macOS にしかないメソッドを cfg!() の分岐で呼んでいます。#[cfg(target_os = "macos")] のブロックに移すか、WINDOW_SUBMENU_ID を使います。
  • platform() で undefined のプロパティを読もうとした趣旨の TypeError: os プラグインが Rust 側に登録されていません。lib.rs に .plugin(tauri_plugin_os::init()) があるか確かめます。
  • Linux で「終了」が機能しない: 標準項目の Quit は Linux では非対応です。自前の項目にして app.exit(0) を呼びます。
  • macOS の「ウィンドウ」にウィンドウの一覧が出ない: setAsWindowsMenuForNSApp() を呼んでいないか、Rust で WINDOW_SUBMENU_ID のサブメニューを最上位ではなく別のサブメニューの中に置いています。

OS ごとの違いと注意点

  • macOS: メニューはアプリで 1 つで、最上位にはサブメニューしか置けません。ウィンドウごとのメニュー(setAsWindowMenu())は使えません。ヘルプメニューを指定しなければ、表示言語で「ヘルプ」に当たる名前のサブメニューが自動で使われます。
  • Windows: 標準項目の Undo・Redo は非対応です。「元に戻す」が必要なら自前の項目にします。
  • Linux: Quit のほか CloseWindow・Minimize なども非対応で、切り取りなどには linux-libxdo が必要です(menu-007)。
  • キーの慣習: Command と Ctrl の違いは CmdOrCtrl で吸収できます(menu-004)。「やり直し」が macOS では Cmd+Shift+Z、Windows では Ctrl+Y であるような慣習の違いは、OS ごとに accelerator を分けます。
  • iOS / Android: メニューの API はありません。OS の判定だけなら OS の種類(Windows/Mac/Linux)を取得する を参照してください。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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