Как я сделал desktop-версию мессенджера на vanilla Electron, не на React Native for Desktop. И не пожалел

от автора

Уровень: 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 windowipcMain.on('webrtc-signal', (_, { callId, signal }) => {  if (mainWindow && !mainWindow.isDestroyed())    mainWindow.webContents.send('relay-webrtc-signal', { callId, signal });});// Forward incoming WS message → call windowipcMain.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 приходит в начальном argvconst 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-showmainWindow.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’у — пишите. На самые интересные комментарии готов отвечать развёрнуто.

ссылка на оригинал статьи https://habr.com/ru/articles/1034184/