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:
@@ -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})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user