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
}