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

33 KiB
Raw Permalink Blame History

Как приручить Electron и спрятать Go-движок внутрь дистрибутива: история одного десктопа

Пролог: почему вообще 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/, которая содержит:

  1. Electron обертку - основной процесс и настройки
  2. Копию Angular проекта в src/frontend/ - для удобства разработки
  3. IPC сервисы - для взаимодействия с нативным кодом
  4. Кастомные модальные окна - нативные диалоги 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"
      }
    ]
  }
}

Преимущества такого подхода

  1. Переиспользование кода - Angular приложение остается тем же самым
  2. Нативные возможности - доступ к файловой системе, системным диалогам
  3. Единая кодовая база - один Angular проект для веба и десктопа
  4. Удобная разработка - hot reload в режиме разработки
  5. Простая сборка - автоматическая упаковка всех компонентов

Результат

Получилось полноценное десктопное приложение, которое:

  • Выглядит и работает как нативное
  • Использует весь функционал Angular
  • Интегрировано с Go бэкендом
  • Имеет кастомные нативные диалоги
  • Упаковывается в единый исполняемый файл

Это решение демонстрирует, как можно элегантно интегрировать современный веб-фреймворк в десктопное приложение, сохраняя все преимущества обеих платформ.


Заключение

Мы рассмотрели два подхода к созданию десктопных приложений:

  1. Vanilla JavaScript - простой, быстрый, но многословный
  2. Angular интеграция - структурированный, переиспользуемый, но более сложный

Оба подхода имеют свои преимущества и недостатки. Выбор зависит от ваших потребностей:

  • Для быстрого прототипа или простого UI - Vanilla JS
  • Для сложного приложения с переиспользованием веб-кода - Angular

Главное - правильно спроектировать архитектуру взаимодействия между Electron и вашим UI, используя IPC и preload скрипты для безопасной передачи данных.