Реализация нативного меню и системного трея в Electron/Tauri приложении
Нативное меню и системный трей — элементы, которые интегрируют приложение в среду ОС. Правильная реализация делает приложение «своим» на каждой платформе; неправильная — выглядит как веб-страница в рамке.
Electron: нативное меню
Меню в Electron строится из MenuItem объектов и устанавливается через Menu.setApplicationMenu.
// main/menu.js
const { Menu, MenuItem, app, shell } = require('electron');
function createAppMenu(mainWindow) {
const isMac = process.platform === 'darwin';
const template = [
// macOS: первый пункт — имя приложения
...(isMac ? [{
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' }
]
}] : []),
{
label: 'Файл',
submenu: [
{
label: 'Новый',
accelerator: 'CmdOrCtrl+N',
click: () => mainWindow.webContents.send('menu:new')
},
{
label: 'Открыть...',
accelerator: 'CmdOrCtrl+O',
click: async () => {
const { dialog } = require('electron');
const result = await dialog.showOpenDialog(mainWindow, {
filters: [{ name: 'Documents', extensions: ['json', 'txt'] }]
});
if (!result.canceled) {
mainWindow.webContents.send('menu:open', result.filePaths[0]);
}
}
},
{ type: 'separator' },
isMac ? { role: 'close' } : { role: 'quit' }
]
},
{
label: 'Правка',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' }
]
},
{
label: 'Вид',
submenu: [
{ role: 'reload' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' }
]
},
{
label: 'Помощь',
role: 'help',
submenu: [
{
label: 'Документация',
click: () => shell.openExternal('https://docs.your-app.com')
},
{
label: `Версия ${app.getVersion()}`,
enabled: false
}
]
}
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
return menu;
}
module.exports = { createAppMenu };
Electron: контекстное меню
Контекстное меню по правому клику строится аналогично, но показывается через menu.popup():
// main/ipc-handlers.js
const { ipcMain, Menu, BrowserWindow } = require('electron');
ipcMain.on('context-menu:show', (event, params) => {
const win = BrowserWindow.fromWebContents(event.sender);
const template = [
{
label: 'Копировать',
enabled: params.hasSelection,
click: () => event.sender.copy()
},
{
label: 'Вставить',
click: () => event.sender.paste()
},
{ type: 'separator' },
...(params.isEditable ? [{
label: 'Проверка орфографии',
type: 'checkbox',
checked: params.spellCheckEnabled,
click: () => event.sender.send('toggle-spellcheck')
}] : []),
{
label: 'Инспектор (dev)',
click: () => event.sender.inspectElement(params.x, params.y),
visible: process.env.NODE_ENV === 'development'
}
];
Menu.buildFromTemplate(template).popup({ window: win });
});
// renderer — вызов из фронтенда
window.addEventListener('contextmenu', (e) => {
e.preventDefault();
window.electronAPI.showContextMenu({
x: e.x,
y: e.y,
hasSelection: window.getSelection()?.toString().length > 0,
isEditable: e.target.matches('input, textarea, [contenteditable]')
});
});
Electron: системный трей
// main/tray.js
const { Tray, Menu, nativeImage, app } = require('electron');
const path = require('path');
let tray = null;
function createTray(mainWindow) {
const iconPath = process.platform === 'darwin'
? path.join(__dirname, '../resources/tray-icon-mac.png') // Template image (чёрно-белая)
: path.join(__dirname, '../resources/tray-icon.png');
tray = new Tray(nativeImage.createFromPath(iconPath));
tray.setToolTip('My Application');
const contextMenu = Menu.buildFromTemplate([
{
label: 'Открыть',
click: () => {
mainWindow.show();
mainWindow.focus();
}
},
{
label: 'Статус: активен',
enabled: false,
id: 'status-item'
},
{ type: 'separator' },
{
label: 'Настройки',
click: () => {
mainWindow.show();
mainWindow.webContents.send('navigate', '/settings');
}
},
{ type: 'separator' },
{
label: 'Выйти',
click: () => {
app.isQuitting = true;
app.quit();
}
}
]);
tray.setContextMenu(contextMenu);
// Клик по иконке (не правый клик)
tray.on('click', () => {
mainWindow.isVisible() ? mainWindow.hide() : mainWindow.show();
});
// Скрывать в трей вместо закрытия
mainWindow.on('close', (event) => {
if (!app.isQuitting) {
event.preventDefault();
mainWindow.hide();
}
});
return tray;
}
function updateTrayStatus(text) {
const menu = tray.getContextMenu();
// Перестроить меню с новым статусом
// (Electron не позволяет менять label у существующего MenuItem напрямую)
}
module.exports = { createTray };
На macOS иконка трея должна быть «Template image» — PNG 16×16 с тёмными пикселями на прозрачном фоне. Система сама инвертирует цвет под тёмную/светлую тему.
Tauri: нативное меню
// src-tauri/src/lib.rs
use tauri::menu::{Menu, MenuItem, Submenu, PredefinedMenuItem};
pub fn run() {
tauri::Builder::default()
.setup(|app| {
let handle = app.handle();
let file_menu = Submenu::with_items(handle, "Файл", true, &[
&MenuItem::with_id(handle, "new", "Новый", true, Some("CmdOrCtrl+N"))?,
&MenuItem::with_id(handle, "open", "Открыть...", true, Some("CmdOrCtrl+O"))?,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::quit(handle, Some("Выйти"))?,
])?;
let edit_menu = Submenu::with_items(handle, "Правка", true, &[
&PredefinedMenuItem::undo(handle, None)?,
&PredefinedMenuItem::redo(handle, None)?,
&PredefinedMenuItem::separator(handle)?,
&PredefinedMenuItem::cut(handle, None)?,
&PredefinedMenuItem::copy(handle, None)?,
&PredefinedMenuItem::paste(handle, None)?,
&PredefinedMenuItem::select_all(handle, None)?,
])?;
let menu = Menu::with_items(handle, &[&file_menu, &edit_menu])?;
app.set_menu(menu)?;
Ok(())
})
.on_menu_event(|app, event| {
match event.id().as_ref() {
"new" => { app.emit("menu:new", ()).unwrap(); }
"open" => { app.emit("menu:open", ()).unwrap(); }
_ => {}
}
})
.run(tauri::generate_context!())
.expect("error while running app");
}
Tauri: системный трей
# src-tauri/Cargo.toml
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
use tauri::{
tray::{TrayIconBuilder, TrayIconEvent, MouseButton},
menu::{Menu, MenuItem, PredefinedMenuItem},
Manager,
};
fn setup_tray(app: &tauri::App) -> tauri::Result<()> {
let handle = app.handle();
let quit = MenuItem::with_id(handle, "quit", "Выйти", true, None::<&str>)?;
let show = MenuItem::with_id(handle, "show", "Открыть", true, None::<&str>)?;
let menu = Menu::with_items(handle, &[&show, &PredefinedMenuItem::separator(handle)?, &quit])?;
TrayIconBuilder::with_id("main-tray")
.tooltip("My Application")
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.on_menu_event(|app, event| match event.id().as_ref() {
"quit" => app.exit(0),
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click { button: MouseButton::Left, .. } = event {
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
}
}
})
.build(app)?;
Ok(())
}
Поведение трея на разных ОС отличается: на macOS левый клик обычно показывает меню, на Windows — показывает/скрывает окно. Это стоит учитывать при проектировании — не стоит требовать правый клик для основного действия на macOS.







