Уровень: middle/senior, кросс-платформенная разработка Стек: Electron 28, electron-builder, electron-updater, vanilla HTML/JS Что внутри: архитектурные решения, IPC между окнами, deep links на трёх ОС, tray-first паттерн, auto-updater grace, custom протоколы
Контекст
Это четвёртая статья из серии про инженерные решения в ONEMIX — моём мессенджере на React Native. В предыдущих разбирал трёхуровневый кэш сообщений, Double Ratchet E2E и WebRTC звонки с trickle ICE. Последняя про звонки набрала больше всего просмотров, и в комментариях несколько раз спрашивали про десктоп: "а как у тебя там устроено?".
Сегодня — отдельная статья про desktop-версию. Сразу скажу: я не использовал React Native for Desktop, не Tauri, не React, не TypeScript. Чистый Electron + vanilla HTML/JS. Это нестандартное решение, и я объясню почему пошёл этим путём, что от этого выиграл, и где это бьёт по голове.
Почему vanilla Electron, а не RN-Desktop
Когда я начинал делать десктоп, рассматривал четыре варианта:
React Native for Windows + macOS. Это официальный Microsoft форк RN для Windows и старый Facebook-форк для macOS. Идея заманчивая — переиспользовать весь мобильный код. На практике у меня было два блокера. Первое: оба порта ужасно отстают от mainline RN, многие зависимости (react-native-reanimated, react-native-svg, expo-secure-store) либо не поддерживаются, либо требуют отдельных нативных модулей которые писать самому. Второе: Linux-поддержки нет в принципе. А Linux я хотел.
Tauri. Современный, лёгкий, на Rust. Я серьёзно его рассматривал и даже пробовал. Минус один, но критичный: WebView на каждой ОС разный (Edge WebView2 на Windows, WebKit на macOS, WebKitGTK на Linux). Это значит что условный CSS Grid у тебя работает на Windows, ломается на Linux, и подвисает на macOS. Отлаживать межплатформенные баги в Tauri — это отдельный жанр страданий. У Electron под капотом Chromium, везде одинаковый, рендеринг предсказуемый.
Electron + React (как делает Discord, Slack, WhatsApp Desktop). Это нормальный путь. Я отказался по одной причине — переусложнение для моих задач. У меня нет реактивных списков сложнее списка чатов и списка сообщений. Нет state-менеджмента сложнее WebSocket + localStorage. Реальная работа происходит на бэкенде. Городить webpack + babel + React + TypeScript ради рендеринга списка чатов — это вес ради веса. На vanilla получается в 5 раз меньше билд-конфига и в 3 раза быстрее разработка.
Vanilla Electron + HTML/JS. То что я в итоге выбрал. Один main.js с main process. Один preload.js. Пять HTML файлов (index, call, settings, join, share-group). Никакого сборщика. electron . — и всё работает.
Если ваш десктоп — это сложное приложение с десятками экранов, активной reactivity и большой кодовой базой, vanilla не подойдёт. Берите React/Vue/Solid. Но для мессенджера где сложность сосредоточена в бэкенде — это оптимальный путь. У меня package.json в десктоп-проекте — это 27 строк зависимостей (включая electron-builder и electron-updater). Сборка проекта весит 50MB вместо 250MB у среднего Electron-проекта.
Архитектура: три окна и main process
В ONEMIX-десктопе три типа окон:
Main window — основное окно с UI чатов и WebSocket-соединением к бэкенду. Это единственное окно, через которое идёт вся сетевая активность. WebSocket держится только здесь.
Call window — отдельное окно для звонков. Создаётся при инициации звонка, закрывается при завершении. Содержит WebRTC PeerConnection, getUserMedia, видео-элементы.
Settings window — отдельное окно настроек. Создаётся при открытии настроек, закрывается при закрытии.
Идея отдельных окон не моя — так делает Telegram Desktop, так делает Skype. Звонок и настройки должны быть независимыми окнами по нескольким причинам:
Звонок не должен скрываться когда юзер сворачивает главное окно. Если юзер на звонке хочет открыть Excel/браузер и параллельно говорить — главное окно ему мешает в taskbar, а отдельное окно звонка нет.
Звонок может (и должен) быть alwaysOnTop, чтобы видео было видно поверх остальных окон. Главное окно — не должен.
Звонок и главное окно живут разными жизнями: звонок может оборваться (и окно закрыться), а главное окно остаётся. Главное окно может перезагрузиться при обновлении — звонок этого не должен заметить.
function createCallWindow(callState) { if (callWindow && !callWindow.isDestroyed()) { callWindow.focus(); return; } const isVideo = callState.callType === 'video'; const { screen } = require('electron'); const { width: sw, height: sh } = screen.getPrimaryDisplay().workAreaSize; const winW = isVideo ? 480 : 360; const winH = isVideo ? 700 : 560; callWindow = new BrowserWindow({ width: winW, height: winH, minWidth: 300, minHeight: 420, x: Math.round((sw - winW) / 2), y: Math.round((sh - winH) / 2), alwaysOnTop: true, frame: false, titleBarStyle: 'hidden', backgroundColor: '#000000', skipTaskbar: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, }, show: false, }); callWindow.loadFile(path.join(__dirname, 'src', 'call.html'), { query: { state: JSON.stringify(callState) }, }); }
Передача начального состояния через query URL — простой и надёжный способ. Альтернатива через IPC требует ждать ready-to-show и потом отдельно слать данные, что добавляет race conditions.
WebRTC через relay: критичное архитектурное решение
Звонок происходит в call window, но WebSocket к бэкенду живёт в main window. WebRTC сигналы (offer, answer, ICE candidates) нужно передавать туда-обратно. Прямой WebSocket из call window — плохая идея: получим два независимых WebSocket-соединения, гонка состояний, дубликаты пушей.
Решение — relay через main process:
[call window] → IPC → [main process] → IPC → [main window WS] → server ↑ │ └──────── IPC ←─── [main process] ←─── IPC ←─── WS message ────┘
В call window (renderer):
// Отправка сигнала window.electronAPI.callWinSendSignal({ callId, signal }); // Получение сигнала window.electronAPI.onCallSignal((data) => { if (data.type === 'webrtc_answer') pc.setRemoteDescription(data.sdp); // ... etc });
В main process:
// Forward signal from call window → main window ipcMain.on('webrtc-signal', (_, { callId, signal }) => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send('relay-webrtc-signal', { callId, signal }); }); // Forward incoming WS message → call window ipcMain.handle('send-to-call-window', (_, message) => sendToCallWindowSafe(message));
Главная грабля — call window может быть ещё не готов в момент когда уже приходят сообщения. Если просто слать webContents.send, сообщения теряются. Решение — буферизация:
let callWindowReady = false; let callWindowBuffer = []; function sendToCallWindowSafe(message) { if (!callWindow || callWindow.isDestroyed()) return; if (callWindowReady) callWindow.webContents.send('call-signal', message); else callWindowBuffer.push(message); } // Call window сигналит когда готов получать сообщения ipcMain.on('call-window-ready', () => { callWindowReady = true; if (callWindow && !callWindow.isDestroyed()) { for (const msg of callWindowBuffer) callWindow.webContents.send('call-signal', msg); } callWindowBuffer = []; });
call-window-ready шлётся из call window после полной инициализации UI, не после ready-to-show (это раньше). После этого буфер сливается, и связь идёт напрямую.
Этот буфер — критичен. Без него ~10% звонков обрывались бы на старте, потому что первый offer от вызывающего приходил быстрее чем рендерер успевал инициализировать обработчик.
Deep links на трёх ОС — три разных подхода
Десктоп-приложение должно открываться по ссылкам из браузера: onemixdesktop://chat/abc123 или из ссылки на itpaxlive.ru.
Регистрация протокола одинаковая везде:
if (process.defaultApp) { if (process.argv.length >= 2) { app.setAsDefaultProtocolClient('onemixdesktop', process.execPath, [path.resolve(process.argv[1])]); } } else { app.setAsDefaultProtocolClient('onemixdesktop'); }
А вот обработка на каждой ОС разная.
macOS: есть отдельный event open-url, который шлёт URL когда юзер кликает по ссылке onemixdesktop://.... Приложение может быть запущено или нет — система разберётся.
app.on('open-url', (event, url) => { event.preventDefault(); handleDeepLinkUrl(url); });
Windows/Linux: event open-url тут не работает. Когда юзер кликает по ссылке, ОС запускает приложение заново с URL в process.argv. Если приложение уже запущено, второй экземпляр запустится параллельно — нужна защита.
const gotSingleLock = app.requestSingleInstanceLock(); if (!gotSingleLock) { app.quit(); // Уже запущенный экземпляр получит сигнал } else { app.on('second-instance', (event, argv) => { const url = argv.find(a => a.startsWith('onemixdesktop://')); if (url) handleDeepLinkUrl(url); if (mainWindow) { mainWindow.show(); mainWindow.focus(); } }); } // При cold start — URL приходит в начальном argv const launchUrl = process.argv.find(a => a.startsWith('onemixdesktop://')); if (launchUrl) _pendingDeepLink = launchUrl;
И последний слой — окно может быть ещё не создано в момент когда пришёл deep link:
let _pendingDeepLink = null; function handleDeepLinkUrl(url) { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.show(); mainWindow.focus(); mainWindow.webContents.send('deep-link', url); } else { _pendingDeepLink = url; } } // В createWindow → ready-to-show mainWindow.once('ready-to-show', () => { mainWindow.show(); if (_pendingDeepLink) { setTimeout(() => { mainWindow.webContents.send('deep-link', _pendingDeepLink); _pendingDeepLink = null; }, 1500); // 1.5s — даём время на инициализацию рендерера } });
Три ОС, три разные стратегии — и все они должны работать одновременно, иначе ссылки на каком-то из них поломаются.
Перехват навигации и web requests
Из file:// (откуда грузится HTML) запросы к https://itpaxlive.ru/* идут как cross-origin. WebSocket работает, но <img src="https://itpaxlive.ru/avatar.jpg"> запросы должны идти с Authorization-заголовком, который у нас в localStorage.
Решение — middleware на webRequest:
mainWindow.webContents.session.webRequest.onBeforeSendHeaders( { urls: ['https://itpaxlive.ru/*'] }, (details, callback) => { const sessionData = _readSessionFile(); const token = sessionData?.token; const headers = { ...details.requestHeaders }; if (token && !headers['Authorization']) { headers['Authorization'] = `Bearer ${token}`; } callback({ requestHeaders: headers }); } );
_readSessionFile() читает токен из app.getPath('userData')/session.json — это безопаснее чем localStorage, потому что не сбросится при clearing browser data.
Заодно перехватываем навигацию — если юзер кликает по ссылке https://itpaxlive.ru/chat/abc, не уводим браузер прочь, а превращаем в deep link:
mainWindow.webContents.on('will-navigate', (event, url) => { if (url.startsWith('file://')) return; try { const u = new URL(url); if (u.hostname.includes('itpaxlive')) { event.preventDefault(); const deepUrl = 'onemixdesktop:/' + u.pathname; mainWindow.webContents.send('deep-link', deepUrl); return; } } catch {} if (url.startsWith('onemixdesktop://')) { event.preventDefault(); mainWindow.webContents.send('deep-link', url); return; } // Все остальные внешние ссылки — открываем в браузере event.preventDefault(); shell.openExternal(url); });
Без этого юзер кликает по ссылке "посмотреть профиль" и теряет своё приложение — file:// уходит и грузится https://itpaxlive.ru/.... Возврата обратно в десктоп уже нет.
Tray-first: close не выходит из приложения
Любой мессенджер на десктопе должен жить в tray. Closing window — это не quit, это hide. Quit бывает только при явном выборе пользователя ("Выход" в меню tray).
mainWindow.on('close', (e) => { if (!isQuitting) { e.preventDefault(); mainWindow.hide(); if (!mainWindow._trayHintShown && tray) { mainWindow._trayHintShown = true; if (process.platform === 'win32') { tray.displayBalloon({ title: 'OneMix работает в фоне', content: 'Нажмите на иконку в трее чтобы открыть.', icon: TRAY_ICON_PATH, }); } } } else { mainWindow = null; } }); app.on('window-all-closed', () => { if (isQuitting) app.quit(); // Иначе не выходим — приложение живёт в tray });
Balloon-подсказка показывается только один раз — _trayHintShown флаг. Иначе при каждом закрытии окна юзер будет видеть подсказку, что раздражает.
Tray-иконка ведёт себя как в Telegram Desktop: левый клик показывает/скрывает окно, правый — контекстное меню. Меню показывает количество непрочитанных:
function buildTrayMenu(unread = 0) { const label = unread > 0 ? `OneMix (${unread} непрочитанных)` : 'OneMix'; const menu = Menu.buildFromTemplate([ { label, enabled: false }, { type: 'separator' }, { label: 'Открыть OneMix', click: () => { mainWindow.show(); mainWindow.focus(); } }, { type: 'separator' }, { label: 'Выход', click: () => { isQuitting = true; app.quit(); } }, ]); if (tray) tray.setContextMenu(menu); } ipcMain.on('set-badge', (_, count) => { if (process.platform === 'darwin') app.dock?.setBadge(count > 0 ? String(count) : ''); buildTrayMenu(count); if (tray) tray.setToolTip(count > 0 ? `OneMix — ${count} непрочитанных` : 'OneMix Messenger'); });
На macOS — dock badge. На Windows/Linux — tray tooltip + изменённое меню. Платформенные особенности унифицируются одним IPC-вызовом из renderer'а: electronAPI.setBadge(7).
Auto-updater с фильтрацией silent ошибок
electron-updater — это must-have для десктоп-мессенджера. Пользователи не любят сами ходить за обновлениями.
Базовая интеграция:
let autoUpdater = null; try { const eu = require('electron-updater'); autoUpdater = eu.autoUpdater; autoUpdater.logger = null; autoUpdater.autoDownload = false; // спрашиваем юзера, потом качаем autoUpdater.autoInstallOnAppQuit = true; } catch (e) { // electron-updater not installed (dev environment) }
try/catch вокруг require — это критично для dev-окружения. В dev мы не хотим тащить тяжёлую зависимость и не хотим чтобы updater пытался искать релизы.
Сам сервер обновлений — generic-провайдер с моим бэкендом:
"publish": [ { "provider": "generic", "url": "https://onemix.me/updates/onemix", "channel": "latest" } ]
На бэкенде раздаются три файла: latest.yml с метаданными, OneMix-Setup-1.2.0.exe (Windows), OneMix-1.2.0.dmg (macOS), OneMix-1.2.0.AppImage (Linux). electron-builder собирает эти артефакты автоматически.
Главная грабля auto-updater'а — silent errors. По умолчанию любая ошибка проверки обновлений показывается пользователю как алерт. Но 404 от сервера обновлений (вышел из строя, не залит ещё), ENOTFOUND (нет интернета), ECONNREFUSED (фаервол) — это не ошибки которые юзеру нужно видеть. Юзер должен видеть только реальные проблемы: "обновление найдено, но не качается", "обновление повреждено".
autoUpdater.on('error', (err) => { const msg = err.message || ''; const isSilent = msg.includes('404') || msg.includes('ENOTFOUND') || msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT') || msg.includes('net::ERR') || msg.includes('getaddrinfo'); if (isSilent) { console.log('[updater] silenced error:', msg); return; // не беспокоим пользователя } mainWindow.webContents.send('update-error', msg); });
И последнее тонкое место — quitAndInstall() на Windows. NSIS-инсталлер пытается заменить файлы приложения, но если эти файлы открыты (а они открыты — приложение запущено), Windows блокирует операцию.
Решение — уничтожить все окна перед quitAndInstall, дать Windows секунду освободить handles, и только потом запускать installer:
ipcMain.on('update-install-now', () => { if (autoUpdater) { isQuitting = true; // Уничтожаем трей и все окна — Windows освободит file handles try { if (tray) { tray.destroy(); tray = null; } } catch {} try { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.destroy(); } catch {} try { if (settingsWindow && !settingsWindow.isDestroyed()) settingsWindow.destroy(); } catch {} setTimeout(() => { autoUpdater.quitAndInstall(false, true); }, 500); } });
500мс — эмпирически подобранное число. Меньше — иногда NSIS падает с "файл занят". Больше — юзер успевает заметить что приложение закрылось перед апдейтом.
Что бы я сделал по-другому
Code signing с самого начала. Я долго откладывал подписание билдов для Windows (нужен EV-сертификат, ~$300-500/год). Без подписи Windows SmartScreen показывает страшное окно "программа из ненадёжного источника", и часть пользователей не устанавливает. На macOS без подписи Apple Notarization приложение в принципе не запустится. Это огромный конверсионный leak, который я игнорировал слишком долго.
Структуру кода под TypeScript. vanilla JS в main.js когда файл достиг 800 строк — это уже сложно поддерживать. Refactor на TypeScript с типизированными IPC-каналами — следующий большой шаг. Сейчас IPC channel name — это магическая строка, опечатки ловятся только в runtime.
Использовать electron-store для session storage. У меня свой readSessionFile / writeSessionFile через прямой fs. Работает, но не атомарно — теоретически возможна потеря данных при сбое во время записи. electron-store даёт atomic writes из коробки.
Тестировать на Linux раньше. Я тестировал на macOS и Windows, Linux добавил в последнюю очередь. И обнаружил что на некоторых GTK-окружениях tray-иконка просто не появляется (старая проблема Electron на Linux). Если бы тестировал раньше — мог бы выбрать другой подход (например, libappindicator).
Итог
Vanilla Electron для мессенджера получился оптимальным решением. Не самым модным, но самым подходящим под задачу. Главный выигрыш — простота: 27 строк зависимостей вместо 200, нет сборщика, дев-цикл electron . без watcher'ов.
Главный проигрыш — потолок сложности. Когда мессенджер вырастет до уровня Telegram Desktop с медиа-вьюером, видео-плеером, advanced настройками — vanilla перестанет масштабироваться. Тогда придёт время рефакторинга на TypeScript + какой-то фреймворк. Но это будет тогда, не сейчас.
Если делаете десктоп-приложение и думаете "наверное надо React" — задайте себе вопрос: что реально сложного у вас в UI? Если ответа нет — vanilla даст вам половину работы в карман.
Это четвёртая статья из серии про ONEMIX. В предыдущих: трёхуровневый кэш, Double Ratchet E2E, WebRTC звонки. Следующая — открытый вопрос, есть несколько кандидатов. Если интересна какая-то конкретная тема — напишите в комментариях, выберу по запросам.
Если есть вопросы по конкретным кускам кода, IPC-архитектуре или auto-updater'у — пишите. На самые интересные комментарии готов отвечать развёрнуто.
redzumi
Спасибо за статью! electron-store классный, но не совсем (как мне кажется) прозрачно работают миграции
ПС удалось подписать винду? от кого EV-сертификат?
niktomimo Автор
Спасибо! Про electron-store вы правы, миграции у неё через
migrationsAPI делаются непрозрачно, особенно когда схема нетривиальная. Я как раз поэтому пока остался на своёмreadSessionFile/writeSessionFileпусть не атомарно, зато прозрачно. Возможно перейду на conf это младший брат electron-store от того же автора, без миграций вообще, и под мои задачи (один JSON со session-токеном) её более чем достаточно.Винду пока не подписал это в очереди, но процесс получения EV-сертификата для российского юрлица в 2026 непростой, поэтому откладываю. Если у вас есть свежий опыт поделитесь, было бы полезно.
redzumi
Я в целом сижу на electron-store, миграции плюс-минус работают, но все больше желания через стор написать свою обвязку с флагами версий и обработчикам если версия не проставлена, как раз для миграций
По поводу EV, могу сказать что ssl com РФ документы (очевидно было, но грустно) не принимает, профиль не валидирует, а это как для EV так и для OV нужно по идее, но даже имея оргу. не в РФ, все равно требует документов в адресом (регистрацию) и DUNS, а как альтернативный вариант (2026 год Карл!) предложили прислать бумажное письмо с кодом, но пока тихо
Вроде бы есть у Azure, code signing, но если правильно понял, там далеко не для всех регионов подходит
В общем, сам хотел узнать, вдруг есть какие-то варианты :)