4 Commits
Author SHA1 Message Date
TZGyn d5689ce70d Add connect-on-launch and notarized desktop release packaging 2026-10-30 22:25:03 +08:00
TZGyn 87f82e2c47 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.
2026-10-30 22:21:58 +08:00
TZGyn ddcea99f37 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.
2026-10-29 21:32:21 +08:00
TZGyn e4ed31f161 Document the Homebrew release and release process 2026-10-26 06:43:32 +08:00
16 changed files with 1006 additions and 14 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/
+30 -6
View File
@@ -18,8 +18,12 @@ what we take vs. skip). Never vendor it into the build.
- `cmd/tunneld` — flags/env wiring, listeners, signal handling - `cmd/tunneld` — flags/env wiring, listeners, signal handling
- `cmd/tunnel` — the native Go client/CLI: JSON config, provisioning, routes, - `cmd/tunnel` — the native Go client/CLI: JSON config, provisioning, routes,
background service background service
- `cmd/tunnel-desktop` — Fyne desktop UI over the same client service (macOS,
Linux, Windows)
- `internal/client` — client implementation: config/API, key/CSR/certificate - `internal/client` — client implementation: config/API, key/CSR/certificate
storage, bridge WebSocket, TLS termination and local proxying storage, bridge WebSocket, TLS termination and local proxying
- `internal/desktop` — Fyne UI: status, routes, start/stop, logs, settings,
import
- `internal/server/limits.go`, `cooldown.go` — per-IP/per-tunnel rate limits - `internal/server/limits.go`, `cooldown.go` — per-IP/per-tunnel rate limits
and the ACME CA cool-down and the ACME CA cool-down
- `internal/server/proto.go` — wire types (pinned JSON key order), IDs/tokens - `internal/server/proto.go` — wire types (pinned JSON key order), IDs/tokens
@@ -68,6 +72,16 @@ what we take vs. skip). Never vendor it into the build.
`up`/`down` manage the background service, `status` shows local + server `up`/`down` manage the background service, `status` shows local + server
state. `import --profile <name> --api <url>` takes over an official CLI state. `import --profile <name> --api <url>` takes over an official CLI
profile (same tunnel/key/cert). profile (same tunnel/key/cert).
- 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. `--autostart`
connects on launch; Settings has **Connect on launch** for the same behavior
when opened normally. Add the app to macOS Login Items for start-at-login.
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
@@ -172,10 +186,20 @@ what we take vs. skip). Never vendor it into the build.
- The bridge WebSocket must use `coder/websocket` with compression disabled - The bridge WebSocket must use `coder/websocket` with compression disabled
and the `opentunnel` subprotocol; read limits are 64 KiB before attach and and the `opentunnel` subprotocol; read limits are 64 KiB before attach and
4+32 KiB after. 4+32 KiB after.
- No third-party deps beyond `github.com/coder/websocket` and - No third-party deps beyond `github.com/coder/websocket`,
`golang.org/x/crypto` (ACME); keep it that way unless a milestone calls for it. `golang.org/x/crypto` (ACME), and `fyne.io/fyne/v2` (desktop UI). Keep it
that way unless a milestone calls for it.
- **CLI release**: `v0.1.0` (tag on `ebae420`) is published on Gitea with
darwin/linux arm64+amd64 tarballs and `checksums.txt`. The Homebrew formula
is `tzgyn-tunnel` in `TZGyn/homebrew-tap` (it installs the `tunnel` binary;
the name avoids the deprecated core `tunnel` formula). Rebuild with
`-X main.version=<version>`, attach all four assets, and update the four
URLs/SHAs in the formula on each release. `tunnel up` currently starts a
detached process, not a launchd agent (the official CLI's agent was stopped
when the live tunnel was switched to this client).
- Milestones: M1 core data path (done); M2 real ACME DNS-01 + renewals (done); - Milestones: M1 core data path (done); M2 real ACME DNS-01 + renewals (done);
M3 rate limits/admin/hardening (done); M4 Go client (done). Remaining optional M3 rate limits/admin/hardening (done); M4 Go client (done); M5 desktop app
work: export/import admin endpoints, token rotation, multi-region mesh, (done). Remaining optional work: launchd/systemd agents for the Go client,
inbound PROXY protocol, per-connection wire flow control (the protocol has release packaging for the desktop app, export/import admin endpoints, token
none; queues are bounded). rotation, multi-region mesh, inbound PROXY protocol, per-connection wire
flow control (the protocol has none; queues are bounded).
+43 -3
View File
@@ -6,9 +6,9 @@ plaintext or private keys. It speaks the exact wire protocol from
`docs/protocol.md`, so the official `opentunnel` CLI and SDK work against it `docs/protocol.md`, so the official `opentunnel` CLI and SDK work against it
unchanged. unchanged.
Status: **M4** — the data path, real ACME DNS-01 certificates with renewal, Status: **M5** — the data path, real ACME DNS-01 certificates with renewal,
rate limits/hardening, and a native Go client are implemented. The official rate limits/hardening, a native Go client, and a cross-platform desktop UI are
`opentunnel` CLI still works unchanged. implemented. The official `opentunnel` CLI still works unchanged.
## Quick start (local development) ## Quick start (local development)
@@ -52,6 +52,13 @@ go build -o .local/tunnel ./cmd/tunnel
.local/tunnel status .local/tunnel status
``` ```
Or install the released binary (installs a `tunnel` command):
```bash
brew tap TZGyn/tap https://gitea.tzgyn.dev/TZGyn/homebrew-tap.git
brew install tzgyn-tunnel
```
The first `route add` creates a tunnel, generates the P-256 key/CSR, waits for The first `route add` creates a tunnel, generates the P-256 key/CSR, waits for
the certificate, starts the background service, and prints the public URL. the certificate, starts the background service, and prints the public URL.
Routes reload without restarting the service. Routes reload without restarting the service.
@@ -84,6 +91,37 @@ tunnel up
the official TOML routes to JSON. Both clients cannot claim the same routes at the official TOML routes to JSON. Both clients cannot claim the same routes at
the same time — stop one before starting the other. the same time — stop one before starting the other.
## Desktop app
A cross-platform desktop UI (Fyne, macOS/Linux/Windows) is built into the same
client service:
```bash
go build -o .local/tunnel-desktop ./cmd/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
certificate expiry, routes with copy/open/remove buttons, live logs, settings
and profile import, and has a start/stop button that runs the service
in-process. The Settings dialog has **Connect on launch**; add the app to
macOS Login Items (or the Linux/Windows equivalent) to reconnect at login. 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 ## Configuration
Every flag also reads an environment variable (`tunneld -h`): Every flag also reads an environment variable (`tunneld -h`):
@@ -244,6 +282,8 @@ setup is needed.
tunnels tunnels
- Native Go client (`cmd/tunnel`): JSON config, routes with live reload, - Native Go client (`cmd/tunnel`): JSON config, routes with live reload,
PROXY protocol, background service, and import of the official CLI profile PROXY protocol, background service, and import of the official CLI profile
- Desktop UI (`cmd/tunnel-desktop`): Fyne app with status, routes, logs,
settings, and start/stop
- Graceful shutdown: `drain` + close to every bridge - Graceful shutdown: `drain` + close to every bridge
## Not yet (later) ## Not yet (later)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+15
View File
@@ -0,0 +1,15 @@
package main
import (
"flag"
"gitea.tzgyn.dev/TZGyn/tunnel/internal/desktop"
)
var version = "dev"
func main() {
autostart := flag.Bool("autostart", false, "start the tunnel service when the app launches")
flag.Parse()
desktop.Run(version, *autostart)
}
+38
View File
@@ -3,6 +3,44 @@ module gitea.tzgyn.dev/TZGyn/tunnel
go 1.27.3 go 1.27.3
require ( require (
fyne.io/fyne/v2 v2.8.2
github.com/coder/websocket v1.8.15 github.com/coder/websocket v1.8.15
golang.org/x/crypto v0.58.0 golang.org/x/crypto v0.58.0
) )
require (
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/FyshOS/fancyfs v0.0.1 // indirect
github.com/anthonynsimon/bild v0.14.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect
github.com/fyne-io/glfw-js v0.4.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9 // indirect
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.2 // indirect
github.com/go-text/render v0.2.1 // indirect
github.com/go-text/typesetting v0.3.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rymdport/portal v0.4.2 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.60.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.43.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+79
View File
@@ -1,4 +1,83 @@
fyne.io/fyne/v2 v2.8.2 h1:gYNJj+ZEPd2wF3M8SlF5iL6febpZkZ1csheRFkuEfHU=
fyne.io/fyne/v2 v2.8.2/go.mod h1:eXHJ/D9Y58UcDw7SkguadCRI64M4flXyjoMn827Q5SI=
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 h1:149/+Wa5EsLLXfyj2pdTmvnQf2VIlgCIwSjcCTHYhIo=
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg=
github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A=
github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo=
github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9 h1:owsdpq6a2KhaAA/hoHGQmFM7kOgKo3srXZVGnbEUzr0=
github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.2 h1:ez76TQInhkDIMobWbOFcPJw6FeB7coXRAVZpKwF8NBg=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.2/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8=
github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg=
github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A=
github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68=
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY=
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU=
golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+9 -2
View File
@@ -94,6 +94,7 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
}) })
cancel() cancel()
if err != nil { if err != nil {
s.report(Status{Connected: false, LastError: err.Error()})
return nil, fmt.Errorf("dial bridge: %w", err) return nil, fmt.Errorf("dial bridge: %w", err)
} }
ws.SetReadLimit(64 * 1024) ws.SetReadLimit(64 * 1024)
@@ -106,8 +107,10 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
} }
sort.Strings(routes) sort.Strings(routes)
if len(routes) > 128 { 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") 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 var attach attachOut
@@ -141,6 +144,7 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
switch answer.Type { switch answer.Type {
case "attached": case "attached":
case "attach_error": case "attach_error":
s.report(Status{Connected: false, LastError: "attach: " + answer.Code})
ws.Close(websocket.StatusPolicyViolation, answer.Code) ws.Close(websocket.StatusPolicyViolation, answer.Code)
if answer.Code == "bad_token" || answer.Code == "cert_not_ready" { if answer.Code == "bad_token" || answer.Code == "cert_not_ready" {
return nil, fmt.Errorf("%w: %s", errFatalAttach, answer.Code) 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) return nil, fmt.Errorf("attach: %s", answer.Code)
default: default:
ws.Close(websocket.StatusProtocolError, "expected attached") 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) ws.SetReadLimit(4 + maxPayload)
@@ -177,6 +183,7 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
go sess.writeLoop() go sess.writeLoop()
go sess.heartbeatLoop() go sess.heartbeatLoop()
go sess.readLoop() 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) s.Log.Printf("attached: session %s, %d route(s), heartbeat %s", answer.Session, len(routes), heartbeat)
return sess, nil return sess, nil
} }
+2 -2
View File
@@ -48,7 +48,7 @@ func TestNormalizeTarget(t *testing.T) {
func TestConfigRoundTrip(t *testing.T) { func TestConfigRoundTrip(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "config.json") path := filepath.Join(dir, "config.json")
cfg := &Config{API: "https://tunnel.example", Routes: map[string]Route{}} cfg := &Config{API: "https://tunnel.example", AutoConnect: true, Routes: map[string]Route{}}
if err := cfg.AddRoute("api", "4096", ""); err != nil { if err := cfg.AddRoute("api", "4096", ""); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -66,7 +66,7 @@ func TestConfigRoundTrip(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if loaded.API != cfg.API || loaded.Routes["api"].Target != "127.0.0.1:4096" { if loaded.API != cfg.API || !loaded.AutoConnect || loaded.Routes["api"].Target != "127.0.0.1:4096" {
t.Errorf("round trip: %+v", loaded) t.Errorf("round trip: %+v", loaded)
} }
} }
+1
View File
@@ -31,6 +31,7 @@ type Config struct {
Hostname string `json:"hostname,omitempty"` Hostname string `json:"hostname,omitempty"`
Token string `json:"token,omitempty"` Token string `json:"token,omitempty"`
CertificateExpiry string `json:"certificate_expiry,omitempty"` CertificateExpiry string `json:"certificate_expiry,omitempty"`
AutoConnect bool `json:"auto_connect,omitempty"`
Routes map[string]Route `json:"routes,omitempty"` Routes map[string]Route `json:"routes,omitempty"`
} }
+51
View File
@@ -24,6 +24,51 @@ type Service struct {
cert atomic.Pointer[tls.Certificate] 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). // 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. // Run keeps the bridge connected, reloading routes when the config changes.
func (s *Service) Run(ctx context.Context) error { func (s *Service) Run(ctx context.Context) error {
s.report(Status{Connected: false})
if err := s.Ensure(ctx); err != nil { if err := s.Ensure(ctx); err != nil {
s.report(Status{Connected: false, LastError: err.Error()})
return err return err
} }
go s.refreshLoop(ctx) go s.refreshLoop(ctx)
@@ -302,6 +349,7 @@ func (s *Service) Run(ctx context.Context) error {
cfg, err := s.reload(ctx) cfg, err := s.reload(ctx)
if err != nil { if err != nil {
s.Log.Printf("config: %v", err) s.Log.Printf("config: %v", err)
s.report(Status{Connected: false, LastError: err.Error()})
if !wait(ctx, delay) { if !wait(ctx, delay) {
return ctx.Err() return ctx.Err()
} }
@@ -324,11 +372,14 @@ func (s *Service) Run(ctx context.Context) error {
select { select {
case <-ctx.Done(): case <-ctx.Done():
sess.close("shutdown") sess.close("shutdown")
s.report(Status{Connected: false})
return ctx.Err() return ctx.Err()
case <-reload: case <-reload:
s.Log.Printf("config changed; reconnecting") s.Log.Printf("config changed; reconnecting")
sess.close("config changed") sess.close("config changed")
s.report(Status{Connected: false})
case <-sess.done: case <-sess.done:
s.report(Status{Connected: false})
} }
} }
} }
+618
View File
@@ -0,0 +1,618 @@
// 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
}
+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\""
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# Build, Developer ID sign, notarize, staple and zip the macOS app.
# Distribution must not skip this step; an ad-hoc build is not a release.
set -euo pipefail
cd "$(dirname "$0")/.."
VERSION="${1:-0.2.0}"
BUILD="${BUILD:-1}"
APP="Tunnel.app"
OUT=".local/release"
ZIP="$OUT/Tunnel-$VERSION.zip"
KEY="$HOME/.config/kapture/AuthKey_K5KXMMSB28.p8"
KEY_ID="K5KXMMSB28"
ISSUER="3e25e5dc-8b75-453f-a71c-c7f3feaac628"
FYNE="$(command -v fyne || true)"
[ -n "$FYNE" ] || FYNE="$(go env GOPATH)/bin/fyne"
[ -x "$FYNE" ] || { echo "install fyne: go install fyne.io/tools/cmd/fyne@latest"; exit 1; }
[ -f "$KEY" ] || { echo "missing notary key: $KEY"; exit 1; }
IDENT="$(security find-identity -v -p codesigning | awk '/Developer ID Application:/{print $2; exit}')"
[ -n "$IDENT" ] || { echo "no Developer ID Application identity"; exit 1; }
mkdir -p "$OUT"
go build -o .local/Tunnel -ldflags "-X main.version=$VERSION" ./cmd/tunnel-desktop
rm -rf "$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 --options runtime --timestamp --sign "$IDENT" "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
ditto -c -k --keepParent "$APP" "$ZIP"
xcrun notarytool submit "$ZIP" --key "$KEY" --key-id "$KEY_ID" --issuer "$ISSUER" \
--wait --output-format json
xcrun stapler staple "$APP"
xcrun stapler validate "$APP"
spctl -a -vv "$APP" 2>&1 | tee "$OUT/spctl.txt"
grep -q "source=Notarized Developer ID" "$OUT/spctl.txt" || {
echo "notarization did not produce a Notarized Developer ID app; not releasing"
exit 1
}
rm -f "$ZIP"
ditto -c -k --keepParent "$APP" "$ZIP"
shasum -a 256 "$ZIP" | tee "$OUT/Tunnel-$VERSION.zip.sha256"
echo "Release app: $ZIP"