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

Страницы:  1

Ответить
 

Professor Seleznov


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