33 KiB
Как приручить Electron и спрятать Go-движок внутрь дистрибутива: история одного десктопа
id: 3
title: "Electron-приложение с секретным Go-движком внутри"
readTime: 15-25 минут
date: 2025-09-24 19:00
author: Direct-Dev (Антон)
level: Средний
tags: #electron #go #node #ipc #desktop #packaging #appimage
version: 1.0.0
Пролог: почему вообще Electron?
В прошлой статье рассказали про то как можно в консольную утилиту встроить фронт на базе веб приложения - то есть запускается все вместе одним бинарником Го, который сервит статику и обрабатывает запросы апи -> все в одном флаконе ... https://main.direct-dev.ru/blog/2 Там у нас был в составе приложения фронта бэкенд на Go (или это был фронт в составе бэкэнда - кому как хочется), умевший красиво «стучаться» по портам (port knocking) и даже обходить туннели, поднятые на хосте с которого «стучатся», если надо. Но ситуации бывают разные, люди(пользователи) разные, кому-то браузер не подходит и надо «кнопочки». Браузер — хорошо, говорят они, но хочется «как приложение». Разработчики переглянулись, вздохнули и сказали: «Electron? Окей…»
Итак, мы тут не просто запустили вебку в окошке с помощью кучамегабайтного файла-приложения. Мы упаковали внутрь него также Go-бинарь, который молча работает рядом, а Electron — это только мост и интерфейс. Пользователь счастлив, WireGuard простаивает без трафика. Правда размер супер приложухи не уменьшился, память также отжирает.
Ну ... такая текнология. Зато быстро и просто (относительно).
Глава 1. Три кита: main, preload, renderer
Первым делом собираем каркас.
- Main: создаёт окна, IPC, меню — короче, дирижёр.
- Preload: аккуратно выдаёт в renderer только то, что можно.
- Renderer: UI, кнопки, формы — чтобы полетело.
И, да! Восславим в нашем проекте чистый Vanila Js - ибо все остальное это только частности ...
Минимальный старт в main.js может выглядеть так:
// src/main/main.js
const { app, BrowserWindow } = require('electron');
const path = require('path');
function createWindow() {
const win = new BrowserWindow({
width: 1024,
height: 768,
webPreferences: {
preload: path.join(__dirname, '../preload/preload.js'),
contextIsolation: true,
nodeIntegration: false,
}
});
win.loadFile(path.join(__dirname, '../renderer/index.html'));
}
// Глобальные обработчики ошибок
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception in main process:', error);
// Не завершаем приложение, просто логируем
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection in main process:', reason);
// Не завершаем приложение, просто логируем
});
app.whenReady().then(() => {
createWindow();
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
// логика локального простукивателя - без вызовов api
ipcMain.handle('knock:local', async (_e, payload) => {
try {
// Валидация входных данных
if (!payload || typeof payload !== 'object') {
return { success: false, error: 'Invalid payload provided' };
}
const { targets, delay, verbose, gateway } = payload;
if (!targets || !Array.isArray(targets) || targets.length === 0) {
return { success: false, error: 'No targets provided' };
}
// Валидация каждого target
const validTargets = targets.filter(target => {
return typeof target === 'string' && target.trim().length > 0;
});
if (validTargets.length === 0) {
return { success: false, error: 'No valid targets provided' };
}
// Если задан gateway, используем Go-хелпер (поддерживает SO_BINDTODEVICE)
if ((gateway && String(gateway).trim()) || validTargets.some(t => t.split(':').length >= 4)) {
const { spawn } = require('child_process');
// Ищем собранный бинарь внутри Electron-пакета
// При разработке: desktop/bin/knock-local
// В продакшене: resources/bin/knock-local
const devBin = path.resolve(__dirname, '../../bin/knock-local');
const prodBin = path.resolve(process.resourcesPath || path.resolve(__dirname, '../../'), 'bin/knock-local');
const helperExec = fs.existsSync(devBin) ? devBin : prodBin;
const req = {
targets: validTargets,
delay: delay || '1s',
// Принудительно отключаем verbose у хелпера, чтобы stdout был чисто JSON
verbose: false,
gateway: gateway || ''
};
const input = JSON.stringify(req);
const child = spawn(helperExec, [], {
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
child.stdout.on('data', d => { stdout += d.toString(); });
child.stderr.on('data', d => { stderr += d.toString(); });
child.stdin.write(input);
child.stdin.end();
const code = await new Promise(resolve => child.on('close', resolve));
if (code !== 0) {
return { success: false, error: `go helper exited with code ${code}: ${stderr || stdout}` };
}
try {
// Извлекаем последнюю JSON-строку из stdout (в случае если есть текстовые логи)
const lines = stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
const jsonLine = [...lines].reverse().find(l => l.startsWith('{') && l.endsWith('}')) || stdout.trim();
const parsed = JSON.parse(jsonLine);
if (parsed?.success) {
return { success: true, results: [], summary: { total: validTargets.length, successful: validTargets.length, failed: 0 } };
}
return { success: false, error: parsed?.error || 'unknown helper error' };
} catch (e) {
return { success: false, error: `failed to parse helper output: ${e.message}`, raw: stdout };
}
}
const results = await performLocalKnock(validTargets, delay || '1s', Boolean(verbose), gateway || null);
return {
success: true,
results: results,
summary: {
total: results.length,
successful: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length
}
};
} catch (error) {
console.error('Local knock error:', error);
return {
success: false,
error: error.message || 'Unknown error occurred'
};
}
});
В preload
отдаем только белый список того, что накодили в main.js:
// src/preload/preload.js
const { contextBridge, ipcRenderer } = require('electron');
// Пробрасываем конфигурацию в рендерер (безопасно)
contextBridge.exposeInMainWorld('config', {
apiBase: process.env.KNOCKER_DESKTOP_API_BASE || 'http://localhost:8080/api/v1'
});
contextBridge.exposeInMainWorld('api', {
localKnock: async (payload) => ipcRenderer.invoke('knock:local', payload)
});
Renderer остался «чистым» фронтом: получает window.api
, дергает оттуда нужные данные и методы ...
(() => {
// Глобальные обработчики ошибок в renderer
window.addEventListener('error', (event) => {
console.error('Global error in renderer:', event.error);
});
window.addEventListener('unhandledrejection', (event) => {
console.error('Unhandled promise rejection in renderer:', event.reason);
});
let apiBase = window.config?.apiBase || "http://localhost:8080/api/v1";
const qs = (sel) => document.querySelector(sel);
const qsi = (sel) => document.querySelector(sel);
const qst = (sel) => document.querySelector(sel);
// ...
window.addEventListener("DOMContentLoaded", () => {
qs("#execute")?.addEventListener("click", async () => {
updateStatus("Выполнение…");
const password = qsi("#password").value;
const mode = document.querySelector('input[name="mode"]:checked')?.value || '';
// Проверяем, нужно ли использовать локальное простукивание
const useLocalKnock = !apiBase || apiBase.trim() === '' || apiBase === 'internal';
if (useLocalKnock) {
// Локальное простукивание через Node.js
try {
let targets = [];
let delay = qsi("#delay").value || '1s';
const verbose = qsi("#verbose").checked;
if (mode === "inline") {
targets = qsi("#targets").value.split(';').filter(t => t.trim());
} else if (mode === "form") {
targets = [serializeFormTargetsToInline()];
} else if (mode === "yaml") {
// Для YAML режима извлекаем targets из YAML
const yamlContent = qst("#configYAML").value;
try {
const config = yaml.load(yamlContent);
if (config?.targets && Array.isArray(config.targets)) {
targets = config.targets.map(t => {
const protocol = t.protocol || 'tcp';
const host = t.host || '127.0.0.1';
const ports = t.ports || [t.port] || [22];
return ports.map(port => `${protocol}:${host}:${port}`);
}).flat();
delay = config.delay || delay;
}
} catch (e) {
updateStatus(`Ошибка парсинга YAML: ${e.message}`);
return;
}
}
if (targets.length === 0) {
updateStatus("Нет целей для простукивания");
return;
}
// Получаем gateway из конфигурации или поля
const gateway = qsi('#gateway')?.value?.trim() || '';
const result = await window.api.localKnock({
targets,
delay,
verbose,
gateway
});
if (result?.success) {
const summary = result.summary;
updateStatus(`Локальное простукивание завершено: ${summary.successful}/${summary.total} успешно`);
// Логируем детальные результаты в консоль
if (verbose) {
console.log('Local knock results:', result.results);
}
} else {
const errorMsg = result?.error || 'Неизвестная ошибка локального простукивания';
updateStatus(`Ошибка локального простукивания: ${errorMsg}`);
console.error('Local knock failed:', result);
}
} catch (e) {
updateStatus(`Ошибка: ${e?.message || String(e)}`);
}
return;
}
// API простукивание через HTTP
const body = {};
if (mode === "yaml") {
body.config_yaml = qst("#configYAML").value;
} else if (mode === "inline") {
body.targets = qsi("#targets").value;
body.delay = qsi("#delay").value;
body.verbose = qsi("#verbose").checked;
body.waitConnection = qsi("#waitConnection").checked;
body.gateway = qsi("#gateway").value;
} else {
body.targets = serializeFormTargetsToInline();
body.delay = qsi("#delay").value;
body.verbose = qsi("#verbose").checked;
body.waitConnection = qsi("#waitConnection").checked;
}
let result;
try {
result = await fetch(`${apiBase}/knock-actions/execute`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...basicAuthHeader(password),
},
body: JSON.stringify(body),
});
if (result?.ok) {
updateStatus("Успешно простучали через API...");
} else {
updateStatus(`Ошибка API: ${result.statusText}`);
}
} catch (e) {
updateStatus(`Ошибка: ${e?.message || String(e)}`);
}
});
}
// ...
})();
Глава 2. Если API нет — кнокаем локально
Итак в приложении у нас возможны два режима:
- Есть API? Хорошо, бьём HTTP-запросом.
- API ===
internal
? Работать всё равно надо. Значит Node-сокеты, TCP/UDP и т.д.
И всё бы у нас было гладко да сладко ... но появился gateway.
Глава 3. Gateway и WireGuard: «кто кого»
Задача: отправить пакеты через конкретный интерфейс, чтобы они не уходили в туннель WireGuard (когда весь трафик завернут туда). На Go реализуется через SO_BINDTODEVICE
и поехали. На Node … ну … хбз (может как то и можно - АУ гуру node.js).
Перепробовал legit-способы: localAddress
, пляски с таймаутами — но туннель всё равно перехватывает пакеты.
А чего мы мучаем Node? Дадим это сделать Go. Он же это уже умеет!
Глава 4. Секретный Go-движок внутри
Написан маленький Go-хелпер, который делает кнокинг портов в соответствии с конфигурацией:
- читает из stdin JSON:
targets
,delay
,gateway
, - превращает их в конфиг,
- вызывает
internal.PortKnocker()
(в нёмSO_BINDTODEVICE
), - печатает в stdout один короткий JSON: «успех/ошибка».
// back/cmd/knock-local/main.go (суть)
type Request struct {
Targets []string `json:"targets"`
Delay string `json:"delay"`
Verbose bool `json:"verbose"`
Gateway string `json:"gateway"`
}
type Response struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
// ... парсим stdin ...
pk := internal.NewPortKnocker()
_ = pk.ExecuteWithConfig(cfg, false, false) // stdout — только JSON
_ = json.NewEncoder(os.Stdout).Encode(Response{ Success:true, Message:"ok" })
Глава 5. Как Electron «общается» с Go
В main
есть обработчик IPC knock:local
. С помощью preload.js он выставлен во внешний мир.
contextBridge.exposeInMainWorld('api', {
localKnock: async (payload) => ipcRenderer.invoke('knock:local', payload)
});
- Если
gateway
задан (или в цели есть:gateway
) — запускаем Go-хелпер. - Иначе — используем Node-сокеты.
Вот так производится запуск бинарника-хелпера:
В проде сам бинарник запакован внутрь .AppImage и приложению доступен по пути resources/bin/knock-local
кстати распаковать и посмотреть что там внутри AppImage можно так
./Knocker\ Desktop-1.0.0.AppImage --appimage-extract
// src/main/main.js (фрагмент)
const { spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const devBin = path.resolve(__dirname, '../../bin/knock-local');
const prodBin = path.resolve(process.resourcesPath || path.resolve(__dirname, '../../'), 'bin/knock-local');
const helperExec = fs.existsSync(devBin) ? devBin : prodBin;
const req = { targets: validTargets, delay: delay || '1s', verbose: false, gateway: gateway || '' };
const input = JSON.stringify(req);
const child = spawn(helperExec, [], { stdio: ['pipe', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
child.stdout.on('data', d => { stdout += d.toString(); });
child.stderr.on('data', d => { stderr += d.toString(); });
child.stdin.write(input);
child.stdin.end();
const code = await new Promise(r => child.on('close', r));
if (code !== 0) {
return { success:false, error:`go helper exited ${code}: ${stderr || stdout}` };
}
// если вдруг кто-то напечатает «лишнее» в stdout — достанем последнюю JSON-строку
const lines = stdout.split(/\r?\n/).map(s=>s.trim()).filter(Boolean);
const jsonLine = [...lines].reverse().find(l => l.startsWith('{') && l.endsWith('}')) || stdout.trim();
const parsed = JSON.parse(jsonLine);
Что делает spawn()
const child = spawn(helperExec, [], {
stdio: ['pipe', 'pipe', 'pipe']
});
spawn(команда, аргументы, опции)
— это Node.js функция для запуска внешних процессов.
helperExec — путь к исполняемому файлу (наш Go-бинарник knock-local)
[] — массив аргументов командной строки (у нас пустой, т.к. всё передаём через stdin)
{ stdio: [...] } — настройки потоков ввода/вывода
Что означает stdio: ['pipe', 'pipe', 'pipe']
Это массив из 3 элементов, каждый отвечает за поток: [0] — stdin (ввод) — 'pipe' = создаём трубу для записи [1] — stdout (вывод) — 'pipe' = создаём трубу для чтения [2] — stderr (ошибки) — 'pipe' = создаём трубу для чтения
// Альтернативы stdio:
stdio: ['pipe', 'pipe', 'pipe'] // полный контроль над всеми потоками
stdio: 'pipe' // то же самое, сокращённая запись
stdio: 'inherit' // наследовать от родительского процесса
stdio: ['ignore', 'pipe', 'pipe'] // игнорировать stdin, читать stdout/stderr
Процесс запускается НЕМЕДЛЕННО при вызове spawn()! Но он ещё ничего не делает — просто "висит" и ждёт данных в stdin.
Полный цикл работы
// 1. Запускаем процесс (он ждёт)
const child = spawn(helperExec, [], { stdio: ['pipe', 'pipe', 'pipe'] });
// 2. Настраиваем "слушателей" на выходы
let stdout = '', stderr = '';
child.stdout.on('data', d => { stdout += d.toString(); });
child.stderr.on('data', d => { stderr += d.toString(); });
// 3. Отправляем JSON в stdin
const req = { targets: ['tcp:192.168.1.1:22'], delay: '1s', gateway: '192.168.89.18' };
const input = JSON.stringify(req);
child.stdin.write(input); // ← Go начинает читать и работать
child.stdin.end(); // ← Go понимает "всё, данных больше не будет"
// 4. Ждём завершения
const code = await new Promise(r => child.on('close', r));
// ↑ Когда Go закончит работу, получим exit code (0 = успех)
spawn() НЕ блокирует. Процесс запускается асинхронно.
Ждём через Promise, который резолвится когда Go вызывает os.Exit()
const code = await new Promise(r => child.on('close', r));
Потоки:
child.stdin — пишем в него
child.stdout — читаем из него
child.stderr — читаем из него
Если Go-процесс "зависнет", Promise никогда не резолвится. Хорошо бы добавить таймаут:
const timeout = setTimeout(() => {
child.kill('SIGTERM');
}, 30000); // 30 секунд максимум
const code = await new Promise(r => child.on('close', r));
clearTimeout(timeout);
Глава 6. UI только для UI
В renderer
ничего такого что непосредственно работает с ядром системы.
Собираем targets
, delay
, забираем gateway
с формы — и даём команду:
// src/renderer/renderer.js (фрагмент)
qs('#execute')?.addEventListener('click', async () => {
const mode = document.querySelector('input[name="mode"]:checked')?.value || '';
const gateway = qsi('#gateway')?.value?.trim() || '';
// ... собираем targets ...
const res = await window.api.localKnock({ targets, delay, verbose, gateway });
updateStatus(res.success ? 'Done' : `Ошибка: ${res.error}`);
});
Renderer ничего не знает про сокеты, туннели и Go. И это все прекрастно и типа безопастно ....
Глава 7. Сборка и упаковка (и маленький лайфхак)
Чтобы Go не требовался у пользователя — собираем бинарь заранее и кладём его в ресурсы приложения.
В desktop/package.json
:
{
"scripts": {
"go:build": "bash -lc 'mkdir -p bin && cd ../back && go build -o ../desktop/bin/knock-local ./cmd/knock-local'",
"dev": "npm run go:build && electron .",
"build": "npm run go:build && electron-builder"
},
"build": {
"files": ["src/**/*", "node_modules/**/*", "bin/**/*"],
"extraResources": [{ "from": "bin", "to": "bin", "filter": ["**/*"] }]
}
}
При разработке запускаем npm run dev
— он сначала соберёт Go, потом стартанёт Electron. В проде electron-builder
положит бинарь в resources/bin
. Пользователь вообще не в курсе, что внутри слишком умный Go сидит и пинает пакеты в нужные цели.
Можно конечно и не Го-хелпер запилить - например на Rust (смотри последнюю версию проекта в репозитории) или на С++
Глава 8. Почему именно так
- Node — хорош для UI и связки слоёв, но ему не хватает «низкоуровневого директа» в сокетах.
- Go умеет то, что нужно:
SO_BINDTODEVICE
, привязка к интерфейсу. - Electron — как контейнер: упаковали веб, настроили мосты, подложили Go, включили DevTools = красота.
Минус: надо собирать маленький бинарничек, который немного увеличит размер пакета (так как он и так ого-го-то несущественно).
Плюс: он работает невидимо и делает что требуется.
Глава 9. Грабли и как мы на них танцевали
- Не кладите бинарь в
app.asar
. Пусть живёт вresources/bin
, будет исполняемым и легко диагностируемым. - Следите за stdout хелпера — там должен быть только JSON (мы жёстко это контролируем).
- Путь к бинарю в деве и проде разный — мы это учли (ищем сначала
bin/
, потомresources/bin
).
Эпилог
Мы сделали десктопное приложение, которое вообще-то «веб», но изнутри умеет кое что еще.
А как же фреймворки?
На Vanilla js все просто и понятно писать но очень многсловно и трудно сделать все структурировано и красиво "по фен-шую". Давайте коротенько рассмотрим, а как же нам портировать имеющийся ui ангуляр проект, который героически был вшит в go-knocker в десктопном проекте electron.
Итак, у нас есть готовый или не очень Angular проект который уже работает как веб-приложение. Теперь нужно интегрировать его в Electron так, чтобы получить все возможности поюзать его как графическое приложение.
Архитектурное решение
Мы создали отдельную папку desktop-angular/
, которая содержит:
- Electron обертку - основной процесс и настройки
- Копию Angular проекта в
src/frontend/
- для удобства разработки - IPC сервисы - для взаимодействия с нативным кодом
- Кастомные модальные окна - нативные диалоги Electron
Структура проекта
desktop-angular/
├── src/
│ ├── main/ # Electron main process
│ │ ├── main.js # Основной процесс
│ │ ├── modal.html # Кастомные модальные окна
│ │ ├── open-dialog.html # Диалог открытия файлов
│ │ └── save-dialog.html # Диалог сохранения файлов
│ ├── preload/ # Безопасный мост
│ │ └── preload.js # API для рендерера
│ └── frontend/ # Angular приложение
│ ├── src/app/
│ │ ├── ipc.service.ts # Сервис для IPC
│ │ ├── modal.service.ts # Сервис модальных окон
│ │ └── root.component.ts # Главный компонент
│ └── package.json # Зависимости Angular
├── package.json # Конфигурация Electron
└── bin/ # Скомпилированный Go бэкенд
Ключевые особенности реализации
1. Двойной режим работы
В main.js
реализована логика, которая определяет режим работы:
const isDev = process.env.NODE_ENV !== "production" && !app.isPackaged;
if (isDev) {
// В режиме разработки загружаем с ng serve
win.loadURL('http://localhost:4200');
win.webContents.openDevTools();
} else {
// В продакшене загружаем собранные файлы
const indexPath = app.isPackaged
? path.join(process.resourcesPath, 'ui-dist', 'index.html')
: path.resolve(__dirname, '../frontend/dist/project-front/browser/index.html');
win.loadFile(indexPath);
}
2. IPC сервис для Angular
Создан специальный сервис IpcService
, который предоставляет удобный API для взаимодействия с Electron:
@Injectable({
providedIn: 'root'
})
export class IpcService {
async showNativeModal(config: ModalConfig): Promise<string> {
return await (window as any).api.showNativeModal(config);
}
async openFileDialog(config: FileDialogConfig): Promise<string[]> {
return await (window as any).api.openFileDialog(config);
}
async saveFileDialog(config: SaveDialogConfig): Promise<string> {
return await (window as any).api.saveFileDialog(config);
}
async loadFileContent(filePath: string): Promise<string> {
return await (window as any).api.loadFileContent(filePath);
}
async saveFileContent(filePath: string, content: string): Promise<void> {
return await (window as any).api.saveFileContent(filePath, content);
}
}
3. Кастомные нативные диалоги
Вместо стандартных системных диалогов созданы кастомные HTML/CSS/JS диалоги, которые:
- Работают даже если Angular UI "зависла"
- Имеют единый стиль с приложением
- Поддерживают превью файлов
- Имеют расширенную функциональность
4. Интеграция с Go бэкендом
Angular приложение работает с тем же Go бэкендом, что и веб-версия:
export class KnockService {
private apiBase = 'http://localhost:8080/api/v1';
async knock(config: KnockConfig): Promise<KnockResult> {
const response = await fetch(`${this.apiBase}/knock`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
return await response.json();
}
}
Скрипты сборки
В package.json
настроены удобные команды:
{
"scripts": {
"dev": "concurrently -k -n UI,ELECTRON -c green,cyan \"cd src/frontend && npm start\" \"wait-on http://localhost:4200 && cross-env NODE_ENV=development electron .\"",
"build:ui": "cd src/frontend && npm run build",
"go:build": "bash -lc 'mkdir -p bin && cd ../back && go build -o ../desktop-angular/bin/full-go-knocker .'",
"dist": "npm run build:ui && npm run go:build && cross-env NODE_ENV=production electron-builder"
}
}
Конфигурация упаковки
Electron Builder настроен для включения всех необходимых файлов:
{
"build": {
"files": [
"src/main/**/*",
"src/preload/**/*",
"package.json",
"bin/**/*"
],
"extraResources": [
{
"from": "src/frontend/dist/project-front/browser",
"to": "ui-dist"
},
{
"from": "bin",
"to": "bin"
}
]
}
}
Преимущества такого подхода
- Переиспользование кода - Angular приложение остается тем же самым
- Нативные возможности - доступ к файловой системе, системным диалогам
- Единая кодовая база - один Angular проект для веба и десктопа
- Удобная разработка - hot reload в режиме разработки
- Простая сборка - автоматическая упаковка всех компонентов
Результат
Получилось полноценное десктопное приложение, которое:
- Выглядит и работает как нативное
- Использует весь функционал Angular
- Интегрировано с Go бэкендом
- Имеет кастомные нативные диалоги
- Упаковывается в единый исполняемый файл
Это решение демонстрирует, как можно элегантно интегрировать современный веб-фреймворк в десктопное приложение, сохраняя все преимущества обеих платформ.
Заключение
Мы рассмотрели два подхода к созданию десктопных приложений:
- Vanilla JavaScript - простой, быстрый, но многословный
- Angular интеграция - структурированный, переиспользуемый, но более сложный
Оба подхода имеют свои преимущества и недостатки. Выбор зависит от ваших потребностей:
- Для быстрого прототипа или простого UI - Vanilla JS
- Для сложного приложения с переиспользованием веб-кода - Angular
Главное - правильно спроектировать архитектуру взаимодействия между Electron и вашим UI, используя IPC и preload скрипты для безопасной передачи данных.