Назад к блогу

Wails: десктоп-приложения на Go + Vue без боли Electron

Как я собрал GUI-клиент для fxTunnel на Wails — Go-бэкенд, Vue-фронтенд, нативный webview, 15 MB бинарник вместо 150 MB Electron. Сборка, IPC, авто-обновления и кросс-платформа.

Wails: десктоп-приложения на Go + Vue без боли Electron

У fxTunnel была только CLI-версия, и не все пользователи готовы работать с терминалом. Нужен нормальный GUI — список туннелей, кнопка «Connect», лог соединений, system tray. Первая мысль — Electron. Но 150 MB для приложения, которое управляет TCP-туннелями, — перебор. Я нашёл Wails и решил попробовать.

Почему не Electron

Я не хочу тратить время на Electron-bashing, но факты: минимальное Electron-приложение весит 120–180 MB. Оно тащит за собой целый Chromium и Node.js. Для мессенджера или IDE это терпимо. Для утилиты, которая показывает список туннелей и пару кнопок — абсурд.

Tauri был альтернативой, но его бэкенд на Rust — лишний язык в стеке. fxTunnel уже написан на Go, и я хотел переиспользовать код.

Wails: что это и как работает

Wails — фреймворк для десктопных приложений с Go-бэкендом и веб-фронтендом. Вместо встроенного Chromium он использует нативный webview системы: WebView2 на Windows, WebKit на macOS и Linux. Отсюда — маленький размер бинарника.

Архитектура простая:

┌──────────────────────────────────────┐
│           Нативное окно OS           │
│  ┌────────────────────────────────┐  │
│  │     WebView (системный)       │  │
│  │  ┌──────────────────────────┐  │  │
│  │  │   Vue.js фронтенд       │  │  │
│  │  │   (HTML/CSS/JS)          │  │  │
│  │  └──────────┬───────────────┘  │  │
│  └─────────────┼──────────────────┘  │
│                │ IPC (bindings)       │
│  ┌─────────────┼──────────────────┐  │
│  │   Go бэкенд │                  │  │
│  │   (бизнес-логика, fxTunnel)    │  │
│  └────────────────────────────────┘  │
└──────────────────────────────────────┘

Инициализация проекта:

wails init -n fxtunnel-gui -t vue-ts

Это создаёт структуру с Go-бэкендом и Vue + TypeScript фронтендом. wails dev запускает приложение с hot-reload для фронтенда — разработка ощущается как обычный веб-проект.

IPC: вызов Go-функций из JavaScript

Главная магия Wails — бесшовный IPC. Вы пишете методы на Go, а Wails генерирует TypeScript-обёртки, которые вызываются из фронтенда как обычные async-функции.

Go-сторона:

type TunnelService struct {
    ctx    context.Context
    config *tunnel.Config
    client *tunnel.Client
}

func NewTunnelService() *TunnelService {
    return &TunnelService{}
}

// startup вызывается при инициализации Wails
func (t *TunnelService) startup(ctx context.Context) {
    t.ctx = ctx
}

// Этот метод станет доступен из JS
func (t *TunnelService) GetTunnels() ([]TunnelInfo, error) {
    tunnels, err := t.client.ListTunnels()
    if err != nil {
        return nil, fmt.Errorf("не удалось получить туннели: %w", err)
    }
    result := make([]TunnelInfo, len(tunnels))
    for i, tun := range tunnels {
        result[i] = TunnelInfo{
            ID:         tun.ID,
            Name:       tun.Name,
            LocalPort:  tun.LocalPort,
            RemoteAddr: tun.RemoteAddr,
            Status:     tun.Status(),
            BytesIn:    tun.Stats.BytesIn,
            BytesOut:   tun.Stats.BytesOut,
        }
    }
    return result, nil
}

func (t *TunnelService) CreateTunnel(name string, localPort int, remoteAddr string) error {
    return t.client.CreateTunnel(tunnel.CreateRequest{
        Name:       name,
        LocalPort:  localPort,
        RemoteAddr: remoteAddr,
    })
}

После wails generate module, на стороне Vue я вызываю эти методы напрямую:

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { GetTunnels, CreateTunnel } from '../wailsjs/go/main/TunnelService'

const tunnels = ref<TunnelInfo[]>([])
const loading = ref(false)

onMounted(async () => {
    tunnels.value = await GetTunnels()
})

async function addTunnel() {
    loading.value = true
    try {
        await CreateTunnel(newName.value, newPort.value, newRemote.value)
        tunnels.value = await GetTunnels()
    } catch (err) {
        // Wails пробрасывает Go-ошибки как JS-исключения
        console.error('Ошибка:', err)
    } finally {
        loading.value = false
    }
}
</script>

Никакого ручного JSON, никакого HTTP между процессами. Типы синхронизированы — если я добавлю поле в Go-структуру, TypeScript подхватит его после генерации.

События: реалтайм-обновления из Go

Для обновления статуса туннелей в реальном времени Wails поддерживает систему событий. Go-бэкенд эмитит события, Vue-фронтенд подписывается.

// В Go — отправляем обновления статуса
func (t *TunnelService) watchTunnelStatus() {
    ticker := time.NewTicker(2 * time.Second)
    for range ticker.C {
        stats := t.client.GetAllStats()
        runtime.EventsEmit(t.ctx, "tunnel:stats", stats)
    }
}
// Во Vue — подписываемся
import { EventsOn } from '../wailsjs/runtime/runtime'

onMounted(() => {
    EventsOn('tunnel:stats', (stats: TunnelStats[]) => {
        updateDashboard(stats)
    })
})

Это позволило сделать живой дашборд: количество активных соединений, трафик, латентность — всё обновляется каждые 2 секунды без polling со стороны фронтенда.

Системный трей и фоновая работа

fxTunnel GUI должен работать в фоне — пользователь запускает туннели и сворачивает приложение в трей. Wails поддерживает это из коробки:

func main() {
    app := wails.NewApp(&options.App{
        Title:            "fxTunnel",
        Width:            900,
        Height:           600,
        StartHidden:      false,
        HideWindowOnClose: true, // Сворачивать в трей вместо закрытия
        OnBeforeClose: func(ctx context.Context) bool {
            // Спрашиваем пользователя
            dialog, _ := runtime.MessageDialog(ctx, runtime.MessageDialogOptions{
                Type:    runtime.QuestionDialog,
                Title:   "Выход",
                Message: "Свернуть в трей или завершить?",
                Buttons: []string{"В трей", "Завершить"},
            })
            return dialog == "Завершить"
        },
        Bind: []interface{}{
            NewTunnelService(),
        },
    })
    app.Run()
}

Авто-обновления

Для авто-обновлений я использовал go-selfupdate. При старте приложение проверяет GitHub Releases на наличие новой версии:

func (t *TunnelService) CheckUpdate() (*UpdateInfo, error) {
    latest, found, err := selfupdate.DetectLatest("fxcode/fxtunnel")
    if err != nil || !found {
        return nil, err
    }
    current := semver.MustParse(Version)
    if latest.Version.LTE(current) {
        return nil, nil // уже актуальная версия
    }
    return &UpdateInfo{
        CurrentVersion: Version,
        LatestVersion:  latest.Version.String(),
        ReleaseNotes:   latest.ReleaseNotes,
    }, nil
}

func (t *TunnelService) ApplyUpdate() error {
    latest, _, _ := selfupdate.DetectLatest("fxcode/fxtunnel")
    exe, _ := os.Executable()
    return selfupdate.UpdateTo(latest.AssetURL, exe)
}

На фронтенде — ненавязчивое уведомление с кнопкой «Обновить». Никаких принудительных обновлений.

Кросс-платформенная сборка

Сборка для всех платформ:

# macOS (на macOS)
wails build -platform darwin/amd64,darwin/arm64

# Windows (кросс-компиляция с Linux)
wails build -platform windows/amd64 -nsis  # NSIS создаёт установщик

# Linux
wails build -platform linux/amd64

Размеры итоговых бинарников (Electron-размеры — примерные, из общих наблюдений сообщества, не мой бенчмарк):

ПлатформаfxTunnel GUI (Wails)Типичный Electron
Windows~14 MB (+установщик)~150-170 MB
macOS~13 MB~170-180 MB
Linux~15 MB~150-160 MB
RAM при работе35-50 MB200+ MB

Разница заметная. Для утилиты, которая работает в фоне, это существенно.

Подводные камни

Wails — не идеален. Вот на что я наткнулся:

WebView2 на Windows. На Windows 10 старых версий WebView2 может быть не установлен. Wails умеет встраивать bootstrapper, но это нужно явно настроить, иначе приложение откажется стартовать у части пользователей.

Linux-зависимости. На Linux нужны libgtk-3 и libwebkit2gtk. В Docker-сборках это требует отдельного этапа установки. Для пользователей — обычно уже установлены с desktop environment.

Отладка. DevTools доступны в dev-режиме, но в production-сборке отключены. Если баг проявляется только в релизе — отлаживать сложнее. Я добавил встроенное логирование, которое пишет в файл и показывается во вкладке «Логи» в самом приложении.

Итог

Wails занял нишу, которую Electron занимать не должен был: лёгкие десктопные утилиты. Для fxTunnel GUI это оказался хороший выбор — Go-бэкенд переиспользует код из CLI-версии, Vue-фронтенд разрабатывается как обычное веб-приложение, а итоговый бинарник весит ~15 MB.

Есть ли минусы? Экосистема Wails пока заметно меньше, чем у Electron. Готовых компонентов, плагинов и ответов на StackOverflow — в разы меньше. Для сложного UI с drag-and-drop, кастомными окнами и пр. Electron, возможно, по-прежнему проще. Но для утилит, где Go уже в стеке, — Wails стоит попробовать.

Если ваш бэкенд на Go и вам нужен GUI — посмотрите на Wails прежде чем тянуть Electron.