Клієнт просить «зробити як у 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 проєктів з нативними елементами для десктопу. Зв'яжіться з нами, щоб обговорити ваш проєкт.







