diff --git a/AGENTS.md b/AGENTS.md index 531fcd4..fddfbcc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,9 @@ what we take vs. skip). Never vendor it into the build. 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. On macOS, `scripts/build-desktop-app.sh ` builds + 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 ` 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. diff --git a/README.md b/README.md index d2631b1..9a83dc7 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,9 @@ 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. If the CLI service is already running, a banner offers to stop it -first. +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 diff --git a/internal/client/client_test.go b/internal/client/client_test.go index c955b28..e30d0ea 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -48,7 +48,7 @@ func TestNormalizeTarget(t *testing.T) { func TestConfigRoundTrip(t *testing.T) { dir := t.TempDir() 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 { t.Fatal(err) } @@ -66,7 +66,7 @@ func TestConfigRoundTrip(t *testing.T) { if err != nil { 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) } } diff --git a/internal/client/config.go b/internal/client/config.go index 0d5bd0a..7837c29 100644 --- a/internal/client/config.go +++ b/internal/client/config.go @@ -31,6 +31,7 @@ type Config struct { Hostname string `json:"hostname,omitempty"` Token string `json:"token,omitempty"` CertificateExpiry string `json:"certificate_expiry,omitempty"` + AutoConnect bool `json:"auto_connect,omitempty"` Routes map[string]Route `json:"routes,omitempty"` } diff --git a/internal/desktop/app.go b/internal/desktop/app.go index 9c60d23..036994c 100644 --- a/internal/desktop/app.go +++ b/internal/desktop/app.go @@ -91,7 +91,7 @@ func Run(version string, autostart bool) { fyne.Do(a.refresh) } }() - if autostart { + if autostart || a.cfg.AutoConnect { a.start() } a.app.Run() @@ -370,8 +370,13 @@ 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.FormItem{ + widget.NewFormItem("API", api), + widget.NewFormItem("Startup", autoConnect), + }, func(ok bool) { if !ok { return @@ -382,6 +387,7 @@ func (a *App) settingsDialog() { return } a.cfg.API = normalized + a.cfg.AutoConnect = autoConnect.Checked if err := a.cfg.Save(a.configPath); err != nil { a.showError(err) return diff --git a/scripts/release-desktop-app.sh b/scripts/release-desktop-app.sh new file mode 100755 index 0000000..e792656 --- /dev/null +++ b/scripts/release-desktop-app.sh @@ -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"