From 191ba4320c82ff9b229f9c2fb72b34fcaaf2dd18 Mon Sep 17 00:00:00 2001 From: Kevin Franklin Kim Date: Thu, 27 Aug 2026 12:18:16 +0200 Subject: [PATCH 1/2] feat: restart feedback & tunnel control --- internal/menubar/tray.go | 66 ++++++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/internal/menubar/tray.go b/internal/menubar/tray.go index f154f0a..d0c32ba 100644 --- a/internal/menubar/tray.go +++ b/internal/menubar/tray.go @@ -6,12 +6,19 @@ import ( "context" "fmt" "runtime" + "sync/atomic" + "time" "github.com/charmbracelet/log" "github.com/foomo/dockprox/pkg/sshclient" "github.com/wailsapp/wails/v3/pkg/application" ) +// restartBusyFloor is the minimum time the tray shows the disabled icon +// after a Restart click, so the feedback is visible even though the +// underlying Stop+Start cycle typically completes in microseconds. +const restartBusyFloor = 500 * time.Millisecond + // Tray binds a ProxyController to a Wails v3 system tray. type Tray struct { app *application.App @@ -19,6 +26,7 @@ type Tray struct { ctrl *ProxyController logger *log.Logger logPath string + busy atomic.Bool } // NewTray creates the system tray and wires it to the controller. @@ -52,7 +60,7 @@ func NewTray(app *application.App, ctrl *ProxyController, logger *log.Logger, lo func (t *Tray) applyIcon(s State) { var icon []byte - if s == StateRunning { + if s == StateRunning && !t.busy.Load() { icon = iconRunning } else { icon = iconStopped @@ -84,20 +92,41 @@ func (t *Tray) rebuildMenu() { menu.Add("◉ " + snap.ListenAddr).SetEnabled(false) } - // Tunnels: read-only status list, one glyph+name row each. + // Tunnels: one glyph+name row each, click to start/stop that tunnel. if len(snap.Tunnels) > 0 { menu.AddSeparator() for _, ts := range snap.Tunnels { + name := ts.Name + listening := ts.State == TunnelListening + glyph := "○" addr := "-" - if ts.ConnState == sshclient.ConnConnected { - glyph = "◉" + if listening { addr = ts.Addr + + if ts.ConnState == sshclient.ConnConnected { + glyph = "◉" + } } - menu.Add(fmt.Sprintf("%s %s: %s", glyph, ts.Name, addr)).SetEnabled(false) + item := menu.Add(fmt.Sprintf("%s %s: %s", glyph, name, addr)) + item.SetEnabled(snap.State == StateRunning && !t.busy.Load()) + + if listening { + item.OnClick(func(_ *application.Context) { + if err := t.ctrl.StopTunnel(name); err != nil { + t.logger.Warn("stop tunnel", "name", name, "err", err) + } + }) + } else { + item.OnClick(func(_ *application.Context) { + if err := t.ctrl.StartTunnel(name); err != nil { + t.logger.Warn("start tunnel", "name", name, "err", err) + } + }) + } } } @@ -117,10 +146,29 @@ func (t *Tray) rebuildMenu() { } menu.Add("↺ Restart").OnClick(func(_ *application.Context) { - if err := t.ctrl.Restart(); err != nil { - t.logger.Warn("restart", "err", err) - } - }).SetEnabled(snap.State == StateRunning) + t.busy.Store(true) + t.applyIcon(snap.State) + t.rebuildMenu() + + go func() { + start := time.Now() + + if err := t.ctrl.Restart(); err != nil { + t.logger.Warn("restart", "err", err) + } + + if remaining := restartBusyFloor - time.Since(start); remaining > 0 { + time.Sleep(remaining) + } + + t.busy.Store(false) + + application.InvokeAsync(func() { + t.applyIcon(t.ctrl.Snapshot().State) + t.rebuildMenu() + }) + }() + }).SetEnabled(snap.State == StateRunning && !t.busy.Load()) autostartEnabled, err := t.app.Autostart.IsEnabled() if err != nil { From c09f774df3f8f363532cdf1a35003e262345407f Mon Sep 17 00:00:00 2001 From: Kevin Franklin Kim Date: Thu, 27 Aug 2026 12:20:13 +0200 Subject: [PATCH 2/2] docs: update --- docs/guide/menubar.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/guide/menubar.md b/docs/guide/menubar.md index ded84da..fe20400 100644 --- a/docs/guide/menubar.md +++ b/docs/guide/menubar.md @@ -33,7 +33,7 @@ Or run from source: go run -tags=safe ./cmd/dockprox-menubar ``` -The proxy auto-starts when the app launches. The tray icon reflects the running / stopped state. +The proxy auto-starts when the app launches. The tray icon reflects the running / stopped state, and dims briefly while a restart is in progress. ## Flags @@ -72,7 +72,8 @@ cache dir: `~/Library/Caches/org.foomo.dockprox/dockprox.log`. | **Version** (top item) | Shows the running app version. Click to open the GitHub releases page. | | **Start** | Start the in-process proxy with the resolved config. | | **Stop** | Stop the running proxy. | -| **Restart** | Stop, reload the config from disk, and start again. | +| **Restart** | Stop, reload the config from disk, and start again. Disabled — and the tray icon dims — while a restart is in flight. | +| *(tunnel rows)* | One row per SSH upstream with a `socks5Listen`, e.g. `◉ bastion: 127.0.0.1:1080`. Click a listening tunnel (`◉`) to stop it, or a stopped one (`○`) to start it, independently of the main proxy. | | **Start at Login** | Toggle launching the app automatically at login — needed since dockprox must be running for a system-wide proxy setup to reach anything. | | **Reveal logs in Finder** | Open the log file (see [Logging](#logging)) in Finder. | | **Reveal config in Finder** | Open the resolved config file in Finder. | @@ -80,6 +81,8 @@ cache dir: `~/Library/Caches/org.foomo.dockprox/dockprox.log`. Edit the resolved config file with any editor and use **Restart** to apply changes — there is no live-reload. +Tunnel rows are only shown while the main proxy is running, and are disabled during a restart. + ## See also - [Configuration](./configuration.md)