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.
This commit is contained in:
@@ -94,6 +94,7 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
s.report(Status{Connected: false, LastError: err.Error()})
|
||||
return nil, fmt.Errorf("dial bridge: %w", err)
|
||||
}
|
||||
ws.SetReadLimit(64 * 1024)
|
||||
@@ -106,8 +107,10 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
|
||||
}
|
||||
sort.Strings(routes)
|
||||
if len(routes) > 128 {
|
||||
err := fmt.Errorf("too many routes (%d, max 128)", len(routes))
|
||||
s.report(Status{Connected: false, LastError: err.Error()})
|
||||
ws.Close(websocket.StatusPolicyViolation, "too many routes")
|
||||
return nil, fmt.Errorf("too many routes (%d, max 128)", len(routes))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var attach attachOut
|
||||
@@ -141,6 +144,7 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
|
||||
switch answer.Type {
|
||||
case "attached":
|
||||
case "attach_error":
|
||||
s.report(Status{Connected: false, LastError: "attach: " + answer.Code})
|
||||
ws.Close(websocket.StatusPolicyViolation, answer.Code)
|
||||
if answer.Code == "bad_token" || answer.Code == "cert_not_ready" {
|
||||
return nil, fmt.Errorf("%w: %s", errFatalAttach, answer.Code)
|
||||
@@ -148,7 +152,9 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
|
||||
return nil, fmt.Errorf("attach: %s", answer.Code)
|
||||
default:
|
||||
ws.Close(websocket.StatusProtocolError, "expected attached")
|
||||
return nil, fmt.Errorf("attach: unexpected message %q", answer.Type)
|
||||
err := fmt.Errorf("attach: unexpected message %q", answer.Type)
|
||||
s.report(Status{Connected: false, LastError: err.Error()})
|
||||
return nil, err
|
||||
}
|
||||
ws.SetReadLimit(4 + maxPayload)
|
||||
|
||||
@@ -177,6 +183,7 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
|
||||
go sess.writeLoop()
|
||||
go sess.heartbeatLoop()
|
||||
go sess.readLoop()
|
||||
s.report(Status{Connected: true, Session: answer.Session, Routes: routes, LastConnectedAt: time.Now()})
|
||||
s.Log.Printf("attached: session %s, %d route(s), heartbeat %s", answer.Session, len(routes), heartbeat)
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
@@ -23,7 +23,52 @@ type Service struct {
|
||||
|
||||
cert atomic.Pointer[tls.Certificate]
|
||||
|
||||
mu sync.RWMutex // guards Config and API during the renewal poller
|
||||
mu sync.RWMutex // guards Config and API during the renewal poller
|
||||
statusMu sync.RWMutex
|
||||
handler func(Status)
|
||||
status atomic.Value
|
||||
}
|
||||
|
||||
// Status is a snapshot of the bridge connection for UIs.
|
||||
type Status struct {
|
||||
Connected bool
|
||||
Session string
|
||||
Routes []string
|
||||
LastError string
|
||||
LastConnectedAt time.Time
|
||||
}
|
||||
|
||||
// SetStatusHandler registers a callback invoked whenever the status changes.
|
||||
func (s *Service) SetStatusHandler(handler func(Status)) {
|
||||
s.statusMu.Lock()
|
||||
s.handler = handler
|
||||
s.statusMu.Unlock()
|
||||
}
|
||||
|
||||
// SetConfig replaces the loaded config and API client, used by the desktop UI.
|
||||
func (s *Service) SetConfig(cfg *Config) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Config = cfg
|
||||
s.API = NewAPIClient(cfg.API)
|
||||
}
|
||||
|
||||
// CurrentStatus returns the latest bridge status.
|
||||
func (s *Service) CurrentStatus() Status {
|
||||
if value := s.status.Load(); value != nil {
|
||||
return value.(Status)
|
||||
}
|
||||
return Status{}
|
||||
}
|
||||
|
||||
func (s *Service) report(status Status) {
|
||||
s.status.Store(status)
|
||||
s.statusMu.RLock()
|
||||
handler := s.handler
|
||||
s.statusMu.RUnlock()
|
||||
if handler != nil {
|
||||
handler(status)
|
||||
}
|
||||
}
|
||||
|
||||
// NewService loads a config (which may not exist yet).
|
||||
@@ -289,7 +334,9 @@ func (s *Service) refreshLoop(ctx context.Context) {
|
||||
|
||||
// Run keeps the bridge connected, reloading routes when the config changes.
|
||||
func (s *Service) Run(ctx context.Context) error {
|
||||
s.report(Status{Connected: false})
|
||||
if err := s.Ensure(ctx); err != nil {
|
||||
s.report(Status{Connected: false, LastError: err.Error()})
|
||||
return err
|
||||
}
|
||||
go s.refreshLoop(ctx)
|
||||
@@ -302,6 +349,7 @@ func (s *Service) Run(ctx context.Context) error {
|
||||
cfg, err := s.reload(ctx)
|
||||
if err != nil {
|
||||
s.Log.Printf("config: %v", err)
|
||||
s.report(Status{Connected: false, LastError: err.Error()})
|
||||
if !wait(ctx, delay) {
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -324,11 +372,14 @@ func (s *Service) Run(ctx context.Context) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
sess.close("shutdown")
|
||||
s.report(Status{Connected: false})
|
||||
return ctx.Err()
|
||||
case <-reload:
|
||||
s.Log.Printf("config changed; reconnecting")
|
||||
sess.close("config changed")
|
||||
s.report(Status{Connected: false})
|
||||
case <-sess.done:
|
||||
s.report(Status{Connected: false})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user