# Как приручить Electron и спрятать Go-движок внутрь дистрибутива: история одного десктопа ```metadata 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? В прошлой статье рассказали про то как можно в консольную утилиту встроить фронт на базе веб приложения - то есть запускается все вместе одним бинарником Го, который сервит статику и обрабатывает запросы апи -> все в одном флаконе ... Там у нас был в составе приложения фронта бэкенд на Go (или это был фронт в составе бэкэнда - кому как хочется), умевший красиво «стучаться» по портам (port knocking) и даже обходить туннели, поднятые на хосте с которого «стучатся», если надо. Но ситуации бывают разные, люди(пользователи) разные, кому-то браузер не подходит и надо «кнопочки». Браузер — хорошо, говорят они, но хочется «как приложение». Разработчики переглянулись, вздохнули и сказали: «Electron? Окей…» Итак, мы тут не просто запустили вебку в окошке с помощью кучамегабайтного файла-приложения. Мы упаковали внутрь него также Go-бинарь, который молча работает рядом, а Electron — это только мост и интерфейс. Пользователь счастлив, WireGuard простаивает без трафика. Правда размер супер приложухи не уменьшился, память также отжирает. Ну ... такая текнология. Зато быстро и просто (относительно). --- ## Глава 1. Три кита: main, preload, renderer Первым делом собираем каркас. - Main: создаёт окна, IPC, меню — короче, дирижёр. - Preload: аккуратно выдаёт в renderer только то, что можно. - Renderer: UI, кнопки, формы — чтобы полетело. >И, да! Восславим в нашем проекте чистый Vanila Js - ибо все остальное это только частности ... Минимальный старт в main.js может выглядеть так: ```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: ```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`, дергает оттуда нужные данные и методы ... ``` js (() => { // Глобальные обработчики ошибок в 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: «успех/ошибка». ```go // 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 он выставлен во внешний мир. ```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` ```js // 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() ``` js 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' = создаём трубу для чтения ``` js // Альтернативы stdio: stdio: ['pipe', 'pipe', 'pipe'] // полный контроль над всеми потоками stdio: 'pipe' // то же самое, сокращённая запись stdio: 'inherit' // наследовать от родительского процесса stdio: ['ignore', 'pipe', 'pipe'] // игнорировать stdin, читать stdout/stderr ``` Процесс запускается НЕМЕДЛЕННО при вызове spawn()! Но он ещё ничего не делает — просто "висит" и ждёт данных в stdin. Полный цикл работы ``` js // 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 никогда не резолвится. Хорошо бы добавить таймаут: ``` js 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` с формы — и даём команду: ```js // 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`: ```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/`, которая содержит: 1. **Electron обертку** - основной процесс и настройки 2. **Копию Angular проекта** в `src/frontend/` - для удобства разработки 3. **IPC сервисы** - для взаимодействия с нативным кодом 4. **Кастомные модальные окна** - нативные диалоги Electron ### Структура проекта ``` text 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` реализована логика, которая определяет режим работы: ```javascript 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: ```typescript @Injectable({ providedIn: 'root' }) export class IpcService { async showNativeModal(config: ModalConfig): Promise { return await (window as any).api.showNativeModal(config); } async openFileDialog(config: FileDialogConfig): Promise { return await (window as any).api.openFileDialog(config); } async saveFileDialog(config: SaveDialogConfig): Promise { return await (window as any).api.saveFileDialog(config); } async loadFileContent(filePath: string): Promise { return await (window as any).api.loadFileContent(filePath); } async saveFileContent(filePath: string, content: string): Promise { return await (window as any).api.saveFileContent(filePath, content); } } ``` #### 3. Кастомные нативные диалоги Вместо стандартных системных диалогов созданы кастомные HTML/CSS/JS диалоги, которые: - Работают даже если Angular UI "зависла" - Имеют единый стиль с приложением - Поддерживают превью файлов - Имеют расширенную функциональность #### 4. Интеграция с Go бэкендом Angular приложение работает с тем же Go бэкендом, что и веб-версия: ```typescript export class KnockService { private apiBase = 'http://localhost:8080/api/v1'; async knock(config: KnockConfig): Promise { const response = await fetch(`${this.apiBase}/knock`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) }); return await response.json(); } } ``` ### Скрипты сборки В `package.json` настроены удобные команды: ```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 настроен для включения всех необходимых файлов: ```json { "build": { "files": [ "src/main/**/*", "src/preload/**/*", "package.json", "bin/**/*" ], "extraResources": [ { "from": "src/frontend/dist/project-front/browser", "to": "ui-dist" }, { "from": "bin", "to": "bin" } ] } } ``` ### Преимущества такого подхода 1. **Переиспользование кода** - Angular приложение остается тем же самым 2. **Нативные возможности** - доступ к файловой системе, системным диалогам 3. **Единая кодовая база** - один Angular проект для веба и десктопа 4. **Удобная разработка** - hot reload в режиме разработки 5. **Простая сборка** - автоматическая упаковка всех компонентов ### Результат Получилось полноценное десктопное приложение, которое: - Выглядит и работает как нативное - Использует весь функционал Angular - Интегрировано с Go бэкендом - Имеет кастомные нативные диалоги - Упаковывается в единый исполняемый файл Это решение демонстрирует, как можно элегантно интегрировать современный веб-фреймворк в десктопное приложение, сохраняя все преимущества обеих платформ. --- ## Заключение Мы рассмотрели два подхода к созданию десктопных приложений: 1. **Vanilla JavaScript** - простой, быстрый, но многословный 2. **Angular интеграция** - структурированный, переиспользуемый, но более сложный Оба подхода имеют свои преимущества и недостатки. Выбор зависит от ваших потребностей: - Для быстрого прототипа или простого UI - Vanilla JS - Для сложного приложения с переиспользованием веб-кода - Angular Главное - правильно спроектировать архитектуру взаимодействия между Electron и вашим UI, используя IPC и preload скрипты для безопасной передачи данных.