diff --git a/.gitignore b/.gitignore index 54c4961..19a03f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ /tunneld +/tunnel +/tunnel-desktop +/Tunnel.app cmd/tunneld/tunneld +cmd/tunnel/tunnel +cmd/tunnel-desktop/tunnel-desktop .local/ .DS_Store web/node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 3a494bb..531fcd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,11 @@ what we take vs. skip). Never vendor it into the build. - Desktop app: `go build -o .local/tunnel-desktop ./cmd/tunnel-desktop` (Fyne; needs CGO). It runs the same `internal/client` service in-process and uses `~/.config/tunnel/config.json`. If the CLI service is already running, stop - it first — the app shows a banner and offers to stop it. + it first — the app shows a banner and offers to stop it. `--autostart` + connects on launch. On macOS, `scripts/build-desktop-app.sh ` builds + and ad-hoc signs `Tunnel.app`; the installed copy is + `~/Applications/Tunnel.app`. Cross-OS builds need that OS's CGO toolchain; + `CGO_ENABLED=0` does not work for Fyne. - Dev loop: `go build -o .local/tunneld ./cmd/tunneld && .local/tunneld -domain localhost`, then `OPENTUNNEL_API=http://localhost:8080 opentunnel route add 4096 --name api` with the official CLI. `go build` drops a binary next to the source — it is diff --git a/README.md b/README.md index 736c180..d2631b1 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,16 @@ client service: ```bash go build -o .local/tunnel-desktop ./cmd/tunnel-desktop -.local/tunnel-desktop +.local/tunnel-desktop # click Start +.local/tunnel-desktop --autostart +``` + +On macOS, build and ad-hoc sign a launchable bundle: + +```bash +./scripts/build-desktop-app.sh 0.2.0 +ditto Tunnel.app "$HOME/Applications/Tunnel.app" +open "$HOME/Applications/Tunnel.app" --args --autostart ``` It uses `~/.config/tunnel/config.json` like the CLI, shows tunnel status and @@ -107,6 +116,11 @@ and profile import, and has a start/stop button that runs the service in-process. If the CLI service is already running, a banner offers to stop it first. +Fyne needs CGO: the macOS build is tested; Linux and Windows must be built on +those platforms (or with a native CGO cross toolchain), not with +`CGO_ENABLED=0`. Distribution of the macOS app still needs Developer ID signing +and notarization (the local script only ad-hoc signs). + ## Configuration Every flag also reads an environment variable (`tunneld -h`): diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..ea7bdff Binary files /dev/null and b/assets/icon.png differ diff --git a/cmd/tunnel-desktop/main.go b/cmd/tunnel-desktop/main.go index 538a75c..681da85 100644 --- a/cmd/tunnel-desktop/main.go +++ b/cmd/tunnel-desktop/main.go @@ -1,11 +1,15 @@ package main import ( + "flag" + "gitea.tzgyn.dev/TZGyn/tunnel/internal/desktop" ) var version = "dev" func main() { - desktop.Run(version) + autostart := flag.Bool("autostart", false, "start the tunnel service when the app launches") + flag.Parse() + desktop.Run(version, *autostart) } diff --git a/internal/desktop/app.go b/internal/desktop/app.go index ff9d29d..9c60d23 100644 --- a/internal/desktop/app.go +++ b/internal/desktop/app.go @@ -17,7 +17,7 @@ import ( "strconv" "strings" "sync" - "syscall" + "time" "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" @@ -63,13 +63,13 @@ type App struct { certLabel *widget.Label configLabel *widget.Label startButton *widget.Button - routeList *widget.List - logList *widget.List + routeBox *fyne.Container + logText *widget.TextGrid banner fyne.CanvasObject } // Run creates and runs the desktop app. -func Run(version string) { +func Run(version string, autostart bool) { a := &App{version: version, configPath: client.DefaultPath()} a.paths = client.ConfigPaths(a.configPath) @@ -83,7 +83,18 @@ func Run(version string) { a.readExistingLog() a.window.SetOnClosed(func() { a.stop() }) - a.window.ShowAndRun() + 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() { @@ -141,15 +152,16 @@ func (a *App) buildUI() { } }) - bannerLabel := widget.NewLabel("The `tunnel` CLI service is running and claims these routes. Stop it to use the app.") + 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) { - _ = syscall.Kill(pid, syscall.SIGTERM) + terminateProcess(pid) } a.banner.Hide() }) - a.banner = container.NewHBox(bannerLabel, stopCLI) + a.banner = container.NewBorder(nil, nil, nil, stopCLI, bannerLabel) a.banner.Hide() routeHeader := container.NewHBox( @@ -157,42 +169,11 @@ func (a *App) buildUI() { 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) } + 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( @@ -208,9 +189,9 @@ func (a *App) buildUI() { statusCard, a.banner, routeHeader, - a.routeList, + a.routeBox, widget.NewLabelWithStyle("Log", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), - a.logList, + logScroll, ) a.window.SetContent(container.NewPadded(container.NewScroll(content))) } @@ -235,20 +216,64 @@ func (a *App) refresh() { return } a.cfg = cfg - a.svc.SetConfig(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(valueOr(cfg.CertificateExpiry, "(no certificate)")) + a.certLabel.SetText(formatCert(cfg.CertificateExpiry)) if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) { a.banner.Show() } else { a.banner.Hide() } - a.routeList.Refresh() + 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() @@ -280,7 +305,8 @@ func (a *App) updateStatus() { a.lastError.Hide() } a.dot.Refresh() - a.routeList.Refresh() + a.startButton.Enable() + a.refreshRoutes() } func (a *App) isRunning() bool { @@ -312,7 +338,6 @@ func (a *App) start() { svc := a.svc a.runMu.Unlock() - a.startButton.Disable() a.stateLabel.SetText("Provisioning…") a.dot.Refresh() go func() { @@ -476,9 +501,13 @@ func (a *App) readExistingLog() { 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.append(line) + a.logWriter.remember(line) } + a.logWriter.onAppend = onAppend + a.refreshLog() } func randomRouteName() string { @@ -502,11 +531,6 @@ func readCLIpid(path string) int { 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 @@ -532,6 +556,14 @@ func (w *uiLogWriter) Write(p []byte) (int, error) { } 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 } @@ -542,9 +574,6 @@ func (w *uiLogWriter) append(line string) { } onAppend := w.onAppend w.mu.Unlock() - if w.file != nil { - _, _ = io.WriteString(w.file, line+"\n") - } if onAppend != nil { onAppend() } @@ -565,6 +594,16 @@ func (w *uiLogWriter) line(i int) string { 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 diff --git a/internal/desktop/process_unix.go b/internal/desktop/process_unix.go new file mode 100644 index 0000000..b19c140 --- /dev/null +++ b/internal/desktop/process_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package desktop + +import ( + "errors" + "syscall" +) + +func processAlive(pid int) bool { + err := syscall.Kill(pid, 0) + return err == nil || errors.Is(err, syscall.EPERM) +} + +func terminateProcess(pid int) { + _ = syscall.Kill(pid, syscall.SIGTERM) +} diff --git a/internal/desktop/process_windows.go b/internal/desktop/process_windows.go new file mode 100644 index 0000000..91c2dbf --- /dev/null +++ b/internal/desktop/process_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package desktop + +import "os" + +// The CLI background daemon is not supported on Windows; the desktop app +// runs the service in-process, so there is no CLI pid to show or stop. +func processAlive(int) bool { return false } + +func terminateProcess(pid int) { + if process, err := os.FindProcess(pid); err == nil { + _ = process.Kill() + } +} diff --git a/scripts/build-desktop-app.sh b/scripts/build-desktop-app.sh new file mode 100755 index 0000000..2ca47e7 --- /dev/null +++ b/scripts/build-desktop-app.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Build and ad-hoc sign a local macOS Tunnel.app. +# Distribution releases still need Developer ID signing + notarization. +set -euo pipefail +cd "$(dirname "$0")/.." + +VERSION="${1:-0.2.0}" +BUILD="${BUILD:-1}" +FYNE="$(command -v fyne || true)" +if [ -z "$FYNE" ]; then + FYNE="$(go env GOPATH)/bin/fyne" +fi +if [ ! -x "$FYNE" ]; then + echo "installing fyne CLI..." + go install fyne.io/tools/cmd/fyne@latest + FYNE="$(go env GOPATH)/bin/fyne" +fi + +mkdir -p .local +go build -o .local/Tunnel -ldflags "-X main.version=$VERSION" ./cmd/tunnel-desktop +rm -rf Tunnel.app +"$FYNE" package -os darwin \ + -executable .local/Tunnel \ + -name Tunnel -app-id dev.tzgyn.tunnel \ + -app-version "$VERSION" -app-build "$BUILD" \ + -icon assets/icon.png -release + +codesign --force --deep --sign - Tunnel.app +echo "Built and ad-hoc signed Tunnel.app (version $VERSION)." +echo "Install locally with:" +echo " rm -rf \"\$HOME/Applications/Tunnel.app\" && ditto Tunnel.app \"\$HOME/Applications/Tunnel.app\""