Уровень: 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'у — пишите. На самые интересные комментарии готов отвечать развёрнуто.

Комментарии (3)


  1. redzumi
    12.05.2026 12:19

    Спасибо за статью! electron-store классный, но не совсем (как мне кажется) прозрачно работают миграции

    ПС удалось подписать винду? от кого EV-сертификат?


    1. niktomimo Автор
      12.05.2026 12:19

      Спасибо! Про electron-store вы правы, миграции у неё через migrations API делаются непрозрачно, особенно когда схема нетривиальная. Я как раз поэтому пока остался на своём readSessionFile/writeSessionFile пусть не атомарно, зато прозрачно. Возможно перейду на conf это младший брат electron-store от того же автора, без миграций вообще, и под мои задачи (один JSON со session-токеном) её более чем достаточно.

      Винду пока не подписал это в очереди, но процесс получения EV-сертификата для российского юрлица в 2026 непростой, поэтому откладываю. Если у вас есть свежий опыт поделитесь, было бы полезно.


      1. redzumi
        12.05.2026 12:19

        Я в целом сижу на electron-store, миграции плюс-минус работают, но все больше желания через стор написать свою обвязку с флагами версий и обработчикам если версия не проставлена, как раз для миграций

        По поводу EV, могу сказать что ssl com РФ документы (очевидно было, но грустно) не принимает, профиль не валидирует, а это как для EV так и для OV нужно по идее, но даже имея оргу. не в РФ, все равно требует документов в адресом (регистрацию) и DUNS, а как альтернативный вариант (2026 год Карл!) предложили прислать бумажное письмо с кодом, но пока тихо

        Вроде бы есть у Azure, code signing, но если правильно понял, там далеко не для всех регионов подходит

        В общем, сам хотел узнать, вдруг есть какие-то варианты :)