Эта статья является прямым продолжением 2 части статьи про мой проект ServeHub-2. Здесь пойдет речь полностью про разработку установщика на Go Wails v2.

Повествование будет идти таким образом: описание архитектуры в целом, потом код по страницам с картинками как это все выглядит. В отличие от черновика, в этом материале отображен весь рабочий код без сокращений с разбором каждой из страниц.

В Wails основными файлами, с которыми проходит 90% работы это index.html, styles.css, main.js, app.go. Остальные файлы мы либо не трогаем, либо настраиваем один раз в самом начале.

Для фронтенда я использовал стандартный html, css, js без современных фреймворков (так как я в них не разбираюсь). Frontend часть подробно рассматривать в этой статье не планируется, a html будет использоваться только чтобы показать, где используются js функции.

Что вообще делает установщик?

  • Пользователя встречает главная страница с описанием.

  • Проверка запущен/установлен ли Docker (в нем запускается ansible).

  • Копирование кода репозитория рядом с установщиком.

  • Настройка secrets.yml для ansible (форма ввода, данные из которой потом записываются в виде .yml конфига).

  • Проверка конфигурации: показывается как будет выглядеть сохраненный конфиг, также дается возможность сделать себе копию.

  • Выбор деплоя (как себя будет вести Ansible). Делится на 3 варианта: для локального сервера, для удаленного сервера и на оба сервера сразу. И каждый из этих вариантов делится еще на 2: полный деплой (с первоначальной настройкой сервера), либо только разворачивание приложений если проект уже запускался.

  • Подтверждение выбора и начало деплоя. Появляется окно с терминалом в котором пишутся логи ansible.

  • Если деплой завершился ошибкой - кнопка выйти или запустить заново. Если деплой успешен - небольшой гайд что делать дальше и кнопка выхода.

Экскурс в Wails (как работает)

Wails делится на Frontend и Backend часть. Основной его смысл в том, что он отрисовывает страницы как стандартный браузер и переводит выводы Go функций в js. Получается таким образом: в Go мы пишем основную логику, Wails переводит данные из Go к JS, который уже работает со статикой страниц.

Первоначальная настройка

Про установку много рассказывать не буду, для работы понадобятся:

  • Go 1.21+

  • NPM / Node.js (15+)

  • Wails v2

  • libwebkit2gtk-4.1-devтолько для Linux (необходим для работы графического движка Wails)

Чтобы создать первоначальную структуру файлов в терминале нужно выполнить wails init.

Первый файл с которым предстоит работать - main.go (здесь мы создаем наше приложение, даем ему базовые настройки по типу размера окна, привязываем ассеты).

main.go:

package main // Объявление основного пакета приложения

import (
	"embed" // Библиотека для внедрения статических файлов в бинарный код приложения

	"github.com/wailsapp/wails/v2" // Основной пакет фреймворка Wails
	"github.com/wailsapp/wails/v2/pkg/options" // Конфигурационные параметры приложения
	"github.com/wailsapp/wails/v2/pkg/options/assetserver" // Настройки сервера статических файлов
)

// Директива компилятора go:embed:
// Указывает компилятору включить содержимое папки 'frontend/dist' в скомпилированный файл.
// 'all:' гарантирует, что будут включены скрытые файлы (начинающиеся с точки) 
// и файлы с нижним подчеркиванием.
//
//go:embed all:frontend/dist
var assets embed.FS // Переменная 'assets' представляет собой виртуальную файловую систему, содержащую все упакованные ресурсы

func main() {
	// Создаем экземпляр структуры App (содержит логику методов Go, доступных из JS)
	app := NewApp()

	// Запуск приложения Wails с заданными параметрами конфигурации:
	err := wails.Run(&options.App{
		Title:     "ServeHub-2 Installer", // Заголовок окна приложения
		Width:     1024,                  // Ширина окна
		Height:    768,                   // Высота окна
		Frameless: true,                  // Отключение системной рамки для создания кастомного интерфейса
		AssetServer: &assetserver.Options{
			Assets: assets, // Привязываем внедренную файловую систему к серверу ассетов
		},
		OnStartup: app.startup, // Метод, вызываемый при запуске приложения для инициализации контекста
		Bind: []interface{}{ // Регистрация экземпляра App для вызова его методов из JavaScript
			app,
		},
	})

	// Если при запуске произошла критическая ошибка, выводим её в консоль
	if err != nil {
		println("Error:", err.Error())
	}
}

Логика смены страниц

Изначально Wails - это SPA, т.е все происходит на одной странице, а javascript меняет ее наполнение, поэтому чтобы реализовать приложение с несколькими страницами я использовал механизм “шагов”.

index.html (Контейнер прогресс-бара):

<!-- Контейнер для отображения прогресса установки -->
<div class="progress-container">
    <!-- 
      Область визуализации шагов. 
      Здесь должны находиться элементы с классом .step-dot, 
      которые JavaScript будет обновлять через функцию updateStepProgress.
    -->
    <div class="progress-bar-track">
        <div id="step-progress-fill" class="progress-bar-fill"></div>
        
        <!-- Пример точек прогресса, соответствующих 7 шагам -->
        <div class="step-dot" data-step="1"></div>
        <div class="step-dot" data-step="2"></div>
        <div class="step-dot" data-step="3"></div>
        <div class="step-dot" data-step="4"></div>
        <div class="step-dot" data-step="5"></div>
        <div class="step-dot" data-step="6"></div>
        <div class="step-dot" data-step="7"></div>
    </div>

    <!-- Текстовое описание текущего этапа -->
    <div class="step-label">
        Шаг <span id="step-progress-current">1</span> из 7 — 
        <span id="step-progress-title">Добро пожаловать</span>
    </div>
</div>

Для управления навигацией написаны соответствующие функции, которые связывают элементы HTML и логику App.js.

main.js (Глобальные функции и роутер):

/**
 * Словарь названий шагов для отображения в прогресс-баре.
 */
const STEP_TITLES = {
    1: 'Добро пожаловать',
    2: 'Проверка требований',
    3: 'Установка компонентов',
    4: 'Настройка secrets.yml',
    5: 'Проверка конфигурации',
    6: 'Выбор сценария деплоя',
    7: 'Запуск деплоя',
};

/**
 * Обновляет визуальное состояние прогресс-бара и индикаторов шагов.
 * @param {number} stepNumber - Текущий номер шага.
 */
function updateStepProgress(stepNumber) {
    const totalSteps = 7;
    const fill = document.getElementById('step-progress-fill');
    const currentEl = document.getElementById('step-progress-current');
    const titleEl = document.getElementById('step-progress-title');

    // 1. Обновление ширины прогресс-бара (процент заполнения)
    if (fill) {
        // Вычисляем процент так, чтобы на 1-м шаге было 0%, а на последнем — 100%
        const pct = ((stepNumber - 1) / (totalSteps - 1)) * 100;
        fill.style.width = `${pct}%`;
    }
    
    // 2. Обновление текстовых индикаторов
    if (currentEl) currentEl.innerText = stepNumber;
    if (titleEl) titleEl.innerText = STEP_TITLES[stepNumber] || '';

    // 3. Обновление визуальных точек (dots) прогресса
    document.querySelectorAll('.step-dot').forEach((dot) => {
        const dotStep = parseInt(dot.getAttribute('data-step'), 10);
        dot.classList.remove('completed', 'active');
        
        // Подсвечиваем пройденные шаги как 'completed', текущий — как 'active'
        if (dotStep < stepNumber) dot.classList.add('completed');
        if (dotStep === stepNumber) dot.classList.add('active');
    });
}

/**
 * Главный переключатель экранов мастера установки.
 * Скрывает неактивные шаги и отображает целевой.
 * @param {number} stepNumber - Номер шага для перехода.
 */
window.goToStep = function(stepNumber) {
    // 1. Скрытие всех экранов шагов
    document.querySelectorAll('.step').forEach(step => {
        step.classList.remove('active');
    });

    // 2. Отображение целевого экрана
    const nextStep = document.getElementById(`step-${stepNumber}`);
    if (nextStep) {
        nextStep.classList.add('active');
    }

    // 3. Обновление прогресс-бара
    updateStepProgress(stepNumber);

    // 4. Триггеры автоматической логики:
    // При переходе на определенные шаги запускаем соответствующие скрипты (проверки или установку).
    if (stepNumber === 2) {
        runDockerCheck();
    }
    if (stepNumber === 3) {
        runInstallation();
    }
}

Ревью по страницам

Шаг 1: Добро пожаловать (SPA и Роутинг)

На первом экране отображается базовая информация и кнопка запуска. По нажатию вызывается глобальная функция goToStep(2), инициирующая проверку окружения.

HTML-разметка шага (index.html):

<!-- 
  Главный контейнер мастера установки ServeHub-2.
  Используется как точка входа для пользователя при первом запуске приложения.
-->
<div class="installer-container">
    <header class="installer-header">
        <h1>ServeHub-2</h1>
        <p class="subtitle">Мастер установки</p>
    </header>

    <main class="installer-content">
        <p>Добро пожаловать! Эта программа поможет вам установить и настроить проект <strong>ServeHub-2</strong> на вашем компьютере.</p>
    </main>

    <footer class="installer-actions">
        <!-- Кнопка перехода к следующему шагу установки -->
        <button id="btn-start" class="btn-primary" onclick="goToStep(2)">
            Продолжить
        </button>
        
        <!-- Ссылка на исходный код проекта для прозрачности и контроля -->
        <a href="https://github.com/canntstand/ServeHub-2" target="_blank" class="link-github">
            <img src="assets/github-icon.svg" alt="GitHub Logo" class="icon">
            GitHub Repository
        </a>
    </footer>
</div>

Шаг 2: Проверка требований

Здесь происходит взаимодействие фронтенда с бэкендом для проверки наличия Docker. Для структурирования ответа в Go используется CheckResult.

Бэкенд на Go (app.go):

// CheckResult определяет стандартный формат ответа для всех операций бэкенда.
// Поля помечены тегами json для корректной сериализации при передаче данных в JavaScript (через Wails).
type CheckResult struct {
    Success bool   `json:"success"` // Флаг успешности выполнения операции
    Message string `json:"message"` // Описание результата или текст ошибки
}

// CheckDocker проверяет наличие и работоспособность Docker и Docker Compose в хост-системе.
// Метод выполняет попытку вызова команд через os/exec и возвращает статус готовности.
func (a *App) CheckDocker() CheckResult {
    // 1. Проверка работоспособности Docker:
    // Выполняем `docker info` — эта команда возвращает системную информацию, если Docker запущен.
    // Если команда завершается с ошибкой, значит демон Docker недоступен или не установлен.
    _, err := exec.Command("docker", "info").Output()
    if err != nil {
        return CheckResult{
            Success: false, 
            Message: "Docker не запущен или не установлен!",
        }
    }

    // 2. Проверка наличия Docker Compose:
    // Docker Compose часто устанавливается как плагин или отдельный бинарный файл.
    // Вызов `docker compose version` подтверждает, что плагин доступен для использования.
    _, err = exec.Command("docker", "compose", "version").Output()
    if err != nil {
        return CheckResult{
            Success: false, 
            Message: "Docker Compose не найден в системе!",
        }
    }

    // 3. Успешный результат:
    // Обе зависимости успешно обнаружены и готовы к работе.
    return CheckResult{
        Success: true, 
        Message: "Docker запущен и готов к работе.",
    }
}

Фронтенд (main.js):

/**
 * Выполняет проверку доступности Docker и Docker Compose в системе.
 * Используется на этапе инициализации приложения для подтверждения готовности окружения.
 */
window.runDockerCheck = async function() {
    // 1. Получение ссылок на DOM-элементы для обновления состояния интерфейса
    const statusText = document.getElementById('status-text');
    const spinner = document.getElementById('status-loading');
    const btnNext = document.getElementById('btn-step2-next');
    const btnRetry = document.getElementById('btn-retry');

    // 2. Настройка UI перед запуском проверки:
    // Включаем спиннер, блокируем кнопку перехода и сбрасываем состояние текста
    spinner.style.display = 'block';
    spinner.className = 'spinner';
    statusText.innerText = 'Проверяем Docker и Docker Compose...';
    statusText.style.color = '#9a9aa0'; // Нейтральный цвет во время ожидания
    btnNext.disabled = true;
    btnRetry.style.display = 'none';

    // 3. Имитация задержки (700 мс):
    // Позволяет пользователю заметить визуальную смену состояний, избегая "дерганья" интерфейса
    // при очень быстром выполнении проверки на бэкенде.
    await new Promise(resolve => setTimeout(resolve, 700));

    try {
        // 4. Вызов Go-функции CheckDocker через мост Wails:
        const result = await CheckDocker();
        spinner.style.display = 'none'; // Скрываем индикатор после получения ответа

        if (result.success) {
            // Если Docker найден и корректно работает
            statusText.innerText = `✓ ${result.message}`;
            statusText.style.color = '#818cf8'; // Акцентный цвет успеха
            btnNext.disabled = false; // Открываем путь к следующему шагу
        } else {
            // Если возникла логическая ошибка (например, Docker не запущен)
            statusText.innerText = `✗ Ошибка: ${result.message}.`;
            statusText.style.color = '#e5484d'; // Цвет опасности
            btnRetry.style.display = 'block'; // Предлагаем попробовать еще раз
        }
    } catch (err) {
        // 5. Обработка системных исключений (например, сбой соединения с бэкендом)
        spinner.style.display = 'none';
        statusText.innerText = 'Критическая ошибка при вызове проверки бэкенда:\n' + err;
        statusText.style.color = '#e5484d';
        btnRetry.style.display = 'block';
    }
}

Шаг 3: Установка компонентов

На этом шаге скачивается полная архитектура проекта с GitHub и производится поиск уже существующего конфига.

Загрузка архива на бэкенде (app.go):

// InstallProject скачивает исходный код проекта с GitHub и распаковывает его в рабочую директорию.
// Метод предотвращает повторную загрузку, если папка проекта уже существует.
func (a *App) InstallProject() CheckResult {
    // 1. Проверка существования проекта:
    // Проверяем наличие папки "ServeHub-2-main". Если она есть, предполагаем, 
    // что проект уже развернут, и прерываем выполнение, чтобы не перезаписывать данные.
    if _, err := os.Stat("ServeHub-2-main"); err == nil {
        return CheckResult{
            Success: true, 
            Message: "Проект уже был развернут ранее (папка ServeHub-2-main существует).",
        }
    }

    // 2. Загрузка архива:
    // Используем стандартный HTTP-клиент для получения ZIP-архива из репозитория.
    url := "https://github.com/canntstand/ServeHub-2/archive/refs/heads/main.zip"
    resp, err := http.Get(url)
    if err != nil {
        return CheckResult{Success: false, Message: "Не удалось подключиться к GitHub: " + err.Error()}
    }
    defer resp.Body.Close() // Гарантируем закрытие потока ответа

    // Проверяем, что сервер вернул статус 200 OK
    if resp.StatusCode != http.StatusOK {
        return CheckResult{Success: false, Message: "GitHub вернул ошибку: " + resp.Status}
    }

    // 3. Чтение содержимого в память:
    bodyBytes, err := io.ReadAll(resp.Body)
    if err != nil {
        return CheckResult{Success: false, Message: "Ошибка при чтении данных архива: " + err.Error()}
    }

    // Инициализируем ZIP-ридер из байтового массива
    zipReader, err := zip.NewReader(bytes.NewReader(bodyBytes), int64(len(bodyBytes)))
    if err != nil {
        return CheckResult{Success: false, Message: "Не удалось прочитать скачанный zip-архив: " + err.Error()}
    }

    destFolder := "." // Распаковка производится в текущую директорию приложения

    // 4. Итерация и распаковка файлов из архива:
    for _, f := range zipReader.File {
        fpath := filepath.Join(destFolder, f.Name)

        // Обработка директорий: создаем папку, если элемент архива является директорией
        if f.FileInfo().IsDir() {
            os.MkdirAll(fpath, os.ModePerm)
            continue
        }

        // Создаем родительскую структуру папок для файла
        if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
            return CheckResult{Success: false, Message: "Не удалось создать структуру папок: " + err.Error()}
        }

        // Создаем файл на диске с правами доступа, указанными в ZIP-архиве
        outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
        if err != nil {
            return CheckResult{Success: false, Message: "Не удалось создать файл на диске: " + err.Error()}
        }

        // Открываем поток файла внутри архива
        rc, err := f.Open()
        if err != nil {
            outFile.Close()
            return CheckResult{Success: false, Message: "Ошибка чтения файла из архива: " + err.Error()}
        }

        // Копируем данные из архива в файл на диске
        _, err = io.Copy(outFile, rc)
        outFile.Close() // Закрываем файл после записи
        rc.Close()      // Закрываем источник данных из архива

        if err != nil {
            return CheckResult{Success: false, Message: "Ошибка записи файла на диск: " + err.Error()}
        }
    }

    return CheckResult{
        Success: true,
        Message: "ServeHub-2 успешно скачан и развернут в рабочей папке!",
    }
}

Умная проверка на фронтенде (main.js):

/**
 * Инициирует установку проекта (скачивание репозитория) и проверяет наличие 
 * файла конфигурации для определения дальнейшего пути пользователя.
 */
window.runInstallation = async function() {
    // 1. Получение ссылок на DOM-элементы для управления состоянием интерфейса
    const installText = document.getElementById('install-text');
    const spinner = document.getElementById('install-loading');
    const btnFinish = document.getElementById('btn-finish');
    const btnRetry = document.getElementById('btn-install-retry');
    const btnEditSecrets = document.getElementById('btn-edit-secrets');
    const btnStep6Back = document.getElementById('btn-step6-back');

    // 2. Настройка начального состояния UI: отображаем индикатор загрузки и блокируем кнопки
    spinner.style.display = 'block';
    installText.innerText = 'Загрузка архива проекта ServeHub-2 с GitHub в память...';
    installText.style.color = 'var(--text-secondary)';
    
    btnFinish.disabled = true;
    btnFinish.style.display = 'inline-block';
    btnRetry.style.display = 'none';
    btnEditSecrets.style.display = 'none';

    try {
        // 3. Вызов бэкенда для клонирования/скачивания проекта
        const result = await InstallProject();
        
        // 4. Обработка успешного скачивания
        if (result.success) {
            // Проверяем, был ли ранее создан файл secrets.yml
            const secretsCheck = await CheckSecrets();
            spinner.style.display = 'none';

            if (secretsCheck.success) {
                // Если конфигурация найдена: пропускаем настройку
                installText.innerHTML = `✓ ${result.message}<br><br><span style="color: var(--accent-soft); font-weight: bold;">ℹ Конфигурация найдена:</span> Файл <code style="background: var(--bg-3); padding: 2px 6px; border-radius: 4px;">secrets.yml</code> уже существует в проекте. Шаги создания и настройки будут пропущены.`;
                installText.style.color = 'var(--accent-bright)';
                
                btnFinish.disabled = false;
                btnFinish.innerText = 'Перейти к выбору развертки';
                btnFinish.setAttribute('onclick', 'goToStep(6)'); // Направляем на выбор сценария
                btnEditSecrets.style.display = 'inline-block';

                if (btnStep6Back) {
                    btnStep6Back.setAttribute('onclick', 'goToStep(3)');
                }
            } else {
                // Если конфигурации нет: направляем пользователя на шаг создания настроек
                installText.innerText = `✓ ${result.message}`;
                installText.style.color = 'var(--accent-bright)';
                
                btnFinish.disabled = false;
                btnFinish.innerText = 'Продолжить к настройке';
                btnFinish.setAttribute('onclick', 'goToStep(4)'); // Направляем на ввод секретов

                if (btnStep6Back) {
                    btnStep6Back.setAttribute('onclick', 'goToStep(5)');
                }
            }
        } else {
            // Ошибка при установке (например, нет интернета)
            spinner.style.display = 'none';
            installText.innerText = `✗ Ошибка установки: ${result.message}`;
            installText.style.color = 'var(--danger)';
            btnFinish.style.display = 'none';
            btnRetry.style.display = 'inline-block'; // Даем возможность повторить
        }
    } catch (err) {
        // 5. Обработка системных сбоев (ошибки API Wails или падение бэкенда)
        spinner.style.display = 'none';
        installText.innerText = 'Критическая ошибка в процессе установки:\n' + err;
        installText.style.color = 'var(--danger)';
        btnFinish.style.display = 'none';
        btnRetry.style.display = 'inline-block';
    }
}

Шаг 4: Настройка secrets.yml

В полной версии представлен весь маппинг полей для парсинга конфигурации из YAML-файла. Парсер корректно собирает многострочные литералы (приватные SSH-ключи), отбрасывая лишние отступы.

Заполнение формы из YAML (main.js):

/**
 * Словарь сопоставления ключей YAML (из secrets.yml) 
 * с ID элементов ввода в HTML-форме.
 * Позволяет легко поддерживать соответствие между структурой конфига и UI.
 */
const SECRETS_FIELD_MAP = {
    vps_public_ip: 'sec_vps_ip',
    vps_user: 'sec_vps_user',
    vps_root_password: 'sec_vps_root_pass',
    local_private_ip: 'sec_local_ip',
    local_user: 'sec_local_user',
    local_root_password: 'sec_local_root_pass',
    server_name: 'sec_server_name',
    admin_user: 'sec_admin_user',
    admin_password: 'sec_admin_pass',
    email: 'sec_email',
    telegram_token: 'sec_tg_token',
    telegram_chat_id: 'sec_tg_chat_id',
    webnames_apikey: 'sec_webnames',
    postgres_db_nextcloud: 'sec_pg_nc',
    postgres_db_vaultwarden: 'sec_pg_vw',
    postgres_user: 'sec_pg_user',
    postgres_password: 'sec_pg_pass',
    nextcloud_redis_pass: 'sec_redis_pass',
    secret_vaultwarden_password: 'sec_vw_admin_pass',
    ssh_public_key: 'sec_public_ssh_key',
    ssh_private_key: 'sec_private_ssh_key',
    borgmatic_encryption_passphrase: 'sec_borg_pass',
    backup_disk_uuid: 'sec_disk_uuid',
};

/**
 * Парсит содержимое YAML-файла и заполняет соответствующие поля HTML-формы.
 * Поддерживает как простые строковые значения, так и многострочные литералы (для SSH-ключей).
 * @param {string} yamlText - Исходный текст файла secrets.yml.
 */
function populateSecretsForm(yamlText) {
    const lines = yamlText.split('\n');
    let i = 0;

    while (i < lines.length) {
        const line = lines[i];

        // 1. Обработка многострочных литералов (ключ вида `key: |`)
        // Используется для SSH-ключей, которые могут содержать много строк
        const literalMatch = line.match(/^([a-z_]+):\s*\|\s*$/);
        if (literalMatch) {
            const fieldId = SECRETS_FIELD_MAP[literalMatch[1]];
            i++; // Переходим к следующей строке (содержимое блока)
            const blockLines = [];
            
            // Читаем все строки, которые имеют отступ (4 пробела)
            while (i < lines.length && (lines[i].startsWith('    ') || lines[i].trim() === '')) {
                // Удаляем 4 ведущих пробела, чтобы восстановить оригинальное содержимое
                blockLines.push(lines[i].replace(/^ {4}/, ''));
                i++;
            }
            
            // Удаляем пустые строки в конце блока
            while (blockLines.length && blockLines[blockLines.length - 1] === '') {
                blockLines.pop();
            }
            
            // Записываем собранный блок в соответствующее поле формы
            if (fieldId) {
                const el = document.getElementById(fieldId);
                if (el) el.value = blockLines.join('\n');
            }
            continue; // Пропускаем инкремент i в конце цикла, так как мы уже продвинулись
        }

        // 2. Обработка стандартных строковых переменных (ключ вида `key: "value"`)
        const simpleMatch = line.match(/^([a-z_]+):\s*"(.*)"\s*$/);
        if (simpleMatch) {
            const fieldId = SECRETS_FIELD_MAP[simpleMatch[1]];
            if (fieldId) {
                const el = document.getElementById(fieldId);
                // Записываем значение из группы регулярного выражения во второе поле ввода
                if (el) el.value = simpleMatch[2];
            }
        }
        i++;
    }
}

Шаг 5: Проверка конфигурации

При нажатии на “Продолжить” мы собираем значения из 23 полей формы и генерируем единый шаблон конфигурации.

Генерация шаблона (main.js):

/**
 * Функция собирает данные из формы конфигурации, формирует YAML-файл в виде строки
 * и подготавливает его для предварительного просмотра пользователем.
 */
window.generateAndReviewYaml = function() {
    // 1. Сбор данных из всех полей ввода формы по их ID
    const vpsIp = document.getElementById('sec_vps_ip').value;
    const vpsUser = document.getElementById('sec_vps_user').value;
    const vpsRootPass = document.getElementById('sec_vps_root_pass').value;
    const localIp = document.getElementById('sec_local_ip').value;
    const localUser = document.getElementById('sec_local_user').value;
    const localRootPass = document.getElementById('sec_local_root_pass').value;
    
    const serverName = document.getElementById('sec_server_name').value;
    const adminUser = document.getElementById('sec_admin_user').value;
    const adminPass = document.getElementById('sec_admin_pass').value;
    const email = document.getElementById('sec_email').value;
    
    const tgToken = document.getElementById('sec_tg_token').value;
    const tgChatId = document.getElementById('sec_tg_chat_id').value;
    const webnamesKey = document.getElementById('sec_webnames').value;
    
    const pgNc = document.getElementById('sec_pg_nc').value;
    const pgVw = document.getElementById('sec_pg_vw').value;
    const pgUser = document.getElementById('sec_pg_user').value;
    const pgPass = document.getElementById('sec_pg_pass').value;
    const redisPass = document.getElementById('sec_redis_pass').value;
    
    const vwAdminPass = document.getElementById('sec_vw_admin_pass').value;
    const publicsshKey = document.getElementById('sec_public_ssh_key').value;
    const privatesshKey = document.getElementById('sec_private_ssh_key').value;
    const borgPass = document.getElementById('sec_borg_pass').value;
    const diskUuid = document.getElementById('sec_disk_uuid').value;

    // 2. Форматирование приватного SSH ключа:
    // YAML требует специфического отступа для многострочных литералов (символ `|`).
    // Мы разбиваем строку по символу переноса и добавляем 4 пробела к каждой строке.
    const formattedPrivateKey = privatesshKey
        .split('\n')
        .map(line => '    ' + line)
        .join('\n');

    // 3. Формирование YAML-документа:
    // Используем шаблонные строки (` `) для вставки переменных. 
    // Это обеспечивает правильную структуру конфига, понятную для Ansible.
    generatedYamlString = `# ==============================================================================
#                 ServeHub-2: СГЕНЕРИРОВАННЫЙ КОНФИГУРАЦИОННЫЙ SECRETS.YML
# ==============================================================================

# 1. СЕТЕВАЯ ИНФРАСТРУКТУРА И ПОЛЬЗОВАТЕЛИ
vps_public_ip: "${vpsIp}"
vps_user: "${vpsUser}"
vps_root_password: "${vpsRootPass}"
local_private_ip: "${localIp}"
local_user: "${localUser}"
local_root_password: "${localRootPass}"

# 2. ГЛОБАЛЬНЫЕ НАСТРОЙКИ
server_name: "${serverName}"
admin_user: "${adminUser}"
admin_password: "${adminPass}"
email: "${email}"

# 3. НАСТРОЙКИ АЛЕРТИНГА
telegram_token: "${tgToken}"
telegram_chat_id: "${tgChatId}"

# 4. ИНТЕГРАЦИЯ DNS
webnames_apikey: "${webnamesKey}"

# 5. НАСТРОЙКИ POSTGRESQL
postgres_db_nextcloud: "${pgNc}"
postgres_db_vaultwarden: "${pgVw}"
postgres_user: "${pgUser}"
postgres_password: "${pgPass}"
nextcloud_redis_pass: "${redisPass}"

# 6. СЕКРЕТЫ СЕРВИСОВ
secret_vaultwarden_password: "${vwAdminPass}"
ssh_public_key: "${publicsshKey}"
ssh_private_key: |
${formattedPrivateKey}
borgmatic_encryption_passphrase: "${borgPass}"
backup_disk_uuid: "${diskUuid}"
`;

    // 4. Отображение результата:
    // Выводим полученный YAML в блок предпросмотра на странице
    document.getElementById('yaml-preview').innerText = generatedYamlString;

    // 5. Управление навигацией:
    // Настраиваем кнопку "Назад", чтобы она вела на экран редактирования данных
    const btnStep6Back = document.getElementById('btn-step6-back');
    if (btnStep6Back) {
        btnStep6Back.setAttribute('onclick', 'goToStep(5)');
    }
    
    // Переход к шагу 5 (просмотр и подтверждение)
    goToStep(5);
}

Сохранение файла (app.go):

// SaveSecrets сохраняет сгенерированный YAML-контент в файл конфигурации проекта.
// Метод принимает строку с содержимым будущего secrets.yml и возвращает результат операции.
func (a *App) SaveSecrets(yamlContent string) CheckResult {
    // 1. Определение пути к файлу:
    // Формируем относительный путь к директории, где Ansible ожидает увидеть файл переменных.
    // Используется filepath.Join для кроссплатформенной совместимости путей.
    targetDir := filepath.Join(".", "ServeHub-2-main", "ansible", "vars")

    // 2. Подготовка директорий:
    // os.MkdirAll создает всю цепочку директорий, если они отсутствуют.
    // os.ModePerm (0777) устанавливает полные права доступа для создаваемых папок.
    err := os.MkdirAll(targetDir, os.ModePerm)
    if err != nil {
        // Возвращаем ошибку, если у приложения нет прав на создание папок или возникла проблема с диском.
        return CheckResult{
            Success: false, 
            Message: "Не удалось создать директорию конфигурации: " + err.Error(),
        }
    }

    // 3. Формирование пути к файлу:
    targetFile := filepath.Join(targetDir, "secrets.yml")

    // 4. Запись данных:
    // os.WriteFile создает файл (или перезаписывает существующий) и записывает в него байты.
    // Права 0644 (rw-r--r--) позволяют владельцу читать и писать в файл, а остальным — только читать.
    // Это стандартный уровень безопасности для конфигурационных файлов с секретами.
    err = os.WriteFile(targetFile, []byte(yamlContent), 0644)
    if err != nil {
        // Если запись не удалась (например, файл занят или доступ ограничен), возвращаем ошибку.
        return CheckResult{
            Success: false, 
            Message: "Не удалось записать файл secrets.yml: " + err.Error(),
        }
    }

    // 5. Успешное завершение:
    // Конфигурация успешно сохранена на диск.
    return CheckResult{
        Success: true, 
        Message: "Файл secrets.yml успешно создан!",
    }
}

Шаг 6: Выбор сценария деплоя

Здесь реализована логика выделения выбранной карточки развертывания и подтверждения старта.

Выбор карточки (main.js):

// Глобальная переменная для хранения выбранного пользователем ID сценария развертывания.
// Инициализируется нулем, так как выбор еще не сделан.
let selectedOptionNum = 0; 

/**
 * Обрабатывает выбор карточки сценария деплоя пользователем.
 * @param {number} num - Идентификатор выбранного сценария.
 */
window.selectDeployOption = function(num) {
    selectedOptionNum = num;

    // 1. Сброс визуального состояния:
    // Находим все карточки с классом 'deploy-option-card' и убираем у них класс 'selected',
    // чтобы при выборе новой карточки старая потеряла выделение.
    document.querySelectorAll('.deploy-option-card').forEach(card => {
        card.classList.remove('selected');
    });

    // 2. Установка нового выделения:
    // Находим карточку по ID (например, 'opt-1') и добавляем ей класс 'selected' для подсветки через CSS.
    const selectedCard = document.getElementById(`opt-${num}`);
    if (selectedCard) {
        selectedCard.classList.add('selected');
    }

    // 3. Активация кнопки перехода:
    // Делаем кнопку 'Далее' доступной, так как сценарий теперь выбран.
    const btnNext = document.getElementById('btn-deploy-next');
    if (btnNext) {
        btnNext.disabled = false;
    }
}

/**
 * Подготавливает данные для финального экрана перед началом выполнения Ansible-сценария.
 */
window.confirmDeployment = function() {
    // Если пользователь не выбрал сценарий (выбран 0), прерываем выполнение.
    if (selectedOptionNum === 0) return;

    // Словарь текстовых описаний для понятного отображения выбранного пункта в UI.
    const optionTexts = {
        1: "Пункт 1. Полный деплой (Локальный + VPS)",
        2: "Пункт 2. Запуск сервисов (Локальный + VPS)",
        3: "Пункт 3. Полный деплой (Локальный)",
        4: "Пункт 4. Запуск сервисов (Локальный)",
        5: "Пункт 5. Полный деплой (VPS)",
        6: "Пункт 6. Запуск сервисов (VPS)"
    };

    // Обновляем текстовое поле на экране подтверждения, подставляя описание выбранного сценария.
    const confirmTextEl = document.getElementById('confirm-option-text');
    if (confirmTextEl) {
        confirmTextEl.innerText = optionTexts[selectedOptionNum] || `Сценарий №${selectedOptionNum}`;
    }

    // 4. Проверка состояния отладки (логирование):
    // Проверяем, включен ли переключатель "Детализация логов" (-vvv).
    const isVerbose = document.getElementById('verbose-logs-toggle').checked;
    const confirmVerboseEl = document.getElementById('confirm-verbose-text');
    
    // Выводим статус отладки на экран подтверждения для информирования пользователя.
    if (confirmVerboseEl) {
        confirmVerboseEl.innerText = isVerbose ? "Да (-vvv)" : "Нет";
    }

    // Переход к шагу 7 — непосредственно экрану запуска деплоя.
    goToStep(7);
}

Шаг 7: Запуск деплоя

Это самый сложный шаг, где происходит запуск процесса в псевдотерминале и трансляция логов в реальном времени. В полных функциях видно, как обрабатываются все 6 вариантов деплоя через switch/case, а также как удаляются цветовые коды из потока логов.

Бэкенд: Запуск Ansible в PTY (app.go):

// ansiEscape — это регулярное выражение для поиска и удаления ANSI escape-последовательностей.
// Эти коды используются терминалом для раскрашивания текста (цвета шрифта, фона, жирность).
// Поскольку мы выводим логи в HTML-блок <pre>, нам нужно их очистить, чтобы в логах не появлялись "мусорные" символы.
var ansiEscape = regexp.MustCompile(
	"\x1b\\][^\x07\x1b]*(\x07|\x1b\\\\)" + // Коды управления операционной системой
		"|\x1b\\[[0-9;?]*[a-zA-Z]" + // Коды форматирования графики (цвета и стили)
		"|\x1b[()[A-Za-z0-9]" + // Коды выбора кодировки и наборов символов
		"|\x1b[=>78]", // Коды инициализации/сброса терминала
)

// stripAnsi удаляет все найденные ANSI-коды из строки.
func stripAnsi(s string) string {
	return ansiEscape.ReplaceAllString(s, "")
}

// RunDeployment выполняет Ansible-плэйбуки через Docker-контейнер, 
// используя PTY для эмуляции интерактивного терминала.
func (a *App) RunDeployment(option int, verbose bool) CheckResult {
	var steps []string
	debugArgs := ""
	if verbose {
		debugArgs = " -vvv" // Добавляем флаг подробного логирования, если нужно
	}

	// 1. Формирование сценария деплоя на основе выбора пользователя
	// Каждый шаг — это отдельный запуск команды ansible-playbook через docker compose
	switch option {
	case 1:
		steps = []string{
			fmt.Sprintf("ansible-playbook -i ansible/inventory.ini ansible/deploy.yml --limit vps,local --tags bootstrap%s", debugArgs),
			fmt.Sprintf("ansible-playbook -i ansible/inventory.ini ansible/deploy.yml --limit vps --skip-tags bootstrap%s", debugArgs),
			fmt.Sprintf("ansible-playbook -i ansible/inventory.ini ansible/deploy.yml --limit local --skip-tags bootstrap%s", debugArgs),
		}
	// ... (другие кейсы)
	default:
		return CheckResult{Success: false, Message: "Неизвестный сценарий развертывания!"}
	}

	// 2. Последовательное выполнение команд из списка steps
	for _, ansibleCmd := range steps {
		wailsRuntime.EventsEmit(a.ctx, "deploy-log")

		// Команда для исполнения внутри контейнера
		cmdArgs := []string{
			"compose",
			"-f", "./ServeHub-2-main/docker-compose.ansible.yaml",
			"run", "--rm", "ansible",
			"sh", "-c", ansibleCmd,
		}

		// 3. Создание псевдотерминала (PTY):
		// Ansible требует PTY, чтобы корректно отображать прогресс-бары и цветовую разметку.
		ptty, err := pty.New()
		if err != nil {
			return CheckResult{Success: false, Message: fmt.Sprintf("Не удалось создать псевдотерминал: %s", err.Error())}
		}
		_ = ptty.Resize(200, 50) // Устанавливаем размер окна терминала для корректного переноса строк

		// Инициализация процесса
		cmd := ptty.CommandContext(a.ctx, "docker", cmdArgs...)
		cmd.Env = append(os.Environ(), "TERM=xterm-256color") // Эмулируем полноценный терминал

		if err := cmd.Start(); err != nil {
			ptty.Close()
			return CheckResult{Success: false, Message: fmt.Sprintf("Не удалось запустить Docker: %s", err.Error())}
		}

		// Сохраняем ссылки в структуре App, чтобы функция SendEnter могла ими воспользоваться
		a.ptty = ptty
		a.cmdStdin = ptty
		a.activeCmd = cmd

		// 4. Асинхронное чтение логов:
		// Создаем отдельную горутину, которая постоянно читает вывод из PTY
		go func(p pty.Pty) {
			scanner := bufio.NewScanner(p)
			scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // Увеличиваем буфер для длинных строк лога
			for scanner.Scan() {
				clean := stripAnsi(scanner.Text())
				if clean == "" {
					continue
				}
				// Отправляем строку на фронтенд (Wails событие)
				wailsRuntime.EventsEmit(a.ctx, "deploy-log", clean+"\n")
			}
		}(ptty)

		// Ждем завершения текущего плэйбука
		waitErr := cmd.Wait()

		// Очистка ресурсов после завершения команды
		ptty.Close()
		a.ptty = nil
		a.cmdStdin = nil

		if waitErr != nil {
			return CheckResult{
				Success: false,
				Message: fmt.Sprintf("Ansible завершился с ошибкой: %s", waitErr.Error()),
			}
		}
	}

	return CheckResult{
		Success: true,
		Message: "Все этапы деплоя Ansible успешно выполнены!",
	}
}

Фронтенд: Прослушивание логов (main.js):

/**
 * Основная функция для инициализации и запуска процесса деплоя через Ansible.
 * Управляет состоянием интерфейса, подпиской на потоки логов и обработкой ошибок.
 */
window.startDeployment = async function() {
    // 1. Получение ссылок на DOM-элементы для управления интерфейсом
    const logContainer = document.getElementById('deploy-logs-container');
    const logPre = document.getElementById('deploy-logs');
    const btnRun = document.getElementById('btn-deploy-run');
    const btnBack = document.getElementById('btn-deploy-back');
    const btnExit = document.getElementById('btn-deploy-exit');
    const btnEnter = document.getElementById('btn-deploy-enter');
    const isVerbose = document.getElementById('verbose-logs-toggle').checked;

    // 2. Первичная настройка UI: отображение контейнера логов и статусная строка
    logContainer.style.display = 'block';
    logPre.innerText = 'Подготовка окружения и инициализация процесса деплоя...\n';

    // Блокировка кнопок навигации, чтобы пользователь не мог прервать процесс
    btnRun.disabled = true;
    btnBack.disabled = true;
    if (btnExit) btnExit.style.display = 'none';
    // Показываем кнопку "Enter" для интерактивного взаимодействия с Ansible
    if (btnEnter) btnEnter.style.display = 'block';

    const postDeployGuide = document.getElementById('post-deploy-guide');
    if (postDeployGuide) postDeployGuide.style.display = 'none';

    /**
     * Вспомогательная функция для добавления логов в терминал с автоскроллом.
     */
    function addLog(message) {
        const logPre = document.getElementById('deploy-logs');
        // Проверяем, находится ли скролл в самом низу (погрешность 5px)
        const isAtBottom = (logPre.scrollHeight - logPre.clientHeight) <= (logPre.scrollTop + 5);
        
        // Добавляем текст в блок логов
        logPre.innerText += message;
        
        // Если пользователь не прокручивал лог вручную вверх, автоматически прокручиваем вниз
        if (isAtBottom) {
            logPre.scrollTop = logPre.scrollHeight;
        }
    }

    // 3. Подписка на событие логов:
    // Мы используем EventsOn из Wails, чтобы слушать события, приходящие из Go.
    // Флаг deployLogListened предотвращает многократную подписку при повторных запусках.
    if (!window.deployLogListened) {
        EventsOn('deploy-log', (message) => {
            addLog(message);
        });
        window.deployLogListened = true;
    }

    // 4. Запуск основного процесса деплоя:
    try {
        // Вызов функции Go-бэкенда. Выполнение блокирует этот поток, пока процесс не завершится.
        const result = await RunDeployment(selectedOptionNum, isVerbose);
        
        // Скрываем кнопку ввода, так как деплой завершился
        if (btnEnter) btnEnter.style.display = 'none';

        // 5. Обработка результата (Успех/Ошибка Ansible):
        if (result.success) {
            logPre.innerText += `\n[ УСПЕХ ]: ${result.message}\n`;
            logPre.style.borderColor = '#818cf8'; // Подсветка границ в случае успеха
            btnRun.style.display = 'none';
            btnBack.style.display = 'none';
            if (btnExit) {
                btnExit.style.display = 'block';
                btnExit.className = 'btn-primary';
            }

            // Показываем руководство, что делать после успешного деплоя
            const guide = document.getElementById('post-deploy-guide');
            if (guide) guide.style.display = 'block';
        } else {
            // Обработка логических ошибок Ansible (например, ошибка конфигурации)
            logPre.innerText += `\n[ ОШИБКА ]: Деплой завершился неудачей.\nПричина: ${result.message}\n`;
            logPre.style.borderColor = '#e5484d'; // Подсветка границ (красный)
            btnRun.disabled = false;
            btnBack.disabled = false;
            if (btnExit) btnExit.style.display = 'block';
        }
    } catch (err) {
        // 6. Обработка критических системных ошибок (например, падение самого Docker/Ansible)
        if (btnEnter) btnEnter.style.display = 'none';
        logPre.innerText += `\n[ КРИТИЧЕСКАЯ ОШИБКА СИСТЕМЫ ]: Ошибка вызова бэкенда:\n${err}\n`;
        logPre.style.borderColor = '#e5484d';

        btnRun.disabled = false;
        btnBack.disabled = false;
        if (btnExit) btnExit.style.display = 'block';
    }
}

Отправка сигнала Enter в процесс (app.go):

// SendEnter отправляет сигнал нажатия клавиши "Enter" в интерактивный процесс Ansible.
// Данный метод используется, когда Ansible приостанавливает выполнение для ожидания
// пользовательского ввода (например, подтверждение sudo-пароля или перезаписи файла).
func (a *App) SendEnter() CheckResult {
    // 1. Проверка состояния процесса:
    // Мы проверяем, инициализирован ли интерфейс записи в стандартный поток ввода (stdin).
    // Если cmdStdin равен nil, значит процесс либо еще не запущен, либо уже завершился,
    // и отправка сигнала невозможна.
    if a.cmdStdin == nil {
        return CheckResult{
            Success: false, 
            Message: "Процесс деплоя не запущен или не ожидает ввода",
        }
    }

    // 2. Отправка управляющей последовательности:
    // Мы записываем в поток ввода "\r\n" (Return + Newline). 
    // Это стандартный способ эмуляции нажатия клавиши Enter для POSIX-совместимых терминалов,
    // через которые Ansible взаимодействует с пользователем.
    _, err := a.cmdStdin.Write([]byte("\r\n"))
    
    // 3. Обработка ошибок записи:
    // Если возникла ошибка записи в поток (например, канал был закрыт до того, как
    // мы успели отправить байты), возвращаем статус ошибки вместе с описанием причины.
    if err != nil {
        return CheckResult{
            Success: false, 
            Message: fmt.Sprintf("Не удалось отправить Enter: %s", err.Error()),
        }
    }

    // 4. Подтверждение операции:
    // Если запись прошла успешно, возвращаем статус успеха.
    return CheckResult{
        Success: true, 
        Message: "Сигнал Enter успешно отправлен",
    }
}

Для фронтенда также реализована функция вызова бэкенда:

/**
 * Функция-обертка для отправки сигнала Enter в запущенный процесс Ansible.
 * Используется в интерфейсе для взаимодействия с интерактивными запросами Ansible
 * (например, подтверждение пароля или перезаписи файлов), если они возникают.
 */
window.sendEnterToAnsible = async function() {
    try {
        // Вызываем GO-функцию SendEnter, которая реализована в app.go
        // Она обращается напрямую к стандартному вводу (stdin) запущенного псевдотерминала (PTY)
        const result = await SendEnter();
        
        // Проверяем результат выполнения операции на бэкенде
        if (!result.success) {
            // Если возникла логическая ошибка (например, процесс уже завершился), 
            // выводим предупреждение в консоль разработчика, не прерывая работу приложения
            console.warn("Попытка отправки Enter была отклонена:", result.message);
        } else {
            // Опционально: можно добавить лог для отладки успешного действия
            console.log("Сигнал Enter успешно отправлен в процесс.");
        }
    } catch (err) {
        // Обработка критических ошибок (например, потеря связи с бэкендом Wails)
        // Выводим детальную информацию об ошибке в консоль
        console.error("Произошла системная ошибка при попытке отправки Enter:", err);
    }
}

Заключение

Постарался подробно пройти по основным моментам установщика Go Wails. Некоторые моменты пришлось опустить, однако в целом полная архитектура и работа наглядно видны, надеюсь что это так.

Вероятно это последняя статья по проекту ServeHub-2, я еще конечно могу рассказать про сборку AppImage, более подробно пройтись по функциям и тд, но вероятно я это сделаю если будет еще что-то, что можно добавить для полноценной статьи.

Внешний вид установщика можно посмотреть, скачав бинарник или AppImage из релизов проекта, я честно пытался встроить нормальное видео, но у меня не очень получилось, поэтому вот так.

Вся кодовая база проекта, подробная документация, инструкции по развертыванию открыты и доступны для сообщества:

🔗 GitHub-репозиторий: https://github.com/canntstand/ServeHub-2

Буду рад вашему фидбеку в комментариях!