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
+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})
}
}
}