Files
knock-gui/article/electron-desktop-guide.md
2025-10-03 16:51:29 +06:00

782 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Как приручить 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 скрипты для безопасной передачи данных.