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.
This commit is contained in:
2026-10-29 21:32:21 +08:00
parent e4ed31f161
commit ddcea99f37
8 changed files with 799 additions and 12 deletions
+9 -2
View File
@@ -94,6 +94,7 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
})
cancel()
if err != nil {
s.report(Status{Connected: false, LastError: err.Error()})
return nil, fmt.Errorf("dial bridge: %w", err)
}
ws.SetReadLimit(64 * 1024)
@@ -106,8 +107,10 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
}
sort.Strings(routes)
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")
return nil, fmt.Errorf("too many routes (%d, max 128)", len(routes))
return nil, err
}
var attach attachOut
@@ -141,6 +144,7 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
switch answer.Type {
case "attached":
case "attach_error":
s.report(Status{Connected: false, LastError: "attach: " + answer.Code})
ws.Close(websocket.StatusPolicyViolation, answer.Code)
if answer.Code == "bad_token" || answer.Code == "cert_not_ready" {
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)
default:
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)
@@ -177,6 +183,7 @@ func (s *Service) connect(ctx context.Context, cfg *Config) (*session, error) {
go sess.writeLoop()
go sess.heartbeatLoop()
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)
return sess, nil
}
+52 -1
View File
@@ -23,7 +23,52 @@ type Service struct {
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).
@@ -289,7 +334,9 @@ func (s *Service) refreshLoop(ctx context.Context) {
// Run keeps the bridge connected, reloading routes when the config changes.
func (s *Service) Run(ctx context.Context) error {
s.report(Status{Connected: false})
if err := s.Ensure(ctx); err != nil {
s.report(Status{Connected: false, LastError: err.Error()})
return err
}
go s.refreshLoop(ctx)
@@ -302,6 +349,7 @@ func (s *Service) Run(ctx context.Context) error {
cfg, err := s.reload(ctx)
if err != nil {
s.Log.Printf("config: %v", err)
s.report(Status{Connected: false, LastError: err.Error()})
if !wait(ctx, delay) {
return ctx.Err()
}
@@ -324,11 +372,14 @@ func (s *Service) Run(ctx context.Context) error {
select {
case <-ctx.Done():
sess.close("shutdown")
s.report(Status{Connected: false})
return ctx.Err()
case <-reload:
s.Log.Printf("config changed; reconnecting")
sess.close("config changed")
s.report(Status{Connected: false})
case <-sess.done:
s.report(Status{Connected: false})
}
}
}