Всем привет! Меня зовут Евгений Оселедец, я ведущий разработчик в компании Axenix. В этой статье расскажу, как мы упаковали React и Java в единое автономное desktop-приложение с помощью Electron для Windows, Linux и macOS — без Docker, без предустановленной Java у пользователя и без необходимости интернет-соединения. Расскажу, с какими техническими вызовами мы столкнулись, и какие решения сработали на практике.
Снаружи это выглядит как обычное приложение: скачал, установил, запустил. Под капотом — Electron, три JAR-сервиса, собственная JRE, кастомный NSIS-установщик и логика оркестрации всего этого хозяйства. Это статья не про "как красиво встроить веб в desktop", а про инженерную рутину, которая начинается сразу после первой успешной сборки. Покажу путь по шагам: что мы хотели получить, где ошиблись и как исправлялись.
Что будет в статье
добавление Electron к проекту на React и Vite
запуск трёх Java-сервисов из приложения
-
типичные проблемы, которые всплывают сразу или почти сразу:
жизненный цикл процессов(Graceful Shutdown и т.д.)
логирование
отслеживание готовности сервисов(heathcheck)
file://в исходящих запросахJava runtime и хранение данных
в конце фиксируем, что из этого оказалось рабочим вариантом, а что осталось компромиссом
Откуда взялась задача
Наш проект BrOk разрабатывался как обычное веб-приложение для работы с брокерами сообщений и графическим дизайнером интеграционных процессов, но в какой-то момент понадобилась автономная desktop-версия приложения.
NB В статье я буду рассматривать проблемы на примере нашего приложения для наглядности, отдельно про предметную область и боли, которое решает наше приложение можно прочитать в статье моего коллеги тут.
Требования были очень приземлённые:
приложение должно работать без интернета
без Docker
MVP за пару недель
Мы выбирали между Electron и Tauri. В общих чертах у Electron порог входа для веб-разработчиков ниже: тот же JavaScript и Node.js, привычная модель расширения, зато довести размер и потребление ресурсов до идеала обычно сложнее. У Tauri — меньший размер приложения и выше планка безопасности "из коробки", но нужен Rust и понимание нативной части, из‑за чего кривая обучения заметно круче. Остановились на Electron: решающими оказались низкий порог входа и предсказуемая экосистема — для нашей задачи и сроков это давало меньше инженерного риска.
Что именно нужно было упаковать
До появления desktop-версии BrOk жил как обычное веб-приложение: браузерный UI, а backend на Java работал отдельно в инфраструктуре. Когда понадобился автономный запуск "на машине пользователя", состав поставки резко изменился: теперь вместе с UI мы обязаны привезти всю локальную среду выполнения.
В desktop-поставку вошли:
Electron + React
main/preload-слой, который управляет запуском приложения
три Java-сервиса
кастомная JRE
При этом само приложение оставалось довольно "тяжёлым" по функциональности. Упаковывать нужно было не один фронтенд, а локальную среду для нескольких крупных пользовательских сценариев.
Внутри BrOk три крупных модуля: сценарии, брокеры и REST.
Сценарии — графический дизайнер интеграционных процессов с BPMN-подобным графом. Пользователь собирает поток из узлов: отправка и чтение сообщений из брокера, SQL-запросы, паузы, REST-вызовы, Groovy-скрипты, подпроцессы и подсценарии, а также эксклюзивные, инклюзивные и параллельные шлюзы для управления потоком выполнения.

Брокеры — модуль для работы с брокерами сообщений. Сейчас поддерживаются: Kafka (со Schema Registry, KSQLDB и Kafka Connect), RabbitMQ, Redis, NATS, ActiveMQ/Artemis, ETCD и Tarantool. Внутри модуля можно просматривать сообщения с заголовками и полезной нагрузкой, отправлять сообщения вручную или через шаблоны, управлять топиками и очередями.

REST — модуль для выполнения HTTP-запросов, по сути встроенный Postman: коллекции запросов, вкладки для Params/Headers/Body/Script, запуск запросов и просмотр ответов с сохранением истории.

В desktop-поставке backend нашего приложения состоит из трёх Java-сервисов, каждый из которых запускается как отдельный JAR-процесс и слушает свой локальный порт:
brok-core— основной API: пользовательские настройки, работа с брокерами и базой данных, порт21815brok-bpm— движок бизнес-процессов на базе BPMN Camunda, отвечает за блок сценариев, порт21816brok-context— сервис контекста: хранит и обрабатывает переменные, полученные в процессе работы сценария или BPMN-процесса, порт21817
Все запросы из React обрабатываются в brok-core, а уже сервисы общаются между собой. Electron в этой схеме становится не "обёрткой над браузером", а локальным оркестратором: поднимает процессы, следит за их состоянием, пишет логи, показывает загрузочный экран и только потом открывает UI.

Архитектура приложения
В итоге всё связанное с desktop собралось в папке нашего приложения, пусть будет brok-react-app с довольно прямолинейной структурой: React + Vite живут в src/, Electron-слой — в public/, утилиты в public/utils/, а JAR и JRE лежат в jars/.
Ниже структура проекта в том виде, к которому мы пришли после нескольких итераций:
brok-react-app/ ├── public/ │ └── pkg-scripts │ └── postisntall # Скрипт для macOS │ ├── main.cjs # Electron main process. Только оркестрация │ ├── preload.cjs # IPC bridge для экрана загрузки, шорткатов и служебного API │ ├── loading.html # Загрузочный экран с прогрессом загрузки сервисов │ └── utils/ # Утилиты, разбитые на модули для удобства │ ├── getLogger.cjs # Создаёт логгер с записью в файл в папке логов приложения │ ├── attachFileLogging.cjs # Записывает stdout/stderr/exit в файлы с логами │ ├── jarServices.cjs # Старт и завершение Java-сервисов │ ├── killPortsIfInUse.cjs # Освобождение портов 21815–21817 при старте │ ├── createMainWindow.cjs # Создание главного окна │ ├── serviceConfig.cjs # Конфигурация сервисов для health-check │ ├── serviceReadinessManager.cjs # Отслеживание готовности трёх сервисов → onAllReady() │ ├── setupServiceReadinessCheck.cjs # подписка на LOADING_EVENTS → send + setReady │ ├── fetchWithRetry.cjs # В цикле запрашивает url до первого ответа 200 с body.status === 'UP', emit в LOADING_EVENTS │ ├── createLoadingWindow.cjs # Создание окна загрузочного экрана с прогрессом сервисов │ ├── startWithLoadingFlow.cjs # did-finish-load → setup → fetchWithRetry │ ├── setupIpcAndShortcuts.cjs # open-logs-folder, get-app-version, шорткат Ctrl+Shift+L │ └── clearLogsOnStart.cjs # очистка папки логов при каждом запуске ├── src/ # React app ├── jars/ # В целевой версии папка jars/ в .gitignore, т.к. её готовит CI │ ├── brok-jre/ # Кастомная JRE │ ├── brok-core.jar │ ├── brok-bpm.jar │ └── brok-context.jar ├── package.json # Vite + electron-builder ├── openapitools.json # Генерация API клиентов из OpenAPI └── vite.config.ts # Vite конфиг
С чего всё началось: добавили Electron к React и Vite
Стартовая точка была максимально простой: обычный фронтенд на React, Vite и TypeScript. Первый шаг — добавить Electron и научить проект жить в двух режимах:
поднимает Vite и Electron
сборка production версии react-приложения + сборка desktop-приложения.
На этом этапе всё ещё выглядит слишком просто. Electron открывает окно, Vite отдаёт фронт, жизнь прекрасна. Но пока это просто "вьюшка", backend ещё нигде не запускается.
Дальше захотелось главного: поднимать три JAR-сервиса при старте
Пользователь запускает desktop-приложение, а вместе с ним автоматически стартуют все три Java-сервиса.
Для этого нужно было сделать две вещи:
каким-то образом упаковать Java-сервисы в бандл
научить Electron-приложение запускать то, что мы запаковывали.
Чтобы все три JAR-файла попали в установленное приложение, мы добавили их в extraFiles:
// package.json { "build": { "extraFiles": [ { "from": "jars", "to": "resources/jars", "filter": ["**/*"] } ] } }
После этого можно приступить к запуску Java-сервисов. Решение появилось сразу, ну или почти сразу. Поискав на githab похожий кейс как у нас, стало очевидным необходимость создания подпроцессов и использование команды spawn() из Node.js. Сразу отмечу, что эта команда программный аналог обычного запуска в bash. Она может использоваться для запуска сторонних программ или команд (например,ls, git, python или другого скрипта Node.js, в нашем случае java), как отдельных процессов операционной системы. Таким образом, Java-сервис можно запустить с помощью bash и предустановленной Java.
Аналог команды в bash:
java -jar pathToJar/brok-core.jar
А используя spawn():
const childProcess = spawn('java', ['-jar', 'pathToJar/brok-core.jar']);
Обратите внимание, первый параметр — команда, а второй — массив аргументов. Мы можем передавать туда все, что могли в bash. Никто нам не запрещает сделать так:
const CORE_ARGS = [ '-Xms128m', '-Xmx256m', '-Dfile.encoding=UTF-8', '-Djava.security.manager=allow', '-Ddatasource.core.url=jdbc:h2:' + path.join(app.getPath('appData'), 'BrOk', 'db', 'brok'), '-Dbrok.coreJarPath=' + path.join(!app.isPackaged ? app.getAppPath() : process.resourcesPath, 'jars', 'brok-core.jar'), '-Djava.security.policy==' + path.join(!app.isPackaged ? app.getAppPath() : process.resourcesPath, 'policy', 'brok-core.policy'), '-Dsun.misc.URLClassPath.disableJarChecking=true', '-jar', path.join(!app.isPackaged ? app.getAppPath() : process.resourcesPath, 'jars', 'brok-core.jar') ]; const childProcessCore = spawn('java', CORE_ARGS );
В базовом примере команда запускает Java-приложение из JAR-файла как дочерний процесс(ChildProcess) в Node.js. Объект ChildProcess позволяет взаимодействовать с процессом: читатьstdout/stderr через события, передавать данные в stdin или отслеживать завершение.
Примеры взаимодействий с процессом:
// Передаём на вход stdin childProcess.stdin.write('Это случайная строка') // Данные из stdout childProcess.stdout.on('data', (data) => { console.log(data.toString()) }) // Данные из stderr childProcess.stderr.on('data', (data) => { console.error(data.toString()) }); // Корректное завершение процесса childProcess.on('close', (code) => { console.log(`[childProcess] завершен с кодом: ${code}`) }) // Процесс завершен с ошибкой childProcess.on('exit', (code, signal) => { console.log(`[childProcess] завершен с ошибкой. Код: ${code}, сигнал: ${signal}`) }) // Ошибка при запуске childProcess.on('error', (error) => { console.error(error) })
Вернемся к приложению. Сразу предлагаю вынести запуск в отдельный модуль:
// utils/jarServices.cjs const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); function getResourcesRoot(app) { return app.isPackaged ? process.resourcesPath : app.getAppPath(); } function getJarPaths(app) { const jarsDir = path.join(getResourcesRoot(app), 'jars'); return { core: path.join(jarsDir, 'brok-core.jar'), bpm: path.join(jarsDir, 'brok-bpm.jar'), context: path.join(jarsDir, 'brok-context.jar') }; } function startService(app, jarPath) { if (!fs.existsSync(jarPath)) { throw new Error(`JAR not found: ${jarPath}`); } return spawn('java', ['-jar', jarPath], { stdio: ['ignore', 'pipe', 'pipe'] }); }
А в main.cjs это выглядит совсем просто:
// public/main.cjs app.whenReady().then(() => { const jarPaths = getJarPaths(app); processCore = startService(app, jarPaths.core); processBpm = startService(app, jarPaths.bpm); processContext = startService(app, jarPaths.context); });
Первый запуск даёт опасное чувство победы: окно открылось, процессы стартовали, значит схема работает. Но первый же повторный запуск быстро возвращает в реальность.
NB: первый успешный старт почти ничего не доказывает. Настоящие проблемы у такого desktop-приложения начинаются на повторном запуске, обновлении и первой установке у реального пользователя.
Фейл 1. Закрыли окно, а порты всё ещё заняты
После закрытия приложения мы запускали его снова и получали ошибку:

Причина оказалась очень простая: мы закрывали окно Electron, но не завершали дочерние Java-процессы. Они продолжали жить в фоне и держали порты.
NB: как только Electron начинает запускать backend как дочерние процессы, он становится локальным оркестратором. Если не взять на себя этот кусок жизненного цикла, приложение ломается буквально на втором запуске.
Дальше пришлось наводить порядок: жизненный цикл процессов и очистка портов
Исправление состояло из двух частей:
при штатном выходе явно завершать все запущенные процессы;
при старте дополнительно освобождать наши порты на случай прошлого нештатного падения.
Для завершения процессов хорошо подошёл tree-kill, потому что на Windows важно убивать не только PID, но и дерево процессов. Для освобождения портов - kill-port.
const kill = require('tree-kill'); const killPort = require('kill-port'); const PORTS = [21815, 21816, 21817]; function killAllProcesses(processes) { processes.forEach((proc) => { if (proc?.pid) { kill(proc.pid, (error) => { if (error) { console.error('kill error', error); } }); } }); } async function killPortsIfInUse() { for (const port of PORTS) { try { await killPort(port, 'tcp'); } catch (_) { // Порт уже свободен } } } app.whenReady().then(async () => { await killPortsIfInUse(); const jarPaths = getJarPaths(app); processCore = startService(app, jarPaths.core); processBpm = startService(app, jarPaths.bpm); processContext = startService(app, jarPaths.context); }); app.on('will-quit', () => { killAllProcesses([processCore, processBpm, processContext]); });
Утилита kill-port здесь выступает как страховка после нештатного завершения. Решение рабочее, но агрессивное: оно оправдано только если вы уверены, что эти локальные порты принадлежат именно вашему приложению и не пересекаются с чужими сервисами на машине пользователя.
После этого приложение перестало спотыкаться о собственные хвосты. Но вылезла следующая проблема, уже менее заметная на машине разработчика и намного более болезненная в эксплуатации.
NB: если Electron запускает backend, он обязан вести себя как менеджер процессов. Иначе вы получаете красивый UI поверх хаотично живущих фоновых процессов.
Фейл 2. Без логов поддержка превращается в гадание
Первый релиз очень быстро показал, что desktop без логов поддерживать нельзя.
Пока приложение запускаешь из терминала, можно посмотреть в stdout и stderr. Но у обычного пользователя нет привычки открывать .exe из консоли и потом присылать вам понятный вывод. В какой-то момент вы просто получаете сообщение в духе "ничего не работает", и всё.

На этом этапе стало понятно, что логирование нужно было закладывать с самого начала.
Следующий обязательный слой: пишем stdout/stderr каждого сервиса в файлы
Мы вынесли логирование в отдельные модули:
getLogger.cjs- создаёт логгер и настраивает путь;attachFileLogging.cjs- подписывается наstdout,stderrиexit;jarServices.cjs- вызываетattachFileLogging()сразу послеspa).
const { app } = require('electron'); const log = require('electron-log/main'); const path = require('path'); log.initialize(); function getLogger(logId, fileName) { const logger = log.create({ logId }); logger.transports.file.resolvePathFn = () => path.join(app.getPath('logs'), fileName); return logger; } function attachFileLogging(proc, serviceName) { if (!proc) return; const outLog = getLogger(`${serviceName}-out`, `${serviceName}.log`); const errLog = getLogger(`${serviceName}-err`, `${serviceName}-error.log`); const exitLog = getLogger('app-exit', 'app-exit.log'); outLog.info(`[${serviceName}] start`); proc.stdout?.on('data', (chunk) => outLog.info(chunk.toString().trim())); proc.stderr?.on('data', (chunk) => errLog.info(chunk.toString().trim())); proc.on('exit', (code, signal) => { exitLog.info(`${serviceName} exited: code=${code}, signal=${signal}`); }); }
После этого у нас появились понятные файлы вроде:
brok-(core/bpm/context).log;brok-(core/bpm/context)-error.log;app-exit.log.
Поддержка сразу стала проще: уже не нужно было начинать диагностику с вопроса, стартовал ли сервис вообще, упал ли он сразу после запуска и что именно произошло внутри JVM.
Но стоило добавить логи, как всплыл следующий побочный эффект.
NB: логирование в desktop-приложении - это не удобный бонус, а минимальная цена за нормальную поддержку после первого релиза.
Фейл 3. Логи копятся, захламляются и мешают разбираться в текущем запуске
Если просто писать в одни и те же файлы, то через несколько запусков папка логов превращается в исторический архив, в котором очень неудобно отделять актуальную сессию от старых.
Для продвинутой ротации можно построить более сложную схему, но нам на этом этапе нужен был прагматичный, воспроизводимый и легко объяснимый вариант.
После этого пришлось подчистить хвосты: чистим папку логов при старте
Мы решили задачу грубо, но эффективно: при старте приложения удаляем папку логов целиком, а затем даём логгеру создать её заново.
const fs = require('fs'); function clearLogsOnStart(app) { const logsPath = app.getPath('logs'); try { fs.rmSync(logsPath, { recursive: true, force: true }); } catch (error) { if (error.code !== 'ENOENT') { console.warn('[clearLogsOnStart]', error.message); } } }
Компромисс очевидный: история прошлой сессии теряется. Зато если пользователь прислал логи "после последнего запуска", вы почти наверняка смотрите именно на нужный запуск, а не на смесь текущих и недельной давности сообщений.
Фейл 4. UI открывается быстрее, чем успевает подняться backend
После того как процессы начали стабильно запускаться и писать логи, вскрылась следующая проблема.
Мы открывали главное окно сразу после spawn(), а JAR сервисам ещё требовалось время на инициализацию. В результате React успевал отправить первые запросы, а backend ещё не слушал порты. Пользователь видел ошибки до того, как приложение фактически было готово.
Сделать setTimeout(10000) было бы соблазнительно, но это плохой путь:
на медленных машинах backend может стартовать дольше;
на быстрых пользователь будет просто ждать лишние секунды;
таймаут ничего не говорит о реальной готовности сервисов.
Делаем запуск человеческим: загрузочный экран и health-check вместо слепого ожидания
Правильнее было проверять не "сколько прошло времени", а "готовы ли сервисы на самом деле". Для этого у каждого сервиса должен быть health-эндпоинт, например Spring Boot Actuator /actuator/health.
Конфиг сервисов мы вынесли в один модуль:
const SERVICE_CONFIG = { CORE: { serviceName: 'core', url: 'http://localhost:21815/actuator/health', progressEvent: 'progressCore', finishedEvent: 'finishedCore' }, BPM: { serviceName: 'bpm', url: 'http://localhost:21816/actuator/health', progressEvent: 'progressBpm', finishedEvent: 'finishedBpm' }, CONTEXT: { serviceName: 'context', url: 'http://localhost:21817/actuator/health', progressEvent: 'progressContext', finishedEvent: 'finishedContext' } };
Дальше логика простая:
создаём
loading.htmlкак загрузочный экран;циклически опрашиваем health-эндпоинты;
по IPC прокидываем в загрузочный экран статус каждого сервиса;
открываем основное окно только после того, как все сервисы ответили
status: 'UP'.
Ключевой кусок кода выглядит так:
async function fetchWithRetry(url, { progressEvent, finishedEvent }) { while (true) { try { const response = await fetch(url); const body = await response.json(); if (response.status === 200 && body.status === 'UP') { LOADING_EVENTS.emit(progressEvent, true); LOADING_EVENTS.emit(finishedEvent); return; } } catch (_) { // Сервис ещё не готов } LOADING_EVENTS.emit(progressEvent, false); await new Promise((resolve) => setTimeout(resolve, 2000)); } }
С пользовательской стороны это просто загрузочный экран с тремя индикаторами. С инженерной стороны важнее другое: не размазать ответственность между main, preload и renderer.

Если разложить это по слоям, связь между main process и renderer process выглядит так:


Main process остаётся единственным местом, которое знает о состоянии backend. Он поднимает процессы, запускает health-check и через внутренние события прокидывает в окно загрузки только прогресс. renderer не знает про child_process, а preload даёт ему узкий и безопасный IPC API:
const progressChannels = Object.values(SERVICE_CONFIG).map( ({ progressEvent }) => progressEvent ); contextBridge.exposeInMainWorld('electronAPI', { on: (channel, callback) => { if (progressChannels.includes(channel)) { ipcRenderer.on(channel, (_, status) => callback(status)); } } });
Дальше всё довольно прозрачно: по событию прогресса обновляем индикатор конкретного сервиса, по событию завершения помечаем сервис готовым, а главное окно создаём только после того, как получили успешный сигнал от всех трёх сервисов.
Ниже флоу запуска, который в итоге оказался рабочим:

Фейл 5. Загрузочный экран есть, а статусы сервисов не меняются
В какой-то момент загрузочный экран открывался, но индикаторы не обновлялись, хотя backend уже был готов к работе.
Проблема оказалась не в health-check, а в preload. Мы пытались тянуть туда конфиг через относительный require('./utils/...'), и при загрузке loading.html это ломало экспонирование window.electronAPI. В результате страница загрузки вообще не подписывалась на IPC-события.
Рекомендации для корректной обработки статуса:
preload должен быть максимально простым и стабильным;
список разрешённых каналов лучше держать прямо в preload, а не тащить сложные зависимости;
порядок во флоу запуска должен быть строгим: сначала подписки, потом первые
emit.
Это тот случай, когда баг внешне выглядит как обычный и просто нет индикации загрузки сервисов, а по сути вскрывает реальную хрупкость жизненного цикла приложения.
Фейл 6. В браузере всё работает, а в собранном Electron запросы ломаются
Когда приложение работает через Vite, запросы к API, в нашем случае /core/... живут в привычной для фронтенда модели: фронтенд на localhost:5173, backend на localhost:21815, CORS настроен, origin совпадает. После сборки Electron загружает UI по file:// — у такого URL нет origin, и относительные пути вида /core/... теряют базу для разрешения. Запросы уходят в никуда.
В проекте уже использовался openapi-generator, поэтому для решения проблемы с file:// добавили middleware, который работает поверх сгенерированных клиентов и подменяет базовый URL для desktop-режима. Результат: одинаковые запросы работают в dev-режиме Electron, в браузерном dev-режиме и в собранном приложении.
У этой проблемы был и более фундаментальный путь решения: уйти с file:// на собственный протокол, например app://. Для нового desktop-проекта я бы уже всерьёз смотрел в эту сторону.
В упрощённом виде это выглядит так:
const { app, protocol, net } = require('electron'); const path = require('path'); const { pathToFileURL } = require('url'); protocol.registerSchemesAsPrivileged([ { scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } } ]); app.whenReady().then(() => { protocol.handle('app', (request) => { const filePath = request.url.slice('app://'.length); return net.fetch(pathToFileURL(path.join(__dirname, filePath)).toString()); }); });
Такой подход особенно хорош, если вы проектируете desktop-приложение с нуля и хотите, чтобы UI жил не на file://, а на нормальной схеме уровня app://bundle/index.html.
Но в нашем случае это означало бы рефакторинг способа загрузки всего UI. Нам нужно было быстро и предсказуемо починить доступ фронтенда к локальному API, поэтому мы выбрали более локальное решение через middleware, а не полную миграцию на кастомный протокол.
Если упростить выбор, то он выглядит так:
кастомный протокол хорош, когда вы контролируете весь способ доставки UI и готовы строить desktop-схему "с нуля";
middleware хорош, когда UI уже существует, а вам нужно быстро и безопасно выровнять только доступ к локальному API.
Генерация клиента выглядит так:
openapi-generator-cli generate \ -i ../brok-backend/brok-core/openapi.yaml \ --generator-name typescript-fetch \ --output src/generated-sources/core
А сама идея middleware предельно простая, например, если путь начинается с /core, подставляем локальный backend-хост или тоже самое, но анализируя file://
import type { RequestContext, FetchParams } from '../generated-sources/core/runtime'; const CORE_ORIGIN = 'http://localhost:21815'; /** * Всегда перенаправляет запросы к /core на localhost:21815. * - В Electron (file://) относительные запросы не имеют хоста — подставляем 21815. * - В dev (http://localhost:5173) без подмены запрос шёл бы на 5173; Vite proxy мог не срабатывать, * поэтому тоже отправляем на 21815. Бэкенд (brok-core) настроен с CORS для localhost:5173 и null. */ export class InternalRequestMiddleware { public async pre(context: RequestContext): Promise<FetchParams | void> { if (typeof window === 'undefined') return; const path = context.url.startsWith('http') ? new URL(context.url).pathname : context.url; if (!path.startsWith('/core')) return; return { url: `${CORE_ORIGIN}${path}`, init: { ...context.init, headers: new Headers(context.init?.headers ?? {}), }, }; } }
Благодаря этому запросы одинаково работают:
в dev-режиме Electron
в браузерном dev-режиме
в собранном приложении с
file://
Фейл 7. Системная Java у пользователей ломает воспроизводимость
Пока всё запускалось на машинах разработчиков, запуск сервисов командой spawn('java', ...) казалось нормальной идеей. Но через несколько релизов начали прилетать уже совсем другие проблемы:
Java не установлена;
установлена не та версия;
PATHуказывает не туда;один и тот же JAR ведёт себя по-разному на разных машинах.
Пока всё это происходит внутри команды, проблему легко недооценить. Как только приложение уезжает во внешний контур, java -jar перестаёт быть технической деталью и становится частью дистрибутива.
Это был тот самый момент, когда стало ясно: полагаться на системную JVM в desktop-приложении нельзя, если вам нужна предсказуемость поставки.
Убираем необходимость предустановленной Java
Мы собрали кастомную JRE через jlink, положили её рядом с JAR и перестали зависеть от Java у пользователя.
Команда сборки выглядела так:
jlink \ --add-modules java.base,java.logging,java.management,java.naming,java.net.http,java.sql,java.transaction.xa,java.xml,jdk.unsupported,java.desktop,java.security.jgss,java.instrument,java.rmi,java.scripting,java.compiler,jdk.crypto.ec,jdk.crypto.cryptoki,jdk.dynalink,java.security.sasl,jdk.naming.dns \ --output brok-jre \ --strip-debug \ --no-man-pages \ --no-header-files \ --compress=2
После этого логика запуска меняется совсем чуть-чуть, но меняет всё по сути:
function getJavaPath(app) { const javaFromBundle = path.join( getResourcesRoot(app), 'jars', 'brok-jre', 'bin', process.platform === 'win32' ? 'java.exe' : 'java' ); return fs.existsSync(javaFromBundle) ? javaFromBundle : 'java'; }
Теперь:
Java не нужна на машине пользователя;
runtime всегда одинаковый;
установка становится воспроизводимой;
количество "магических" багов резко снижается.
Компромисс здесь очевидный: дистрибутив растёт, а сборка усложняется. Но если задача — офлайн desktop с предсказуемым поведением, это хорошая сделка.
Теперь схема приложения выглядит следующим образом:

Фейл 8. Нельзя хранить пользовательские данные рядом с приложением
Следующая ошибка уже не про запуск, а про операционную корректность.
В первых версиях база данных жила внутри папки установленного приложения. На Windows это плохая идея по нескольким причинам:
папка приложения может перезаписываться при обновлении;
доступ на запись туда не всегда предсказуем;
пользовательские данные оказываются не там, где этого ожидает ОС.
Иными словами, приложение вроде бы работает, но при неудачном обновлении пользователь рискует потерять данные.
Патч для Windows: выносим БД в %APPDATA% и мигрируем её через NSIS
Исправление мы сделали на уровне установщика. Если при обновлении обнаруживаем старые .db в папке приложения, переносим их в %APPDATA%\BrOk\db, а новые установки уже изначально работают с этим путём.
Если нарисовать эту часть совсем кратко, получается такой путь:

Эта схема простая, но она хорошо показывает самую важную мысль: данные пользователя должны жить отдельно от установленного приложения, а миграция должна происходить прозрачно.
Это не самый "романтичный" фрагмент статьи, но с практической точки зрения один из самых важных: именно здесь desktop-приложение начинает корректно обращаться с пользовательскими данными.
Пользователь при этом вообще не обязан знать, что произошла миграция. Для него важен только результат: обновление не уничтожает локальную базу, а новая версия подхватывает данные прозрачно.
Пример NSIS-скрипта:
; Оставь надежду всяк сюда входящий Function CheckAndCopyDBFiles IfFileExists "$APPDATA\BrOk\db\*.db" skipMove IfFileExists "$INSTDIR\resources\database\*.db" 0 skipMove CreateDirectory "$APPDATA\BrOk\db" FindFirst $0 $1 "$INSTDIR\resources\database\*.db" loop: StrCmp $1 "" exitloop CopyFiles /SILENT "$INSTDIR\resources\database\$1" "$APPDATA\BrOk\db\$1" Delete "$INSTDIR\resources\database\$1" FindNext $0 $1 Goto loop exitloop: FindClose $0 skipMove: FunctionEnd !macro customInstall Call CheckAndCopyDBFiles !macroend
Распространение macOS-версии без Apple Developer
Основной сценарий у нас был Windows-first, часть решений нормально переносилась на Linux, а вот macOS быстро напомнила, что кроссплатформенная сборка и кроссплатформенная поставка - не одно и то же.
Для централизованной дистрибуции на macOS нужна нормальная подпись и вся сопутствующая бюрократия Apple. В нашем случае пройти этот путь не получилось, поэтому для внутренней поставки использовали обходной вариант: pkg со скриптом
#!/bin/bash # Postinstall script for BrOk macOS package (brok-demo: BrOk Demo.app) # Runs after the application is installed. Sets permissions, removes quarantine, ad-hoc signs. LOG_FILE="/tmp/brok-postinstall.log" exec > "$LOG_FILE" 2>&1 echo "=== BrOk Post-Install Script Started ===" echo "Date: $(date)" echo "User: $(whoami)" echo "Arguments: $@" echo "" echo "$1: Full path to the installer package" echo "$2: Full path to the installation location (e.g. /Applications)" echo "$3: Mountpoint of the installation disk" echo "$4: Root directory of currently booted system" echo "" # Имя приложения (должно совпадать с productName в package.json: BrOk Demo → BrOk Demo.app) APP_NAME="BrOk Demo.app" APP_PATH="$2" echo "Installation directory: $APP_PATH" if [ -z "$APP_PATH" ]; then APP_PATH="/Applications/$APP_NAME" echo "No install dir provided, using default: $APP_PATH" elif [[ "$APP_PATH" == *.app ]]; then echo "Using provided path: $APP_PATH" else APP_PATH="$APP_PATH/$APP_NAME" echo "Appending app name to path: $APP_PATH" fi if [ -d "$APP_PATH" ]; then echo "Found application at: $APP_PATH" echo "Setting permissions..." chmod -R 755 "$APP_PATH" && echo "Permissions set successfully" || echo "Failed to set permissions" echo "Removing quarantine attribute..." xattr -r -d com.apple.quarantine "$APP_PATH" 2>&1 && echo "Quarantine removed" || echo "No quarantine to remove" ls -la@ "$APP_PATH" echo "Adding ad-hoc signature..." codesign --force --deep --sign - "$APP_PATH" 2>&1 && echo "Signed successfully" || echo "Signing failed" echo "Post-installation completed successfully." else echo "ERROR: Application not found at $APP_PATH" ls -la "$(dirname "$APP_PATH")" 2>&1 || echo "Parent directory doesn't exist" fi echo "=== BrOk Post-Install Script Completed ===" echo "" logger "BrOk postinstall completed. Check $LOG_FILE for details" exit 0
после установки, который снимает типичные блокировки для неподписанного приложения:
выставляет права
снимает
quarantineделает ad-hoc подпись через
codesign
Технически это работает, но важно назвать ограничение прямо: это ненормальный путь для массовой внешней дистрибуции. Для внутренней поставки или ограниченного круга пользователей это приемлемый компромисс. Для широкого распространения всё равно придётся идти в сторону нормальной подписи.
Что ещё сильно помогло в реальной эксплуатации
Когда основные проблемы были закрыты, больше всего в поддержке окупились уже не крупные архитектурные решения, а небольшие эксплуатационные детали:
шорткат
Ctrl+Shift+L/Cmd+Shift+Lдля открытия папки логов;-
версия приложения прямо на загрузочном экране;
/** * IPC-обработчики (открытие папки логов, версия приложения) и глобальный шорткат. * Регистрация в whenReady, снятие шорткатов в will-quit. */ const { ipcMain, shell, dialog, globalShortcut } = require('electron'); /** * Регистрирует IPC-обработчики и глобальный шорткат Ctrl/Cmd+Shift+L (открытие папки логов). * @param {import('electron').App} app */ function setupIpcAndShortcuts(app) { const logsPath = app.getPath('logs'); ipcMain.handle('open-logs-folder', async () => { try { await shell.openPath(logsPath); } catch (err) { console.error('Ошибка при открытии папки с логами:', err); dialog.showErrorBox('Ошибка', `Не удалось открыть папку с логами: ${err.message}`); throw err; } }); ipcMain.handle('get-app-version', () => app.getVersion()); globalShortcut.register('CommandOrControl+Shift+L', () => { shell.openPath(logsPath).catch((err) => { console.error('Ошибка при открытии папки с логами (шорткат):', err); dialog.showErrorBox('Ошибка', `Не удалось открыть папку с логами: ${err.message}`); }); }); } function unregisterShortcuts() { globalShortcut.unregisterAll(); } module.exports = { setupIpcAndShortcuts, unregisterShortcuts };И далее вызываем в
main.cjs:// main.cjs app.whenReady().then(async () => { setupIpcAndShortcuts(app); })
жёсткое правило:
main.cjsтолько оркестрирует, логика живёт вutils;явное разделение ответственности между модулями: запуск и остановка, ведение логов, проверка готовности, основной цикл работы и процесс установки/обновления
Именно такие вещи экономят часы не в разработке, а в диагностике и поддержке.
Что получилось в итоге
В нашем случае Electron, React, три JAR-сервиса и JRE дают размер порядка 500 MB в зависимости от платформы и того, насколько агрессивно вы режете JRE. Цена получилась заметной по размеру дистрибутива, но она убрала зависимость от системной Java и сделала запуск ожидаемо воспроизводимым.
На выходе получилось приложение со своим жизненным циклом:
пользователь ставит приложение без отдельной Java
backend стартует локально вместе с UI
UI не открывается раньше, чем backend проходит health-check
есть нормальные логи для поддержки
данные не теряются при переустановке
поведение воспроизводимо, потому что runtime контролируем мы
Цена решения тоже понятна:
дистрибутив тяжёлый
вся оркестрация в
Main processзаметно сложнее, чем обычный web-flowплатформенные хвосты никуда не исчезают, особенно на macOS.
Что бы я сделал иначе, если бы начинал заново
Если собрать весь опыт в короткий список, он будет таким:
не надеяться на системную Java;
сразу проектировать жизненный цикл дочерних процессов;
логирование в файлы закладывать с первого релиза;
не хранить пользовательские данные рядом с приложением;
заранее договориться, как именно UI узнаёт о готовности backend;
как можно раньше решить, живёт ли приложение на
file://или на собственном протоколе.
Упаковка React в Electron — технически простая задача. Сложность начинается, когда нужно превратить веб-стек в автономную локальную среду. В нашей поставке это четыре блока: управление процессами, логирование и наблюдаемость, воспроизводимость окружения, доставка и обновления.
Если у вас уже есть React, backend на Java и нужно автономное desktop-приложение без переписывания половины системы, этот путь вполне рабочий. Но только если с самого начала относиться к Electron должным образом.
Пользователь в итоге оценивает не архитектурную элегантность, а более простые вещи: запускается ли приложение стабильно, переживает ли обновления, не теряет ли данные и можно ли его поддерживать без танцев с бубном. Всё остальное вторично.
Как и обещал, оставляю ссылки на примеры реализации BE и FE.
Буду рад ответить на вопросы в комментариях!