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:
@@ -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/
|
||||
|
||||
@@ -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 <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`,
|
||||
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
|
||||
|
||||
@@ -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`):
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -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)
|
||||
}
|
||||
|
||||
+98
-59
@@ -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
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Executable
+31
@@ -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\""
|
||||
Reference in New Issue
Block a user