Files
tunnel/internal/desktop/app.go
T
TZGyn ddcea99f37 Add a cross-platform desktop app (Fyne)
The tunnel client now has a desktop UI over the same internal/client
service: status with live connection state, routes with add/remove/copy,
live logs, settings, profile import, and a start/stop button that runs
the bridge in-process. It uses the existing ~/.config/tunnel/config.json
and shows a warning banner with a stop button when the CLI service is
already running and claiming the routes.
2026-10-29 21:32:21 +08:00

574 lines
14 KiB
Go

// Package desktop is the Fyne desktop UI for the tunnel client.
package desktop
import (
"context"
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"image/color"
"io"
"log"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"gitea.tzgyn.dev/TZGyn/tunnel/internal/client"
)
var (
green = color.NRGBA{0x3f, 0xb9, 0x50, 0xff}
red = color.NRGBA{0xf8, 0x51, 0x49, 0xff}
gray = color.NRGBA{0x8b, 0x94, 0x9e, 0xff}
)
// App is the desktop application.
type App struct {
version string
configPath string
paths client.Paths
app fyne.App
window fyne.Window
cfg *client.Config
svc *client.Service
logWriter *uiLogWriter
runMu sync.Mutex
runCancel context.CancelFunc
dot *canvas.Circle
stateLabel *widget.Label
lastError *widget.Label
sessionLabel *widget.Label
apiLabel *widget.Label
tunnelLabel *widget.Label
certLabel *widget.Label
configLabel *widget.Label
startButton *widget.Button
routeList *widget.List
logList *widget.List
banner fyne.CanvasObject
}
// Run creates and runs the desktop app.
func Run(version string) {
a := &App{version: version, configPath: client.DefaultPath()}
a.paths = client.ConfigPaths(a.configPath)
a.app = app.NewWithID("dev.tzgyn.tunnel")
a.window = a.app.NewWindow("Tunnel")
a.window.Resize(fyne.NewSize(920, 680))
a.loadService()
a.buildUI()
a.refresh()
a.readExistingLog()
a.window.SetOnClosed(func() { a.stop() })
a.window.ShowAndRun()
}
func (a *App) loadService() {
cfg, err := client.Load(a.configPath)
if err != nil {
a.showError(err)
cfg = &client.Config{Routes: map[string]client.Route{}}
}
a.cfg = cfg
a.logWriter = newUILogWriter(a.paths.Dir)
logger := log.New(a.logWriter, "", log.LstdFlags)
svc, err := client.NewService(a.configPath, a.version, logger)
if err != nil {
a.showError(err)
return
}
a.svc = svc
a.svc.SetStatusHandler(func(client.Status) {
fyne.Do(a.updateStatus)
})
}
func (a *App) buildUI() {
a.dot = canvas.NewCircle(gray)
a.dot.Resize(fyne.NewSize(12, 12))
a.stateLabel = widget.NewLabel("Stopped")
a.stateLabel.TextStyle = fyne.TextStyle{Bold: true}
a.lastError = widget.NewLabel("")
a.lastError.Wrapping = fyne.TextWrapWord
a.lastError.Hide()
a.sessionLabel = widget.NewLabel("")
a.apiLabel = widget.NewLabel("")
a.tunnelLabel = widget.NewLabel("")
a.certLabel = widget.NewLabel("")
a.configLabel = widget.NewLabel(a.configPath)
a.configLabel.Wrapping = fyne.TextWrapBreak
statusCard := widget.NewCard("Status", "", container.NewVBox(
container.NewHBox(container.NewGridWrap(fyne.NewSize(14, 14), a.dot), a.stateLabel),
formRow("API", a.apiLabel),
formRow("Tunnel", a.tunnelLabel),
formRow("Certificate", a.certLabel),
formRow("Session", a.sessionLabel),
formRow("Config", a.configLabel),
a.lastError,
))
a.startButton = widget.NewButtonWithIcon("Start", theme.MediaPlayIcon(), a.toggleService)
refreshButton := widget.NewButtonWithIcon("Refresh", theme.ViewRefreshIcon(), a.refresh)
settingsButton := widget.NewButtonWithIcon("Settings", theme.SettingsIcon(), a.settingsDialog)
importButton := widget.NewButtonWithIcon("Import official profile", theme.DownloadIcon(), a.importDialog)
openLogButton := widget.NewButtonWithIcon("Open log", theme.DocumentIcon(), func() {
if parsed, err := url.Parse("file://" + a.desktopLogPath()); err == nil {
_ = a.app.OpenURL(parsed)
}
})
bannerLabel := widget.NewLabel("The `tunnel` CLI service is running and claims these routes. Stop it to use the app.")
bannerLabel.Wrapping = fyne.TextWrapWord
stopCLI := widget.NewButtonWithIcon("Stop CLI", theme.MediaStopIcon(), func() {
if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) {
_ = syscall.Kill(pid, syscall.SIGTERM)
}
a.banner.Hide()
})
a.banner = container.NewHBox(bannerLabel, stopCLI)
a.banner.Hide()
routeHeader := container.NewHBox(
widget.NewLabelWithStyle("Routes", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
layout.NewSpacer(),
widget.NewButtonWithIcon("Add route", theme.ContentAddIcon(), a.addRouteDialog),
)
a.routeList = widget.NewList(
func() int { return len(a.routeNames()) },
func() fyne.CanvasObject {
return container.NewHBox(
widget.NewLabel("name"),
widget.NewLabel("target"),
widget.NewHyperlink("url", nil),
layout.NewSpacer(),
widget.NewButtonWithIcon("", theme.ContentCopyIcon(), nil),
widget.NewButtonWithIcon("", theme.DeleteIcon(), nil),
)
},
func(i widget.ListItemID, item fyne.CanvasObject) {
names := a.routeNames()
name := names[i]
route := a.cfg.Routes[name]
linkURL := a.cfg.RouteURL(name)
box := item.(*fyne.Container)
box.Objects[0].(*widget.Label).SetText(name)
box.Objects[1].(*widget.Label).SetText(route.Target)
link := box.Objects[2].(*widget.Hyperlink)
link.SetText(linkURL)
if parsed, err := url.Parse(linkURL); err == nil {
link.SetURL(parsed)
}
box.Objects[4].(*widget.Button).OnTapped = func() { a.app.Clipboard().SetContent(linkURL) }
box.Objects[5].(*widget.Button).OnTapped = func() { a.removeRoute(name) }
},
)
a.logList = widget.NewList(
func() int { return a.logWriter.count() },
func() fyne.CanvasObject { return widget.NewLabel("") },
func(i widget.ListItemID, item fyne.CanvasObject) { item.(*widget.Label).SetText(a.logWriter.line(i)) },
)
a.logWriter.onAppend = func() { fyne.Do(a.logList.Refresh) }
content := container.NewVBox(
container.NewHBox(
widget.NewLabelWithStyle("Tunnel", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
widget.NewLabel("client "+a.version),
layout.NewSpacer(),
a.startButton,
refreshButton,
settingsButton,
importButton,
openLogButton,
),
statusCard,
a.banner,
routeHeader,
a.routeList,
widget.NewLabelWithStyle("Log", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
a.logList,
)
a.window.SetContent(container.NewPadded(container.NewScroll(content)))
}
func formRow(label string, value fyne.CanvasObject) *fyne.Container {
return container.NewGridWithColumns(2, widget.NewLabel(label), value)
}
func (a *App) routeNames() []string {
names := make([]string, 0, len(a.cfg.Routes))
for name := range a.cfg.Routes {
names = append(names, name)
}
sort.Strings(names)
return names
}
func (a *App) refresh() {
cfg, err := client.Load(a.configPath)
if err != nil {
a.showError(err)
return
}
a.cfg = cfg
a.svc.SetConfig(cfg)
a.apiLabel.SetText(valueOr(cfg.API, "(not set)"))
a.tunnelLabel.SetText(valueOr(cfg.Hostname, "(no tunnel yet)"))
a.certLabel.SetText(valueOr(cfg.CertificateExpiry, "(no certificate)"))
if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) {
a.banner.Show()
} else {
a.banner.Hide()
}
a.routeList.Refresh()
a.updateStatus()
}
func (a *App) updateStatus() {
status := a.svc.CurrentStatus()
running := a.isRunning()
switch {
case status.Connected:
a.dot.FillColor = green
a.stateLabel.SetText("Connected")
a.sessionLabel.SetText(status.Session + " since " + status.LastConnectedAt.Format("15:04:05"))
a.startButton.SetText("Stop")
a.startButton.SetIcon(theme.MediaStopIcon())
case running:
a.dot.FillColor = gray
a.stateLabel.SetText("Connecting…")
a.sessionLabel.SetText("")
a.startButton.SetText("Stop")
a.startButton.SetIcon(theme.MediaStopIcon())
default:
a.dot.FillColor = gray
a.stateLabel.SetText("Stopped")
a.sessionLabel.SetText("")
a.startButton.SetText("Start")
a.startButton.SetIcon(theme.MediaPlayIcon())
}
if status.LastError != "" {
a.lastError.SetText(status.LastError)
a.lastError.Show()
} else {
a.lastError.SetText("")
a.lastError.Hide()
}
a.dot.Refresh()
a.routeList.Refresh()
}
func (a *App) isRunning() bool {
a.runMu.Lock()
defer a.runMu.Unlock()
return a.runCancel != nil
}
func (a *App) toggleService() {
if a.isRunning() {
a.stop()
return
}
a.start()
}
func (a *App) start() {
if a.cfg.API == "" {
a.settingsDialog()
return
}
a.runMu.Lock()
if a.runCancel != nil {
a.runMu.Unlock()
return
}
ctx, cancel := context.WithCancel(context.Background())
a.runCancel = cancel
svc := a.svc
a.runMu.Unlock()
a.startButton.Disable()
a.stateLabel.SetText("Provisioning…")
a.dot.Refresh()
go func() {
err := svc.Run(ctx)
fyne.Do(func() {
a.runMu.Lock()
a.runCancel = nil
a.runMu.Unlock()
a.startButton.Enable()
a.updateStatus()
if err != nil && !errors.Is(err, context.Canceled) {
a.showError(err)
}
})
}()
}
func (a *App) stop() {
a.runMu.Lock()
cancel := a.runCancel
a.runCancel = nil
a.runMu.Unlock()
if cancel != nil {
cancel()
}
a.updateStatus()
}
func (a *App) settingsDialog() {
api := widget.NewEntry()
api.SetText(a.cfg.API)
api.SetPlaceHolder("https://tunnel.example.com")
dialog.NewForm("Settings", "Save", "Cancel",
[]*widget.FormItem{widget.NewFormItem("API", api)},
func(ok bool) {
if !ok {
return
}
normalized, err := client.NormalizeAPI(api.Text)
if err != nil {
a.showError(err)
return
}
a.cfg.API = normalized
if err := a.cfg.Save(a.configPath); err != nil {
a.showError(err)
return
}
wasRunning := a.isRunning()
if wasRunning {
a.stop()
}
a.refresh()
if wasRunning {
a.start()
}
}, a.window).Show()
}
func (a *App) importDialog() {
profile := widget.NewEntry()
profile.SetText("coolify")
api := widget.NewEntry()
api.SetText(a.cfg.API)
api.SetPlaceHolder("https://tunnel.example.com")
dialog.NewForm("Import official profile", "Import", "Cancel",
[]*widget.FormItem{
widget.NewFormItem("Profile", profile),
widget.NewFormItem("API", api),
},
func(ok bool) {
if !ok {
return
}
if a.isRunning() {
a.stop()
}
result, err := client.Import(client.ImportOptions{
Profile: strings.TrimSpace(profile.Text),
API: strings.TrimSpace(api.Text),
ConfigPath: a.configPath,
Force: true,
})
if err != nil {
a.showError(err)
return
}
a.showInfo(fmt.Sprintf("Imported %s (%s) with routes: %s", result.TunnelID, result.Hostname, strings.Join(result.Routes, ", ")))
a.refresh()
}, a.window).Show()
}
func (a *App) addRouteDialog() {
target := widget.NewEntry()
target.SetPlaceHolder("3000 or 127.0.0.1:3000")
name := widget.NewEntry()
name.SetPlaceHolder("optional; random if empty")
proxy := widget.NewSelect([]string{"", "v1", "v2"}, nil)
proxy.SetSelected("")
dialog.NewForm("Add route", "Add", "Cancel",
[]*widget.FormItem{
widget.NewFormItem("Target", target),
widget.NewFormItem("Name", name),
widget.NewFormItem("PROXY protocol", proxy),
},
func(ok bool) {
if !ok {
return
}
targetValue := strings.TrimSpace(target.Text)
nameValue := strings.TrimSpace(name.Text)
if nameValue == "" {
nameValue = randomRouteName()
}
normalized, err := client.NormalizeTarget(targetValue)
if err != nil {
a.showError(err)
return
}
if err := a.cfg.AddRoute(nameValue, normalized, proxy.Selected); err != nil {
a.showError(err)
return
}
if err := a.cfg.Save(a.configPath); err != nil {
a.showError(err)
return
}
a.refresh()
if !a.isRunning() && a.cfg.API != "" {
a.start()
}
a.showInfo("Route " + nameValue + " added: " + a.cfg.RouteURL(nameValue))
}, a.window).Show()
}
func (a *App) removeRoute(name string) {
if !a.cfg.RemoveRoute(name) {
return
}
if err := a.cfg.Save(a.configPath); err != nil {
a.showError(err)
return
}
a.refresh()
}
func (a *App) showError(err error) {
dialog.ShowError(err, a.window)
}
func (a *App) showInfo(message string) {
dialog.ShowInformation("Tunnel", message, a.window)
}
func (a *App) desktopLogPath() string {
return filepath.Join(a.paths.Dir, "tunnel-desktop.log")
}
func (a *App) readExistingLog() {
data, err := os.ReadFile(a.desktopLogPath())
if err != nil {
return
}
for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") {
a.logWriter.append(line)
}
}
func randomRouteName() string {
buf := make([]byte, 10)
if _, err := rand.Read(buf); err != nil {
return "route"
}
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf))
}
// readCLIpid returns the pid of a running CLI service, or 0.
func readCLIpid(path string) int {
data, err := os.ReadFile(path)
if err != nil {
return 0
}
pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
if err != nil {
return 0
}
return pid
}
func processAlive(pid int) bool {
err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}
// uiLogWriter mirrors service logs into a bounded in-memory list and a file.
type uiLogWriter struct {
mu sync.Mutex
lines []string
file *os.File
onAppend func()
}
func newUILogWriter(dir string) *uiLogWriter {
w := &uiLogWriter{}
_ = os.MkdirAll(dir, 0o700)
logPath := filepath.Join(dir, "tunnel-desktop.log")
if file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
w.file = file
}
return w
}
func (w *uiLogWriter) Write(p []byte) (int, error) {
line := strings.TrimRight(string(p), "\n")
w.append(line)
return len(p), nil
}
func (w *uiLogWriter) append(line string) {
if line == "" {
return
}
w.mu.Lock()
w.lines = append(w.lines, line)
if len(w.lines) > 400 {
w.lines = w.lines[len(w.lines)-400:]
}
onAppend := w.onAppend
w.mu.Unlock()
if w.file != nil {
_, _ = io.WriteString(w.file, line+"\n")
}
if onAppend != nil {
onAppend()
}
}
func (w *uiLogWriter) count() int {
w.mu.Lock()
defer w.mu.Unlock()
return len(w.lines)
}
func (w *uiLogWriter) line(i int) string {
w.mu.Lock()
defer w.mu.Unlock()
if i < 0 || i >= len(w.lines) {
return ""
}
return w.lines[i]
}
func valueOr(value, fallback string) string {
if value == "" {
return fallback
}
return value
}