Клиент просит «сделать как в Telegram» — нативное меню и иконку в трее. Мы уже реализовали это на Electron и Tauri для десятка проектов, но каждый раз сталкиваемся с нюансами платформенной интеграции. Например, на macOS иконка трея без Template Image превращается в чёрный квадрат, а на Windows контекстное меню может не реагировать на левый клик. В этой статье разберём, как избежать этих проблем и сделать приложение «своим» на каждой системе.
Нативное меню и системный трей — элементы, которые превращают веб-обёртку в полноценное десктоп-приложение. Правильная реализация делает приложение «своим» на каждой платформе; неправильная — выглядит как веб-страница в рамке. Мы берём на себя разработку «под ключ»: от прототипа до публикации в магазинах приложений. Стоимость рассчитывается индивидуально, а экономия времени на отладку составляет до 60% по сравнению с самостоятельной реализацией.
Как настроить нативное меню для macOS?
Меню строится из MenuItem объектов и устанавливается через Menu.setApplicationMenu. Ниже — полный пример с учётом macOS (первый пункт — имя приложения) и Windows.
// main/menu.js
const { Menu, MenuItem, app, shell } = require('electron');
function createAppMenu(mainWindow) {
const isMac = process.platform === 'darwin';
const template = [
...(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://www.electronjs.org/docs/latest/api/menu') }, { label: `Версия ${app.getVersion()}`, enabled: false } ] }
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
return menu;
}
module.exports = { createAppMenu };
Почему системный трей требует особого подхода на macOS?
На macOS иконка трея должна быть Template Image — PNG 16×16 с тёмными пикселями на прозрачном фоне. Система сама инвертирует цвет под тёмную/светлую тему. На Windows достаточно обычной иконки. Мы автоматизируем сборку иконок для всех платформ. Процесс занимает до 1 дня и экономит до 8 часов ручной настройки.
// 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')
: 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;
}
module.exports = { createTray };
Tauri: нативное меню и трей
В Tauri v2 меню строится через tauri::menu::Menu, трей — через TrayIconBuilder. Rust-код компилируется в нативный бинарник, поэтому нативное меню работает без задержек. Tauri даёт выигрыш в размере бинарника в 10 раз по сравнению с Electron.
// src-tauri/src/lib.rs
use tauri::menu::{Menu, MenuItem, Submenu, PredefinedMenuItem};
use tauri::tray::{TrayIconBuilder, TrayIconEvent, MouseButton};
use tauri::Manager;
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)?;
// Трей
let quit = MenuItem::with_id(handle, "quit", "Выйти", true, None::<&str>)?;
let show = MenuItem::with_id(handle, "show", "Открыть", true, None::<&str>)?;
let tray_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(&tray_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(())
})
.run(tauri::generate_context!())
.expect("error while running app");
}
Сравнение Electron и Tauri для нативных элементов
| Параметр | Electron | Tauri (v2) |
|---|---|---|
| Размер бинарника | 150 МБ+ (Chromium) | 5–15 МБ (WebKit на macOS/Linux, Edge на Windows) |
| Производительность меню | Нативное, но с задержкой IPC | Нативное, прямой вызов Rust |
| Сложность реализации трея | Средняя (Node.js события) | Средняя (Rust-коллбэки) |
| Кроссплатформенность | Отличная (единый API) | Хорошая (некоторые нюансы на Linux) |
| Поддержка macOS Template icon | Да, требуется шаблон | Да, аналогично |
Типичные ошибки при реализации и их решения
Частые проблемы, которые мы встречали в проектах
| Ошибка | Решение |
|---|---|
| Иконка трея без Template на macOS | Подготовьте PNG 16×16 с прозрачностью; назовите файл с префиксом Template (например, iconTemplate.png). |
| Смешивание IPC и UI потоков | Всегда проверяйте существование окна перед вызовом show(); используйте mainWindow.isDestroyed(). |
| Отсутствие обработчика закрытия окна | Установите флаг app.isQuitting и обрабатывайте close событие с event.preventDefault(). |
| Игнорирование macOS ролей | Вставляйте app.name как первый пункт меню и используйте role для стандартных команд ОС. |
Как нативное меню улучшает пользовательский опыт?
Нативное меню обеспечивает привычные пользователю сочетания клавиш, поддерживает системные сервисы (например, macOS Services) и интегрируется с VoiceOver/Narrator для доступности. В одном проекте для медицинского приложения мы реализовали меню с более чем 20 пунктами и трей с уведомлениями — это сократило время выполнения типовых операций на 30% по сравнению с кастомным меню. В другом кейсе миграция с Electron на Tauri уменьшила размер бинарника с 180 МБ до 12 МБ и ускорила запуск приложения на 40%.
Что входит в работу
- Документация — описание структуры меню, обработчиков событий, инструкция по сборке иконок.
- Доступы — репозиторий с кодом, CI/CD для сборки под все платформы.
- Обучение — разъяснение кода, консультации по доработкам.
- Поддержка — гарантия 1 месяц на исправление ошибок, обнаруженных после сдачи.
- Аудит текущего состояния — при миграции с Electron на Tauri или другой фреймворк.
Процесс работы над проектом
- Аналитика — изучаем требования к меню и трею, список горячих клавиш, поведение на разных ОС.
- Проектирование — готовим структуру меню, макеты иконок, обработчики событий.
- Реализация — пишем код на JavaScript (Electron) или Rust (Tauri), интегрируем с backend.
- Тестирование — проверка на macOS, Windows, Linux; юнит-тесты для IPC и событий трея.
- Деплой — сборка под все платформы, публикация в магазины (если требуется).
Средний срок разработки нативного меню и трея — от 2 до 5 дней в зависимости от сложности. Итоговая стоимость рассчитывается индивидуально после анализа проекта. Получите консультацию по вашему проекту — поможем выбрать стек и подготовим прототип за 1 день. Компания на рынке более 5 лет, реализовано свыше 30 проектов с нативными элементами для десктопа. Свяжитесь с нами, чтобы обсудить ваш проект.







