782 lines
33 KiB
Markdown
782 lines
33 KiB
Markdown
# Как приручить 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?
|
||
|
||
В прошлой статье рассказали про то как можно в консольную утилиту встроить фронт на базе веб приложения - то есть запускается все вместе одним бинарником Го, который сервит статику и обрабатывает запросы апи ->
|
||
все в одном флаконе ... <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 может выглядеть так:
|
||
|
||
```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<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 бэкендом, что и веб-версия:
|
||
|
||
```typescript
|
||
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` настроены удобные команды:
|
||
|
||
```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 скрипты для безопасной передачи данных.
|