A cross-platform desktop UI over the same client service: status, routes with add/remove/copy/open, live logs, settings, official-profile import, and start/stop. It uses the existing JSON config and offers to stop a running CLI daemon so the two do not fight over routes. The app can autostart the service, refreshes status/config every few seconds, and builds as a macOS .app via scripts/build-desktop-app.sh.
613 lines
14 KiB
Go
613 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"
|
|
"time"
|
|
|
|
"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
|
|
routeBox *fyne.Container
|
|
logText *widget.TextGrid
|
|
banner fyne.CanvasObject
|
|
}
|
|
|
|
// Run creates and runs the desktop app.
|
|
func Run(version string, autostart bool) {
|
|
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.Show()
|
|
go func() {
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
fyne.Do(a.refresh)
|
|
}
|
|
}()
|
|
if autostart {
|
|
a.start()
|
|
}
|
|
a.app.Run()
|
|
}
|
|
|
|
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
|
|
bannerLabel.Importance = widget.WarningImportance
|
|
stopCLI := widget.NewButtonWithIcon("Stop CLI", theme.MediaStopIcon(), func() {
|
|
if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) {
|
|
terminateProcess(pid)
|
|
}
|
|
a.banner.Hide()
|
|
})
|
|
a.banner = container.NewBorder(nil, nil, nil, stopCLI, bannerLabel)
|
|
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.routeBox = container.NewVBox()
|
|
a.logText = widget.NewTextGrid()
|
|
logScroll := container.NewScroll(a.logText)
|
|
logScroll.SetMinSize(fyne.NewSize(0, 220))
|
|
a.logWriter.onAppend = func() { fyne.Do(a.refreshLog) }
|
|
|
|
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.routeBox,
|
|
widget.NewLabelWithStyle("Log", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
|
logScroll,
|
|
)
|
|
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
|
|
if a.svc != nil {
|
|
a.svc.SetConfig(cfg)
|
|
}
|
|
|
|
a.apiLabel.SetText(valueOr(cfg.API, "(not set)"))
|
|
a.tunnelLabel.SetText(valueOr(cfg.Hostname, "(no tunnel yet)"))
|
|
a.certLabel.SetText(formatCert(cfg.CertificateExpiry))
|
|
if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) {
|
|
a.banner.Show()
|
|
} else {
|
|
a.banner.Hide()
|
|
}
|
|
a.refreshRoutes()
|
|
a.updateStatus()
|
|
}
|
|
|
|
func (a *App) refreshRoutes() {
|
|
a.routeBox.Objects = nil
|
|
for _, name := range a.routeNames() {
|
|
route := a.cfg.Routes[name]
|
|
linkURL := a.cfg.RouteURL(name)
|
|
link := widget.NewHyperlink(linkURL, nil)
|
|
if parsed, err := url.Parse(linkURL); err == nil {
|
|
link.SetURL(parsed)
|
|
}
|
|
copyButton := widget.NewButtonWithIcon("", theme.ContentCopyIcon(), func() {
|
|
a.app.Clipboard().SetContent(linkURL)
|
|
})
|
|
deleteButton := widget.NewButtonWithIcon("", theme.DeleteIcon(), func() {
|
|
a.removeRoute(name)
|
|
})
|
|
row := container.NewHBox(
|
|
container.NewGridWrap(fyne.NewSize(130, 32), widget.NewLabel(name)),
|
|
container.NewGridWrap(fyne.NewSize(170, 32), widget.NewLabel(route.Target)),
|
|
link,
|
|
layout.NewSpacer(),
|
|
copyButton,
|
|
deleteButton,
|
|
)
|
|
a.routeBox.Add(row)
|
|
}
|
|
a.routeBox.Refresh()
|
|
}
|
|
|
|
func (a *App) refreshLog() {
|
|
a.logText.SetText(a.logWriter.text(300))
|
|
}
|
|
|
|
func formatCert(value string) string {
|
|
if value == "" {
|
|
return "(no certificate)"
|
|
}
|
|
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
|
return parsed.Local().Format("2006-01-02 15:04 MST")
|
|
}
|
|
return value
|
|
}
|
|
|
|
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.startButton.Enable()
|
|
a.refreshRoutes()
|
|
}
|
|
|
|
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.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
|
|
}
|
|
onAppend := a.logWriter.onAppend
|
|
a.logWriter.onAppend = nil
|
|
for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") {
|
|
a.logWriter.remember(line)
|
|
}
|
|
a.logWriter.onAppend = onAppend
|
|
a.refreshLog()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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) {
|
|
w.remember(line)
|
|
if w.file != nil {
|
|
_, _ = io.WriteString(w.file, line+"\n")
|
|
}
|
|
}
|
|
|
|
// remember adds a line to the in-memory list without writing it to the file.
|
|
func (w *uiLogWriter) remember(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 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 (w *uiLogWriter) text(limit int) string {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
lines := w.lines
|
|
if len(lines) > limit {
|
|
lines = lines[len(lines)-limit:]
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func valueOr(value, fallback string) string {
|
|
if value == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|