У 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 MB | 200+ 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.