惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Engineering at Meta
Engineering at Meta
人人都是产品经理
人人都是产品经理
大猫的无限游戏
大猫的无限游戏
博客园 - 三生石上(FineUI控件)
量子位
腾讯CDC
The Cloudflare Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
云风的 BLOG
云风的 BLOG
Vercel News
Vercel News
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
L
LangChain Blog
aimingoo的专栏
aimingoo的专栏
The Hacker News
The Hacker News
T
The Exploit Database - CXSecurity.com
B
Blog
S
SegmentFault 最新的问题
P
Privacy & Cybersecurity Law Blog
T
Threatpost
博客园 - 聂微东
T
Tailwind CSS Blog
The Last Watchdog
The Last Watchdog
C
Check Point Blog
N
Netflix TechBlog - Medium
D
DataBreaches.Net
爱范儿
爱范儿
IT之家
IT之家
S
Secure Thoughts
M
MIT News - Artificial intelligence
NISL@THU
NISL@THU
C
Cisco Blogs
TaoSecurity Blog
TaoSecurity Blog
有赞技术团队
有赞技术团队
A
Arctic Wolf
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
P
Proofpoint News Feed
Spread Privacy
Spread Privacy
Schneier on Security
Schneier on Security
Simon Willison's Weblog
Simon Willison's Weblog
G
GRAHAM CLULEY
雷峰网
雷峰网
Project Zero
Project Zero
博客园 - Franky
H
Heimdal Security Blog
A
About on SuperTechFans
Security Latest
Security Latest
Webroot Blog
Webroot Blog
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Hugging Face - Blog
Hugging Face - Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как я сделал desktop‑версию мессенджера на vanilla Electron, не на React Native for Desktop. И не пожалел
Виктор · 2026-05-12 · via Все публикации подряд на Хабре

Средний

12 мин

9K

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