// 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.cfg.AutoConnect { 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") autoConnect := widget.NewCheck("Connect on launch", nil) autoConnect.SetChecked(a.cfg.AutoConnect) dialog.NewForm("Settings", "Save", "Cancel", []*widget.FormItem{ widget.NewFormItem("API", api), widget.NewFormItem("Startup", autoConnect), }, func(ok bool) { if !ok { return } normalized, err := client.NormalizeAPI(api.Text) if err != nil { a.showError(err) return } a.cfg.API = normalized a.cfg.AutoConnect = autoConnect.Checked 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 }