Add the Fyne desktop app (M5)

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.
This commit is contained in:
2026-10-30 22:21:58 +08:00
parent ddcea99f37
commit 87f82e2c47
9 changed files with 192 additions and 63 deletions
+5
View File
@@ -1,5 +1,10 @@
/tunneld /tunneld
/tunnel
/tunnel-desktop
/Tunnel.app
cmd/tunneld/tunneld cmd/tunneld/tunneld
cmd/tunnel/tunnel
cmd/tunnel-desktop/tunnel-desktop
.local/ .local/
.DS_Store .DS_Store
web/node_modules/ web/node_modules/
+5 -1
View File
@@ -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; - 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 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 `~/.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 <version>` 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`, - 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` 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 with the official CLI. `go build` drops a binary next to the source — it is
+15 -1
View File
@@ -98,7 +98,16 @@ client service:
```bash ```bash
go build -o .local/tunnel-desktop ./cmd/tunnel-desktop 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 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 in-process. If the CLI service is already running, a banner offers to stop it
first. 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 ## Configuration
Every flag also reads an environment variable (`tunneld -h`): Every flag also reads an environment variable (`tunneld -h`):
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+5 -1
View File
@@ -1,11 +1,15 @@
package main package main
import ( import (
"flag"
"gitea.tzgyn.dev/TZGyn/tunnel/internal/desktop" "gitea.tzgyn.dev/TZGyn/tunnel/internal/desktop"
) )
var version = "dev" var version = "dev"
func main() { func main() {
desktop.Run(version) autostart := flag.Bool("autostart", false, "start the tunnel service when the app launches")
flag.Parse()
desktop.Run(version, *autostart)
} }
+98 -59
View File
@@ -17,7 +17,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"syscall" "time"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/app" "fyne.io/fyne/v2/app"
@@ -63,13 +63,13 @@ type App struct {
certLabel *widget.Label certLabel *widget.Label
configLabel *widget.Label configLabel *widget.Label
startButton *widget.Button startButton *widget.Button
routeList *widget.List routeBox *fyne.Container
logList *widget.List logText *widget.TextGrid
banner fyne.CanvasObject banner fyne.CanvasObject
} }
// Run creates and runs the desktop app. // 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 := &App{version: version, configPath: client.DefaultPath()}
a.paths = client.ConfigPaths(a.configPath) a.paths = client.ConfigPaths(a.configPath)
@@ -83,7 +83,18 @@ func Run(version string) {
a.readExistingLog() a.readExistingLog()
a.window.SetOnClosed(func() { a.stop() }) 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() { 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.Wrapping = fyne.TextWrapWord
bannerLabel.Importance = widget.WarningImportance
stopCLI := widget.NewButtonWithIcon("Stop CLI", theme.MediaStopIcon(), func() { stopCLI := widget.NewButtonWithIcon("Stop CLI", theme.MediaStopIcon(), func() {
if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) { if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) {
_ = syscall.Kill(pid, syscall.SIGTERM) terminateProcess(pid)
} }
a.banner.Hide() a.banner.Hide()
}) })
a.banner = container.NewHBox(bannerLabel, stopCLI) a.banner = container.NewBorder(nil, nil, nil, stopCLI, bannerLabel)
a.banner.Hide() a.banner.Hide()
routeHeader := container.NewHBox( routeHeader := container.NewHBox(
@@ -157,42 +169,11 @@ func (a *App) buildUI() {
layout.NewSpacer(), layout.NewSpacer(),
widget.NewButtonWithIcon("Add route", theme.ContentAddIcon(), a.addRouteDialog), widget.NewButtonWithIcon("Add route", theme.ContentAddIcon(), a.addRouteDialog),
) )
a.routeList = widget.NewList( a.routeBox = container.NewVBox()
func() int { return len(a.routeNames()) }, a.logText = widget.NewTextGrid()
func() fyne.CanvasObject { logScroll := container.NewScroll(a.logText)
return container.NewHBox( logScroll.SetMinSize(fyne.NewSize(0, 220))
widget.NewLabel("name"), a.logWriter.onAppend = func() { fyne.Do(a.refreshLog) }
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( content := container.NewVBox(
container.NewHBox( container.NewHBox(
@@ -208,9 +189,9 @@ func (a *App) buildUI() {
statusCard, statusCard,
a.banner, a.banner,
routeHeader, routeHeader,
a.routeList, a.routeBox,
widget.NewLabelWithStyle("Log", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Log", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
a.logList, logScroll,
) )
a.window.SetContent(container.NewPadded(container.NewScroll(content))) a.window.SetContent(container.NewPadded(container.NewScroll(content)))
} }
@@ -235,20 +216,64 @@ func (a *App) refresh() {
return return
} }
a.cfg = cfg a.cfg = cfg
if a.svc != nil {
a.svc.SetConfig(cfg) a.svc.SetConfig(cfg)
}
a.apiLabel.SetText(valueOr(cfg.API, "(not set)")) a.apiLabel.SetText(valueOr(cfg.API, "(not set)"))
a.tunnelLabel.SetText(valueOr(cfg.Hostname, "(no tunnel yet)")) 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) { if pid := readCLIpid(a.paths.PID); pid > 0 && processAlive(pid) {
a.banner.Show() a.banner.Show()
} else { } else {
a.banner.Hide() a.banner.Hide()
} }
a.routeList.Refresh() a.refreshRoutes()
a.updateStatus() 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() { func (a *App) updateStatus() {
status := a.svc.CurrentStatus() status := a.svc.CurrentStatus()
running := a.isRunning() running := a.isRunning()
@@ -280,7 +305,8 @@ func (a *App) updateStatus() {
a.lastError.Hide() a.lastError.Hide()
} }
a.dot.Refresh() a.dot.Refresh()
a.routeList.Refresh() a.startButton.Enable()
a.refreshRoutes()
} }
func (a *App) isRunning() bool { func (a *App) isRunning() bool {
@@ -312,7 +338,6 @@ func (a *App) start() {
svc := a.svc svc := a.svc
a.runMu.Unlock() a.runMu.Unlock()
a.startButton.Disable()
a.stateLabel.SetText("Provisioning…") a.stateLabel.SetText("Provisioning…")
a.dot.Refresh() a.dot.Refresh()
go func() { go func() {
@@ -476,9 +501,13 @@ func (a *App) readExistingLog() {
if err != nil { if err != nil {
return return
} }
onAppend := a.logWriter.onAppend
a.logWriter.onAppend = nil
for _, line := range strings.Split(strings.TrimRight(string(data), "\n"), "\n") { 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 { func randomRouteName() string {
@@ -502,11 +531,6 @@ func readCLIpid(path string) int {
return pid 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. // uiLogWriter mirrors service logs into a bounded in-memory list and a file.
type uiLogWriter struct { type uiLogWriter struct {
mu sync.Mutex mu sync.Mutex
@@ -532,6 +556,14 @@ func (w *uiLogWriter) Write(p []byte) (int, error) {
} }
func (w *uiLogWriter) append(line string) { 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 == "" { if line == "" {
return return
} }
@@ -542,9 +574,6 @@ func (w *uiLogWriter) append(line string) {
} }
onAppend := w.onAppend onAppend := w.onAppend
w.mu.Unlock() w.mu.Unlock()
if w.file != nil {
_, _ = io.WriteString(w.file, line+"\n")
}
if onAppend != nil { if onAppend != nil {
onAppend() onAppend()
} }
@@ -565,6 +594,16 @@ func (w *uiLogWriter) line(i int) string {
return w.lines[i] 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 { func valueOr(value, fallback string) string {
if value == "" { if value == "" {
return fallback return fallback
+17
View File
@@ -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)
}
+15
View File
@@ -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()
}
}
+31
View File
@@ -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\""