mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-02 13:01:29 +02:00
[client] UI refactor (#6069)
Refactor UI --------- Co-authored-by: Eduard Gert <kontakt@eduardgert.de> Co-authored-by: braginini <bangvalo@gmail.com> Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: riccardom <riccardomanfrin@gmail.com>
This commit is contained in:
52
client/ui/services/autostart.go
Normal file
52
client/ui/services/autostart.go
Normal file
@@ -0,0 +1,52 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// Autostart facade over Wails' AutostartManager. The OS login-item registration
|
||||
// is the single source of truth; nothing is mirrored to preferences.
|
||||
type Autostart struct {
|
||||
mgr *application.AutostartManager
|
||||
}
|
||||
|
||||
func NewAutostart(mgr *application.AutostartManager) *Autostart {
|
||||
return &Autostart{mgr: mgr}
|
||||
}
|
||||
|
||||
func (a *Autostart) Supported(_ context.Context) bool {
|
||||
_, err := a.mgr.Status()
|
||||
return !errors.Is(err, application.ErrAutostartNotSupported)
|
||||
}
|
||||
|
||||
// IsEnabled returns false without error on unsupported platforms.
|
||||
func (a *Autostart) IsEnabled(_ context.Context) (bool, error) {
|
||||
enabled, err := a.mgr.IsEnabled()
|
||||
if err != nil {
|
||||
if errors.Is(err, application.ErrAutostartNotSupported) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("read autostart state: %w", err)
|
||||
}
|
||||
return enabled, nil
|
||||
}
|
||||
|
||||
// SetEnabled takes effect on the next login, not immediately.
|
||||
func (a *Autostart) SetEnabled(_ context.Context, enabled bool) error {
|
||||
if enabled {
|
||||
if err := a.mgr.Enable(); err != nil {
|
||||
return fmt.Errorf("enable autostart: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := a.mgr.Disable(); err != nil {
|
||||
return fmt.Errorf("disable autostart: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
40
client/ui/services/compat.go
Normal file
40
client/ui/services/compat.go
Normal file
@@ -0,0 +1,40 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// Compat answers whether the running daemon is new enough to drive this UI.
|
||||
type Compat struct {
|
||||
conn DaemonConn
|
||||
}
|
||||
|
||||
func NewCompat(conn DaemonConn) *Compat {
|
||||
return &Compat{conn: conn}
|
||||
}
|
||||
|
||||
// DaemonReady probes the WailsUIReady RPC once. A true result means the daemon
|
||||
// implements it and is compatible. An Unimplemented response means the daemon
|
||||
// predates this UI and is too old; the caller should surface an upgrade prompt.
|
||||
// Any other error (daemon not running, transport failure) is returned so the
|
||||
// frontend can tell "outdated" apart from "not reachable".
|
||||
func (c *Compat) DaemonReady(ctx context.Context) (bool, error) {
|
||||
client, err := c.conn.Client()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, err := client.WailsUIReady(ctx, &proto.WailsUIReadyRequest{}); err != nil {
|
||||
if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
13
client/ui/services/conn.go
Normal file
13
client/ui/services/conn.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import "github.com/netbirdio/netbird/client/proto"
|
||||
|
||||
// DaemonConn returns a lazy gRPC client to the NetBird daemon.
|
||||
// All services receive a DaemonConn so they share a single connection.
|
||||
type DaemonConn interface {
|
||||
Client() (proto.DaemonServiceClient, error)
|
||||
}
|
||||
|
||||
func ptrStr(s string) *string { return &s }
|
||||
223
client/ui/services/connection.go
Normal file
223
client/ui/services/connection.go
Normal file
@@ -0,0 +1,223 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// LoginParams are the inputs to Login.
|
||||
type LoginParams struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
Username string `json:"username"`
|
||||
ManagementURL string `json:"managementUrl"`
|
||||
SetupKey string `json:"setupKey"`
|
||||
PreSharedKey string `json:"preSharedKey"`
|
||||
Hostname string `json:"hostname"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
|
||||
// LoginResult is the daemon's reply to Login.
|
||||
type LoginResult struct {
|
||||
NeedsSSOLogin bool `json:"needsSsoLogin"`
|
||||
UserCode string `json:"userCode"`
|
||||
VerificationURI string `json:"verificationUri"`
|
||||
VerificationURIComplete string `json:"verificationUriComplete"`
|
||||
}
|
||||
|
||||
// WaitSSOParams are the inputs to WaitSSOLogin.
|
||||
type WaitSSOParams struct {
|
||||
UserCode string `json:"userCode"`
|
||||
Hostname string `json:"hostname"`
|
||||
}
|
||||
|
||||
// UpParams selects the profile to bring up.
|
||||
type UpParams struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// LogoutParams selects the profile to log out.
|
||||
type LogoutParams struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// Connection groups the daemon RPCs that drive login / connect / disconnect.
|
||||
type Connection struct {
|
||||
conn DaemonConn
|
||||
classifier errorClassifier
|
||||
}
|
||||
|
||||
// NewConnection wires up a Connection. translator or prefs may be nil, in which
|
||||
// case classifyDaemonError falls back to the bare error key.
|
||||
func NewConnection(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Connection {
|
||||
return &Connection{conn: conn, classifier: errorClassifier{translator: translator, prefs: prefs}}
|
||||
}
|
||||
|
||||
func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
|
||||
// No pre-Login Down: Login dislodges a pending WaitSSOLogin itself, and a
|
||||
// defensive Down would only flash an Idle blink in the tray during handoff.
|
||||
|
||||
// Fall back to the daemon's active profile and the current OS user.
|
||||
profileName := p.ProfileName
|
||||
username := p.Username
|
||||
if profileName == "" {
|
||||
if active, aerr := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{}); aerr == nil {
|
||||
// Address the active profile by ID (the daemon resolves it as a
|
||||
// handle); names can collide, the ID cannot.
|
||||
profileName = active.GetId()
|
||||
if username == "" {
|
||||
username = active.GetUsername()
|
||||
}
|
||||
}
|
||||
}
|
||||
if username == "" {
|
||||
if u, uerr := user.Current(); uerr == nil {
|
||||
username = u.Username
|
||||
}
|
||||
}
|
||||
|
||||
req := &proto.LoginRequest{
|
||||
ManagementUrl: p.ManagementURL,
|
||||
SetupKey: p.SetupKey,
|
||||
Hostname: p.Hostname,
|
||||
IsUnixDesktopClient: runtime.GOOS == "linux",
|
||||
}
|
||||
if profileName != "" {
|
||||
req.ProfileName = ptrStr(profileName)
|
||||
}
|
||||
if username != "" {
|
||||
req.Username = ptrStr(username)
|
||||
}
|
||||
if p.PreSharedKey != "" {
|
||||
req.OptionalPreSharedKey = ptrStr(p.PreSharedKey)
|
||||
}
|
||||
if p.Hint != "" {
|
||||
req.Hint = ptrStr(p.Hint)
|
||||
}
|
||||
|
||||
resp, err := cli.Login(ctx, req)
|
||||
if err != nil {
|
||||
return LoginResult{}, s.classifyDaemonError(err)
|
||||
}
|
||||
return LoginResult{
|
||||
NeedsSSOLogin: resp.GetNeedsSSOLogin(),
|
||||
UserCode: resp.GetUserCode(),
|
||||
VerificationURI: resp.GetVerificationURI(),
|
||||
VerificationURIComplete: resp.GetVerificationURIComplete(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := cli.WaitSSOLogin(ctx, &proto.WaitSSOLoginRequest{
|
||||
UserCode: p.UserCode,
|
||||
Hostname: p.Hostname,
|
||||
})
|
||||
if err != nil {
|
||||
return "", s.classifyDaemonError(err)
|
||||
}
|
||||
return resp.GetEmail(), nil
|
||||
}
|
||||
|
||||
func (s *Connection) Up(ctx context.Context, p UpParams) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Always async: status updates flow via SubscribeStatus.
|
||||
req := &proto.UpRequest{Async: true}
|
||||
if p.ProfileName != "" {
|
||||
req.ProfileName = ptrStr(p.ProfileName)
|
||||
}
|
||||
if p.Username != "" {
|
||||
req.Username = ptrStr(p.Username)
|
||||
}
|
||||
if _, err = cli.Up(ctx, req); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Connection) Down(ctx context.Context) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = cli.Down(ctx, &proto.DownRequest{}); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenURL opens url in an external browser; the embedded webview blocks
|
||||
// window.open, so the SSO verification page can't pop inline. Honors $BROWSER
|
||||
// before the platform default.
|
||||
func (s *Connection) OpenURL(url string) error {
|
||||
if browser := os.Getenv("BROWSER"); browser != "" {
|
||||
return exec.Command(browser, url).Start()
|
||||
}
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
|
||||
case "darwin":
|
||||
return exec.Command("open", url).Start()
|
||||
case "linux":
|
||||
return exec.Command("xdg-open", url).Start()
|
||||
default:
|
||||
return fmt.Errorf("unsupported platform")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := &proto.LogoutRequest{}
|
||||
if p.ProfileName != "" {
|
||||
req.ProfileName = ptrStr(p.ProfileName)
|
||||
}
|
||||
if p.Username != "" {
|
||||
req.Username = ptrStr(p.Username)
|
||||
}
|
||||
if _, err = cli.Logout(ctx, req); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
|
||||
// The daemon runs as root and can't reach the user-owned per-profile state
|
||||
// file holding the account email (see Profiles.List), so clear the stale
|
||||
// email here; the next SSO login recreates it.
|
||||
if p.ProfileName != "" {
|
||||
if err := profilemanager.NewProfileManager().RemoveProfileState(p.ProfileName); err != nil {
|
||||
// Non-fatal: the logout itself succeeded.
|
||||
log.Warnf("failed to remove profile state for %s: %v", p.ProfileName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifyDaemonError maps a gRPC error to a localised ClientError.
|
||||
func (s *Connection) classifyDaemonError(err error) *ClientError {
|
||||
return s.classifier.classify(err)
|
||||
}
|
||||
42
client/ui/services/cursor_darwin.go
Normal file
42
client/ui/services/cursor_darwin.go
Normal file
@@ -0,0 +1,42 @@
|
||||
//go:build darwin
|
||||
|
||||
package services
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework Foundation -framework Cocoa -framework AppKit
|
||||
#import <Cocoa/Cocoa.h>
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
typedef struct CursorPoint {
|
||||
int x;
|
||||
int y;
|
||||
int ok;
|
||||
} CursorPoint;
|
||||
|
||||
// NSEvent.mouseLocation is Y-up from primary's bottom-left; flip against
|
||||
// the primary's frame height so the point matches Wails' Y-down Screen.Bounds.
|
||||
CursorPoint nbGetCursorPos(void) {
|
||||
CursorPoint p = {0, 0, 0};
|
||||
NSArray<NSScreen *> *screens = [NSScreen screens];
|
||||
if (screens == nil || screens.count == 0) return p;
|
||||
NSScreen *primary = [screens firstObject];
|
||||
if (primary == nil) return p;
|
||||
NSPoint loc = [NSEvent mouseLocation];
|
||||
p.x = (int)loc.x;
|
||||
p.y = (int)(primary.frame.size.height - loc.y);
|
||||
p.ok = 1;
|
||||
return p;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
func getCursorPosition(_ *application.App) (application.Point, bool) {
|
||||
res := C.nbGetCursorPos()
|
||||
if res.ok == 0 {
|
||||
return application.Point{}, false
|
||||
}
|
||||
return application.Point{X: int(res.x), Y: int(res.y)}, true
|
||||
}
|
||||
53
client/ui/services/cursor_linux.go
Normal file
53
client/ui/services/cursor_linux.go
Normal file
@@ -0,0 +1,53 @@
|
||||
//go:build linux
|
||||
|
||||
package services
|
||||
|
||||
/*
|
||||
#cgo pkg-config: x11
|
||||
#cgo LDFLAGS: -lX11
|
||||
#include <X11/Xlib.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
typedef struct CursorPoint {
|
||||
int x;
|
||||
int y;
|
||||
int ok;
|
||||
} CursorPoint;
|
||||
|
||||
// XQueryPointer works on X11 and, via XWayland, on Wayland sessions.
|
||||
// ok=0 when no X server is reachable.
|
||||
CursorPoint nbGetCursorPos(void) {
|
||||
CursorPoint p = {0, 0, 0};
|
||||
Display *dpy = XOpenDisplay(NULL);
|
||||
if (!dpy) return p;
|
||||
Window root = DefaultRootWindow(dpy);
|
||||
if (root == 0) { XCloseDisplay(dpy); return p; }
|
||||
Window root_return = 0, child_return = 0;
|
||||
int root_x = 0, root_y = 0, win_x = 0, win_y = 0;
|
||||
unsigned int mask_return = 0;
|
||||
if (XQueryPointer(dpy, root, &root_return, &child_return,
|
||||
&root_x, &root_y, &win_x, &win_y, &mask_return)) {
|
||||
p.x = root_x;
|
||||
p.y = root_y;
|
||||
p.ok = 1;
|
||||
}
|
||||
XCloseDisplay(dpy);
|
||||
return p;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
func getCursorPosition(app *application.App) (application.Point, bool) {
|
||||
res := C.nbGetCursorPos()
|
||||
if res.ok == 0 {
|
||||
return application.Point{}, false
|
||||
}
|
||||
p := application.Point{X: int(res.x), Y: int(res.y)}
|
||||
// X11 root coords are physical pixels; Screen.Bounds is in DIPs.
|
||||
if app == nil || app.Screen == nil {
|
||||
return p, true
|
||||
}
|
||||
return app.Screen.PhysicalToDipPoint(p), true
|
||||
}
|
||||
9
client/ui/services/cursor_other.go
Normal file
9
client/ui/services/cursor_other.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !darwin && !windows && !linux && !freebsd && !android && !ios && !js
|
||||
|
||||
package services
|
||||
|
||||
import "github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
func getCursorPosition(_ *application.App) (application.Point, bool) {
|
||||
return application.Point{}, false
|
||||
}
|
||||
17
client/ui/services/cursor_windows.go
Normal file
17
client/ui/services/cursor_windows.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/w32"
|
||||
)
|
||||
|
||||
func getCursorPosition(app *application.App) (application.Point, bool) {
|
||||
x, y, ok := w32.GetCursorPos()
|
||||
if !ok || app == nil || app.Screen == nil {
|
||||
return application.Point{}, false
|
||||
}
|
||||
// GetCursorPos is in physical pixels; Screen.Bounds is in DIPs.
|
||||
return app.Screen.PhysicalToDipPoint(application.Point{X: x, Y: y}), true
|
||||
}
|
||||
590
client/ui/services/daemon_feed.go
Normal file
590
client/ui/services/daemon_feed.go
Normal file
@@ -0,0 +1,590 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
"github.com/netbirdio/netbird/client/ui/updater"
|
||||
)
|
||||
|
||||
const (
|
||||
EventStatusSnapshot = "netbird:status"
|
||||
// EventDaemonNotification carries each SubscribeEvents message. Auto-update
|
||||
// SystemEvents are also forwarded to updater.Holder.OnSystemEvent so the typed
|
||||
// update state needs no second daemon subscription.
|
||||
EventDaemonNotification = "netbird:event"
|
||||
// EventProfileChanged fires after a daemon-side switch (payload: the new
|
||||
// ProfileRef). The daemon emits no profile event, so this is the only signal
|
||||
// that lets a flip driven from one surface paint in the others.
|
||||
EventProfileChanged = "netbird:profile:changed"
|
||||
// EventSessionWarning is a typed sibling of EventDaemonNotification so
|
||||
// subscribers needn't filter the notification firehose. Consumers branch on
|
||||
// SessionWarning.Final to tell the T-10 event from the T-2 fallback.
|
||||
EventSessionWarning = "netbird:session:warning"
|
||||
|
||||
// StatusDaemonUnavailable is the synthetic Status emitted when the daemon's
|
||||
// gRPC socket is unreachable. No internal.Status* collides with this label.
|
||||
StatusDaemonUnavailable = "DaemonUnavailable"
|
||||
|
||||
// Daemon connection status strings — mirror internal.Status* in
|
||||
// client/internal/state.go.
|
||||
StatusConnected = "Connected"
|
||||
StatusConnecting = "Connecting"
|
||||
StatusIdle = "Idle"
|
||||
StatusNeedsLogin = "NeedsLogin"
|
||||
StatusLoginFailed = "LoginFailed"
|
||||
StatusSessionExpired = "SessionExpired"
|
||||
|
||||
// SeverityCritical is the lower-cased proto SystemEvent_CRITICAL severity, as
|
||||
// emitted by systemEventFromProto. Critical events bypass the notifications gate.
|
||||
SeverityCritical = "critical"
|
||||
)
|
||||
|
||||
// Emitter sends a named payload to the frontend. Satisfied by Wails app.Event.
|
||||
type Emitter interface {
|
||||
Emit(name string, data ...any) bool
|
||||
}
|
||||
|
||||
// SystemEvent is the frontend-facing shape of a daemon SystemEvent.
|
||||
type SystemEvent struct {
|
||||
ID string `json:"id"`
|
||||
Severity string `json:"severity"`
|
||||
Category string `json:"category"`
|
||||
Message string `json:"message"`
|
||||
UserMessage string `json:"userMessage"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
// PeerStatus is the frontend-facing shape of a daemon PeerState.
|
||||
type PeerStatus struct {
|
||||
IP string `json:"ip"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
PubKey string `json:"pubKey"`
|
||||
ConnStatus string `json:"connStatus"`
|
||||
ConnStatusUpdateUnix int64 `json:"connStatusUpdateUnix"`
|
||||
Relayed bool `json:"relayed"`
|
||||
LocalIceCandidateType string `json:"localIceCandidateType"`
|
||||
RemoteIceCandidateType string `json:"remoteIceCandidateType"`
|
||||
LocalIceCandidateEndpoint string `json:"localIceCandidateEndpoint"`
|
||||
RemoteIceCandidateEndpoint string `json:"remoteIceCandidateEndpoint"`
|
||||
Fqdn string `json:"fqdn"`
|
||||
BytesRx int64 `json:"bytesRx"`
|
||||
BytesTx int64 `json:"bytesTx"`
|
||||
LatencyMs int64 `json:"latencyMs"`
|
||||
RelayAddress string `json:"relayAddress"`
|
||||
LastHandshakeUnix int64 `json:"lastHandshakeUnix"`
|
||||
RosenpassEnabled bool `json:"rosenpassEnabled"`
|
||||
Networks []string `json:"networks"`
|
||||
}
|
||||
|
||||
// PeerLink is this peer's connection to its mgmt or signal server.
|
||||
type PeerLink struct {
|
||||
URL string `json:"url"`
|
||||
Connected bool `json:"connected"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// LocalPeer mirrors LocalPeerState.
|
||||
type LocalPeer struct {
|
||||
IP string `json:"ip"`
|
||||
IPv6 string `json:"ipv6"`
|
||||
PubKey string `json:"pubKey"`
|
||||
Fqdn string `json:"fqdn"`
|
||||
Networks []string `json:"networks"`
|
||||
}
|
||||
|
||||
// Status is the snapshot the frontend renders on the dashboard.
|
||||
type Status struct {
|
||||
Status string `json:"status"`
|
||||
DaemonVersion string `json:"daemonVersion"`
|
||||
Management PeerLink `json:"management"`
|
||||
Signal PeerLink `json:"signal"`
|
||||
Local LocalPeer `json:"local"`
|
||||
Peers []PeerStatus `json:"peers"`
|
||||
Events []SystemEvent `json:"events"`
|
||||
// NetworksRevision bumps whenever the daemon's routed-networks set or their
|
||||
// selected state changes, so consumers know when to re-fetch ListNetworks
|
||||
// instead of polling every snapshot.
|
||||
NetworksRevision uint64 `json:"networksRevision"`
|
||||
// SessionExpiresAt is the absolute UTC instant the SSO session expires; nil
|
||||
// when the peer is not SSO-tracked or login expiration is disabled.
|
||||
SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"`
|
||||
}
|
||||
|
||||
// DaemonFeed fans the daemon's two long-running gRPC streams (SubscribeStatus,
|
||||
// SubscribeEvents) out to the frontend and tray, and exposes a one-shot Status
|
||||
// RPC for callers wanting the current snapshot without subscribing.
|
||||
//
|
||||
// Profile-switch suppression: BeginProfileSwitch makes statusStreamLoop swallow
|
||||
// the transient stale Connected and Idle pushes the daemon emits during Down, so
|
||||
// consumers see Connecting → new-profile-state instead of the full blink.
|
||||
//
|
||||
// Two flags govern the switch lifecycle, evaluated independently by
|
||||
// consumeForSwitch on every push because their lifetimes differ:
|
||||
//
|
||||
// switchInProgress (suppression): clears on the first real push from the new
|
||||
// Up. Daemon-side StatusConnecting comes BEFORE any NeedsLogin, so
|
||||
// suppression must release here before the terminal arrives.
|
||||
// switchLoginWatch (trigger): outlives suppression. Watches for NeedsLogin
|
||||
// / LoginFailed / SessionExpired along the Up's retry loop and emits
|
||||
// EventTriggerLogin so the React orchestrator opens browser-login.
|
||||
//
|
||||
// ┌────────────────────────────────────────────┬──────────────────────────────────┐
|
||||
// │ Incoming daemon status │ Action │
|
||||
// ├────────────────────────────────────────────┼──────────────────────────────────┤
|
||||
// │ Connected, Idle (while switchInProgress) │ Suppress (the blink we hide) │
|
||||
// │ Connecting │ Emit, clear switchInProgress │
|
||||
// │ NeedsLogin, LoginFailed, SessionExpired │ Emit, clear both flags, also │
|
||||
// │ │ emit EventTriggerLogin │
|
||||
// │ Connected, Idle (while only login-watch) │ Emit, clear switchLoginWatch │
|
||||
// │ DaemonUnavailable │ Emit, clear both flags │
|
||||
// │ (timeout elapsed) │ Clear flags, emit normally │
|
||||
// └────────────────────────────────────────────┴──────────────────────────────────┘
|
||||
type DaemonFeed struct {
|
||||
conn DaemonConn
|
||||
emitter Emitter
|
||||
updater *updater.Holder
|
||||
// logCtl attaches/detaches the GUI file log in response to the daemon's log
|
||||
// level (a marked SystemEvent on the SubscribeEvents stream). nil when the GUI
|
||||
// doesn't manage its log (server build / not wired), in which case the marker
|
||||
// is ignored.
|
||||
logCtl LogController
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
streamWg sync.WaitGroup
|
||||
|
||||
switchMu sync.Mutex
|
||||
switchInProgress bool
|
||||
switchInProgressUntil time.Time
|
||||
switchLoginWatch bool
|
||||
switchLoginWatchUntil time.Time
|
||||
}
|
||||
|
||||
// LogController is the subset of guilog.DebugLog that DaemonFeed drives: Apply
|
||||
// turns the GUI file log on/off for a daemon level; Path is the gui-client.log
|
||||
// path to register with the daemon (empty when the GUI doesn't own its log).
|
||||
type LogController interface {
|
||||
Apply(level string)
|
||||
Path() string
|
||||
}
|
||||
|
||||
// NewDaemonFeed builds the feed. logCtl may be nil (server build / GUI log not
|
||||
// managed), in which case log-level markers on the event stream are ignored.
|
||||
func NewDaemonFeed(conn DaemonConn, emitter Emitter, updaterHolder *updater.Holder, logCtl LogController) *DaemonFeed {
|
||||
return &DaemonFeed{conn: conn, emitter: emitter, updater: updaterHolder, logCtl: logCtl}
|
||||
}
|
||||
|
||||
// BeginProfileSwitch arms suppression for a switch from Connected/Connecting,
|
||||
// where the daemon emits stale Connected updates during Down's teardown then an
|
||||
// Idle before the new Up; statusStreamLoop drops those, and a synthetic
|
||||
// Connecting snapshot is emitted so consumers paint optimistically. A 30s safety
|
||||
// timeout clears the flag if no follow-up status arrives.
|
||||
func (s *DaemonFeed) BeginProfileSwitch() {
|
||||
now := time.Now()
|
||||
s.switchMu.Lock()
|
||||
s.switchInProgress = true
|
||||
s.switchInProgressUntil = now.Add(30 * time.Second)
|
||||
s.switchLoginWatch = true
|
||||
s.switchLoginWatchUntil = now.Add(30 * time.Second)
|
||||
s.switchMu.Unlock()
|
||||
s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusConnecting})
|
||||
}
|
||||
|
||||
// CancelProfileSwitch aborts a switch midway (tray Disconnect while Connecting):
|
||||
// clears suppression so the next daemon Idle paints through, and disarms the
|
||||
// login-watch so the abort doesn't pop a browser-login after the user cancelled.
|
||||
func (s *DaemonFeed) CancelProfileSwitch() {
|
||||
s.switchMu.Lock()
|
||||
s.switchInProgress = false
|
||||
s.switchLoginWatch = false
|
||||
s.switchMu.Unlock()
|
||||
}
|
||||
|
||||
// Watch starts the two background stream loops. Idempotent (a second call while
|
||||
// running is a no-op); both loops self-restart via exponential backoff.
|
||||
func (s *DaemonFeed) Watch(ctx context.Context) {
|
||||
s.mu.Lock()
|
||||
if s.cancel != nil {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
s.cancel = cancel
|
||||
s.mu.Unlock()
|
||||
|
||||
s.streamWg.Add(2)
|
||||
go s.statusStreamLoop(ctx)
|
||||
go s.toastStreamLoop(ctx)
|
||||
}
|
||||
|
||||
// ServiceShutdown is the Wails service hook fired on app exit.
|
||||
func (s *DaemonFeed) ServiceShutdown() error {
|
||||
s.mu.Lock()
|
||||
cancel := s.cancel
|
||||
s.cancel = nil
|
||||
s.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
s.streamWg.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the current daemon status snapshot. An unreachable daemon socket
|
||||
// yields Status{Status: StatusDaemonUnavailable} rather than an error, so the
|
||||
// frontend keys off a single status enum without a parallel "error" path.
|
||||
func (s *DaemonFeed) Get(ctx context.Context) (Status, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
if isDaemonUnreachable(err) {
|
||||
return Status{Status: StatusDaemonUnavailable}, nil
|
||||
}
|
||||
return Status{}, err
|
||||
}
|
||||
resp, err := cli.Status(ctx, &proto.StatusRequest{GetFullPeerStatus: true})
|
||||
if err != nil {
|
||||
if isDaemonUnreachable(err) {
|
||||
return Status{Status: StatusDaemonUnavailable}, nil
|
||||
}
|
||||
return Status{}, err
|
||||
}
|
||||
return statusFromProto(resp), nil
|
||||
}
|
||||
|
||||
// consumeForSwitch decides, for an incoming push during a profile switch,
|
||||
// whether to suppress it (suppress) and whether the switch landed in a state
|
||||
// needing the SSO flow (triggerLogin: NeedsLogin / SessionExpired / LoginFailed).
|
||||
//
|
||||
// The two flags have different lifetimes: suppression clears on Connecting, but
|
||||
// the trigger watcher must survive past it to catch the eventual NeedsLogin —
|
||||
// daemon-side StatusConnecting fires before loginToManagement, which is what may
|
||||
// then set StatusNeedsLogin.
|
||||
func (s *DaemonFeed) consumeForSwitch(st Status) (suppress, triggerLogin bool) {
|
||||
s.switchMu.Lock()
|
||||
defer s.switchMu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
if s.switchInProgress && now.After(s.switchInProgressUntil) {
|
||||
s.switchInProgress = false
|
||||
}
|
||||
if s.switchLoginWatch && now.After(s.switchLoginWatchUntil) {
|
||||
s.switchLoginWatch = false
|
||||
}
|
||||
|
||||
if s.switchInProgress {
|
||||
switch {
|
||||
case strings.EqualFold(st.Status, StatusConnecting),
|
||||
strings.EqualFold(st.Status, StatusNeedsLogin),
|
||||
strings.EqualFold(st.Status, StatusLoginFailed),
|
||||
strings.EqualFold(st.Status, StatusSessionExpired),
|
||||
strings.EqualFold(st.Status, StatusDaemonUnavailable):
|
||||
// New flow has begun (Up started, or daemon refused it).
|
||||
s.switchInProgress = false
|
||||
default:
|
||||
// Stale Connected from teardown or transient Idle: suppress so the
|
||||
// optimistic Connecting stays painted. Login-watch stays armed.
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
|
||||
if s.switchLoginWatch {
|
||||
switch {
|
||||
case strings.EqualFold(st.Status, StatusNeedsLogin),
|
||||
strings.EqualFold(st.Status, StatusLoginFailed),
|
||||
strings.EqualFold(st.Status, StatusSessionExpired):
|
||||
// SSO-needed terminal: trigger browser-login without a second click.
|
||||
s.switchLoginWatch = false
|
||||
return false, true
|
||||
case strings.EqualFold(st.Status, StatusConnected),
|
||||
strings.EqualFold(st.Status, StatusIdle),
|
||||
strings.EqualFold(st.Status, StatusDaemonUnavailable):
|
||||
// Terminal but not SSO — disarm without triggering.
|
||||
s.switchLoginWatch = false
|
||||
}
|
||||
}
|
||||
|
||||
return false, false
|
||||
}
|
||||
|
||||
// statusStreamLoop subscribes to SubscribeStatus and re-emits each snapshot on
|
||||
// the Wails event bus. The first message is the current snapshot; later ones
|
||||
// fire on connection-state changes only — no polling.
|
||||
func (s *DaemonFeed) statusStreamLoop(ctx context.Context) {
|
||||
defer s.streamWg.Done()
|
||||
|
||||
bo := backoff.WithContext(&backoff.ExponentialBackOff{
|
||||
InitialInterval: time.Second,
|
||||
RandomizationFactor: backoff.DefaultRandomizationFactor,
|
||||
Multiplier: backoff.DefaultMultiplier,
|
||||
MaxInterval: 10 * time.Second,
|
||||
MaxElapsedTime: 0,
|
||||
Stop: backoff.Stop,
|
||||
Clock: backoff.SystemClock,
|
||||
}, ctx)
|
||||
|
||||
// unavailable fires the synthetic event once per outage, not on every retry.
|
||||
unavailable := false
|
||||
emitUnavailable := func() {
|
||||
if unavailable {
|
||||
return
|
||||
}
|
||||
unavailable = true
|
||||
s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusDaemonUnavailable})
|
||||
}
|
||||
|
||||
op := func() error {
|
||||
return s.subscribeAndStreamStatus(ctx, &unavailable, emitUnavailable)
|
||||
}
|
||||
|
||||
if err := backoff.Retry(op, bo); err != nil && ctx.Err() == nil {
|
||||
log.Errorf("status stream ended: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// subscribeAndStreamStatus is one attempt of the status backoff loop: open
|
||||
// SubscribeStatus and re-emit every snapshot until it errors. A daemon-
|
||||
// unreachable failure also flips the synthetic-unavailable signal.
|
||||
func (s *DaemonFeed) subscribeAndStreamStatus(ctx context.Context, unavailable *bool, emitUnavailable func()) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
emitUnavailable()
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
stream, err := cli.SubscribeStatus(ctx, &proto.StatusRequest{GetFullPeerStatus: true})
|
||||
if err != nil {
|
||||
if isDaemonUnreachable(err) {
|
||||
emitUnavailable()
|
||||
}
|
||||
return fmt.Errorf("subscribe status: %w", err)
|
||||
}
|
||||
for {
|
||||
resp, err := stream.Recv()
|
||||
if err != nil {
|
||||
return s.handleStatusRecvErr(ctx, err, emitUnavailable)
|
||||
}
|
||||
*unavailable = false
|
||||
s.emitStatus(statusFromProto(resp))
|
||||
}
|
||||
}
|
||||
|
||||
// handleStatusRecvErr maps a SubscribeStatus Recv error into the backoff loop's
|
||||
// return: ctx cancellation stops the loop, an unreachable socket flips the
|
||||
// synthetic-unavailable signal, everything else is retryable.
|
||||
func (s *DaemonFeed) handleStatusRecvErr(ctx context.Context, err error, emitUnavailable func()) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if isDaemonUnreachable(err) {
|
||||
emitUnavailable()
|
||||
}
|
||||
return fmt.Errorf("status stream recv: %w", err)
|
||||
}
|
||||
|
||||
// emitStatus pushes a snapshot to the frontend, dropping the transient
|
||||
// stale-Connected / Idle pushes that occur mid profile switch.
|
||||
func (s *DaemonFeed) emitStatus(st Status) {
|
||||
log.Infof("backend event: status status=%q peers=%d", st.Status, len(st.Peers))
|
||||
suppress, triggerLogin := s.consumeForSwitch(st)
|
||||
if suppress {
|
||||
log.Debugf("suppressing status=%q during profile switch", st.Status)
|
||||
return
|
||||
}
|
||||
s.emitter.Emit(EventStatusSnapshot, st)
|
||||
if triggerLogin {
|
||||
s.emitter.Emit(EventTriggerLogin)
|
||||
}
|
||||
}
|
||||
|
||||
// toastStreamLoop subscribes to SubscribeEvents and re-emits every SystemEvent
|
||||
// on the Wails event bus. Local name differs from the RPC so the file's two
|
||||
// streams aren't both called streamLoop.
|
||||
func (s *DaemonFeed) toastStreamLoop(ctx context.Context) {
|
||||
defer s.streamWg.Done()
|
||||
|
||||
bo := backoff.WithContext(&backoff.ExponentialBackOff{
|
||||
InitialInterval: time.Second,
|
||||
RandomizationFactor: backoff.DefaultRandomizationFactor,
|
||||
Multiplier: backoff.DefaultMultiplier,
|
||||
MaxInterval: 10 * time.Second,
|
||||
MaxElapsedTime: 0,
|
||||
Stop: backoff.Stop,
|
||||
Clock: backoff.SystemClock,
|
||||
}, ctx)
|
||||
|
||||
op := func() error {
|
||||
return s.subscribeAndStreamEvents(ctx)
|
||||
}
|
||||
|
||||
if err := backoff.Retry(op, bo); err != nil && ctx.Err() == nil {
|
||||
log.Errorf("event stream ended: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// subscribeAndStreamEvents is one attempt of the event backoff loop: open
|
||||
// SubscribeEvents and fan out every SystemEvent until it errors.
|
||||
func (s *DaemonFeed) subscribeAndStreamEvents(ctx context.Context) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get client: %w", err)
|
||||
}
|
||||
stream, err := cli.SubscribeEvents(ctx, &proto.SubscribeRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscribe: %w", err)
|
||||
}
|
||||
|
||||
// Re-register the GUI log path on every (re)connect so a daemon restart
|
||||
// re-learns it. Best-effort — a failure must not abort the stream. Done even
|
||||
// when file logging is off, so the path is known ahead of any debug toggle.
|
||||
if s.logCtl != nil && s.logCtl.Path() != "" {
|
||||
if _, err := cli.RegisterUILog(ctx, &proto.RegisterUILogRequest{Path: s.logCtl.Path()}); err != nil {
|
||||
log.Warnf("register UI log path: %v", err)
|
||||
}
|
||||
}
|
||||
for {
|
||||
ev, err := stream.Recv()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
return fmt.Errorf("stream recv: %w", err)
|
||||
}
|
||||
s.dispatchSystemEvent(ev)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchSystemEvent fans one daemon SystemEvent out to the frontend
|
||||
// notification stream, the typed session-warning event (when the metadata
|
||||
// carries one), and the updater holder (when present).
|
||||
func (s *DaemonFeed) dispatchSystemEvent(ev *proto.SystemEvent) {
|
||||
se := systemEventFromProto(ev)
|
||||
log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage)
|
||||
// Internal refresh signal (CLI-driven profile add/remove), not a notification:
|
||||
// translate and stop so it never reaches Recent Events or fires an OS toast.
|
||||
if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindProfileListChanged {
|
||||
s.emitter.Emit(EventProfileChanged, ProfileRef{})
|
||||
return
|
||||
}
|
||||
// Internal control signal driving the GUI file log on/off — handle and stop
|
||||
// so it never reaches Recent Events or toasts.
|
||||
if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindLogLevelChanged {
|
||||
if s.logCtl != nil {
|
||||
s.logCtl.Apply(se.Metadata[proto.MetadataLevelKey])
|
||||
}
|
||||
return
|
||||
}
|
||||
s.emitter.Emit(EventDaemonNotification, se)
|
||||
if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok {
|
||||
s.emitter.Emit(EventSessionWarning, warn)
|
||||
}
|
||||
if s.updater != nil {
|
||||
s.updater.OnSystemEvent(ev)
|
||||
}
|
||||
}
|
||||
|
||||
func statusFromProto(resp *proto.StatusResponse) Status {
|
||||
full := resp.GetFullStatus()
|
||||
mgmt := full.GetManagementState()
|
||||
sig := full.GetSignalState()
|
||||
local := full.GetLocalPeerState()
|
||||
|
||||
st := Status{
|
||||
Status: resp.GetStatus(),
|
||||
DaemonVersion: resp.GetDaemonVersion(),
|
||||
NetworksRevision: full.GetNetworksRevision(),
|
||||
Management: PeerLink{
|
||||
URL: mgmt.GetURL(),
|
||||
Connected: mgmt.GetConnected(),
|
||||
Error: mgmt.GetError(),
|
||||
},
|
||||
Signal: PeerLink{
|
||||
URL: sig.GetURL(),
|
||||
Connected: sig.GetConnected(),
|
||||
Error: sig.GetError(),
|
||||
},
|
||||
Local: LocalPeer{
|
||||
IP: local.GetIP(),
|
||||
IPv6: local.GetIpv6(),
|
||||
PubKey: local.GetPubKey(),
|
||||
Fqdn: local.GetFqdn(),
|
||||
Networks: append([]string{}, local.GetNetworks()...),
|
||||
},
|
||||
}
|
||||
|
||||
for _, p := range full.GetPeers() {
|
||||
st.Peers = append(st.Peers, PeerStatus{
|
||||
IP: p.GetIP(),
|
||||
IPv6: p.GetIpv6(),
|
||||
PubKey: p.GetPubKey(),
|
||||
ConnStatus: p.GetConnStatus(),
|
||||
ConnStatusUpdateUnix: p.GetConnStatusUpdate().GetSeconds(),
|
||||
Relayed: p.GetRelayed(),
|
||||
LocalIceCandidateType: p.GetLocalIceCandidateType(),
|
||||
RemoteIceCandidateType: p.GetRemoteIceCandidateType(),
|
||||
LocalIceCandidateEndpoint: p.GetLocalIceCandidateEndpoint(),
|
||||
RemoteIceCandidateEndpoint: p.GetRemoteIceCandidateEndpoint(),
|
||||
Fqdn: p.GetFqdn(),
|
||||
BytesRx: p.GetBytesRx(),
|
||||
BytesTx: p.GetBytesTx(),
|
||||
LatencyMs: p.GetLatency().AsDuration().Milliseconds(),
|
||||
RelayAddress: p.GetRelayAddress(),
|
||||
LastHandshakeUnix: p.GetLastWireguardHandshake().GetSeconds(),
|
||||
RosenpassEnabled: p.GetRosenpassEnabled(),
|
||||
Networks: append([]string{}, p.GetNetworks()...),
|
||||
})
|
||||
}
|
||||
for _, e := range full.GetEvents() {
|
||||
st.Events = append(st.Events, systemEventFromProto(e))
|
||||
}
|
||||
if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() {
|
||||
t := ts.AsTime().UTC()
|
||||
st.SessionExpiresAt = &t
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func systemEventFromProto(e *proto.SystemEvent) SystemEvent {
|
||||
out := SystemEvent{
|
||||
ID: e.GetId(),
|
||||
Severity: strings.ToLower(strings.TrimPrefix(e.GetSeverity().String(), "SystemEvent_")),
|
||||
Category: strings.ToLower(strings.TrimPrefix(e.GetCategory().String(), "SystemEvent_")),
|
||||
Message: e.GetMessage(),
|
||||
UserMessage: e.GetUserMessage(),
|
||||
Metadata: map[string]string{},
|
||||
}
|
||||
if ts := e.GetTimestamp(); ts != nil {
|
||||
out.Timestamp = ts.GetSeconds()
|
||||
}
|
||||
for k, v := range e.GetMetadata() {
|
||||
out.Metadata[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isDaemonUnreachable reports whether a gRPC error means the daemon socket isn't
|
||||
// answering, versus the daemon responding with an application-level code. Only
|
||||
// the former should flip the tray to "Not running" — a daemon returning e.g.
|
||||
// FailedPrecondition is alive and must not be reported as down.
|
||||
func isDaemonUnreachable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return st.Code() == codes.Unavailable
|
||||
}
|
||||
136
client/ui/services/debug.go
Normal file
136
client/ui/services/debug.go
Normal file
@@ -0,0 +1,136 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
type DebugBundleParams struct {
|
||||
Anonymize bool `json:"anonymize"`
|
||||
SystemInfo bool `json:"systemInfo"`
|
||||
UploadURL string `json:"uploadUrl"`
|
||||
LogFileCount uint32 `json:"logFileCount"`
|
||||
}
|
||||
|
||||
// DebugBundleResult: Path is set for local-only bundles, UploadedKey on upload
|
||||
// success, UploadFailureReason on upload failure.
|
||||
type DebugBundleResult struct {
|
||||
Path string `json:"path"`
|
||||
UploadedKey string `json:"uploadedKey"`
|
||||
UploadFailureReason string `json:"uploadFailureReason"`
|
||||
}
|
||||
|
||||
// LogLevel carries a logrus level name: "error", "warn", "info", "debug", "trace".
|
||||
type LogLevel struct {
|
||||
Level string `json:"level"`
|
||||
}
|
||||
|
||||
type Debug struct {
|
||||
conn DaemonConn
|
||||
}
|
||||
|
||||
func NewDebug(conn DaemonConn) *Debug {
|
||||
return &Debug{conn: conn}
|
||||
}
|
||||
|
||||
func (s *Debug) Bundle(ctx context.Context, p DebugBundleParams) (DebugBundleResult, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return DebugBundleResult{}, err
|
||||
}
|
||||
resp, err := cli.DebugBundle(ctx, &proto.DebugBundleRequest{
|
||||
Anonymize: p.Anonymize,
|
||||
SystemInfo: p.SystemInfo,
|
||||
UploadURL: p.UploadURL,
|
||||
LogFileCount: p.LogFileCount,
|
||||
CliVersion: version.NetbirdVersion(),
|
||||
})
|
||||
if err != nil {
|
||||
return DebugBundleResult{}, err
|
||||
}
|
||||
return DebugBundleResult{
|
||||
Path: resp.GetPath(),
|
||||
UploadedKey: resp.GetUploadedKey(),
|
||||
UploadFailureReason: resp.GetUploadFailureReason(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Debug) GetLogLevel(ctx context.Context) (LogLevel, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return LogLevel{}, err
|
||||
}
|
||||
resp, err := cli.GetLogLevel(ctx, &proto.GetLogLevelRequest{})
|
||||
if err != nil {
|
||||
return LogLevel{}, err
|
||||
}
|
||||
return LogLevel{Level: resp.GetLevel().String()}, nil
|
||||
}
|
||||
|
||||
// RevealFile opens the OS file manager focused on path. Needed because Wails'
|
||||
// Browser.OpenURL refuses non-http(s) schemes like file://.
|
||||
func (s *Debug) RevealFile(_ context.Context, path string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("empty path")
|
||||
}
|
||||
return revealFile(path)
|
||||
}
|
||||
|
||||
// RegisterUILog reports the GUI log path to the daemon for bundle collection;
|
||||
// the daemon runs as root and can't resolve the user's config dir. Called on
|
||||
// each daemon (re)connect.
|
||||
func (s *Debug) RegisterUILog(ctx context.Context, path string) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.RegisterUILog(ctx, &proto.RegisterUILogRequest{Path: path})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Debug) StartBundleCapture(ctx context.Context, timeoutSeconds int32) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := &proto.StartBundleCaptureRequest{}
|
||||
if timeoutSeconds > 0 {
|
||||
req.Timeout = durationpb.New(time.Duration(timeoutSeconds) * time.Second)
|
||||
}
|
||||
_, err = cli.StartBundleCapture(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Debug) StopBundleCapture(ctx context.Context) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.StopBundleCapture(ctx, &proto.StopBundleCaptureRequest{})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// proto.LogLevel_value keys are upper-case enum names; callers pass
|
||||
// lowercase logrus names. Upper-case before lookup or a valid level
|
||||
// silently falls through to INFO.
|
||||
level, ok := proto.LogLevel_value[strings.ToUpper(lvl.Level)]
|
||||
if !ok {
|
||||
level = int32(proto.LogLevel_INFO)
|
||||
}
|
||||
_, err = cli.SetLogLevel(ctx, &proto.SetLogLevelRequest{Level: proto.LogLevel(level)})
|
||||
return err
|
||||
}
|
||||
20
client/ui/services/debug_reveal_other.go
Normal file
20
client/ui/services/debug_reveal_other.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build !android && !ios && !freebsd && !js && !windows
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// revealFile opens the OS file manager focused on path.
|
||||
func revealFile(path string) error {
|
||||
var cmd *exec.Cmd
|
||||
if runtime.GOOS == "darwin" {
|
||||
cmd = exec.Command("open", "-R", path)
|
||||
} else {
|
||||
cmd = exec.Command("xdg-open", filepath.Dir(path))
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
54
client/ui/services/debug_reveal_windows.go
Normal file
54
client/ui/services/debug_reveal_windows.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// SW_SHOWNORMAL for ShellExecuteW's nShowCmd.
|
||||
const swShowNormal = 1
|
||||
|
||||
var (
|
||||
shell32 = windows.NewLazySystemDLL("shell32.dll")
|
||||
procShellExecute = shell32.NewProc("ShellExecuteW")
|
||||
)
|
||||
|
||||
// revealFile opens Explorer focused on path. The debug bundle is written by the
|
||||
// daemon (running as SYSTEM) into C:\Windows\SystemTemp, whose ACL denies the
|
||||
// logged-in user. A plain "explorer /select" can't traverse it, so we elevate
|
||||
// via the ShellExecuteW "runas" verb (UAC prompt) — the elevated Explorer can
|
||||
// read the folder and highlight the file.
|
||||
func revealFile(path string) error {
|
||||
verb, err := windows.UTF16PtrFromString("runas")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode verb: %w", err)
|
||||
}
|
||||
file, err := windows.UTF16PtrFromString("explorer.exe")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode file: %w", err)
|
||||
}
|
||||
params, err := windows.UTF16PtrFromString("/select," + path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode params: %w", err)
|
||||
}
|
||||
|
||||
// ShellExecuteW returns an HINSTANCE; a value <=32 is an error code.
|
||||
ret, _, _ := procShellExecute.Call(
|
||||
0,
|
||||
uintptr(unsafe.Pointer(verb)),
|
||||
uintptr(unsafe.Pointer(file)),
|
||||
uintptr(unsafe.Pointer(params)),
|
||||
0,
|
||||
swShowNormal,
|
||||
)
|
||||
if ret <= 32 {
|
||||
// Elevation declined or failed: fall back to an unelevated reveal of the
|
||||
// parent directory so the user at least lands near the bundle.
|
||||
return exec.Command("explorer", filepath.Dir(path)).Start() //nolint:gosec
|
||||
}
|
||||
return nil
|
||||
}
|
||||
133
client/ui/services/errors.go
Normal file
133
client/ui/services/errors.go
Normal file
@@ -0,0 +1,133 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
gcodes "google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// ErrorTranslator localises daemon errors; runtime impl is *i18n.Bundle.
|
||||
type ErrorTranslator interface {
|
||||
Translate(lang i18n.LanguageCode, key string, args ...string) string
|
||||
}
|
||||
|
||||
// LanguagePreference reports the current UI language; runtime impl is *preferences.Store.
|
||||
type LanguagePreference interface {
|
||||
Get() preferences.UIPreferences
|
||||
}
|
||||
|
||||
// ClientError is a structured error returned to the frontend. The frontend
|
||||
// translates Code via i18n; Short is an English fallback; Long carries the
|
||||
// unwrapped daemon message.
|
||||
type ClientError struct {
|
||||
Code string `json:"code"`
|
||||
Short string `json:"short"`
|
||||
Long string `json:"long"`
|
||||
}
|
||||
|
||||
// Error returns the short message for plain Go callers.
|
||||
func (e *ClientError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Short
|
||||
}
|
||||
|
||||
// MarshalJSON emits the struct so the Wails binding sends an object, not the
|
||||
// default "error: ..." string.
|
||||
func (e *ClientError) MarshalJSON() ([]byte, error) {
|
||||
if e == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
type alias ClientError
|
||||
return json.Marshal((*alias)(e))
|
||||
}
|
||||
|
||||
// errorClassifier maps gRPC errors to a localised ClientError. Shared by the
|
||||
// daemon-facing services so the frontend gets a clean short message instead of
|
||||
// the wrapped gRPC chain.
|
||||
type errorClassifier struct {
|
||||
translator ErrorTranslator
|
||||
prefs LanguagePreference
|
||||
}
|
||||
|
||||
// classify maps a gRPC error to a ClientError by matching known substrings to a
|
||||
// stable code. A missing locale entry surfaces as a visible "error.<code>"
|
||||
// string — a deliberate fail-loud signal to update the bundle.
|
||||
func (c errorClassifier) classify(err error) *ClientError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
grpcCode := gcodes.Unknown
|
||||
if st, ok := gstatus.FromError(err); ok {
|
||||
msg = st.Message()
|
||||
grpcCode = st.Code()
|
||||
}
|
||||
lower := strings.ToLower(msg)
|
||||
|
||||
code := "unknown"
|
||||
switch {
|
||||
case strings.Contains(lower, "token used before issued"),
|
||||
strings.Contains(lower, "token is not valid yet"):
|
||||
code = "jwt_clock_skew"
|
||||
case strings.Contains(lower, "token is expired"),
|
||||
strings.Contains(lower, "token has expired"):
|
||||
code = "jwt_expired"
|
||||
case strings.Contains(lower, "token signature is invalid"):
|
||||
code = "jwt_signature_invalid"
|
||||
case strings.Contains(lower, "peer login has expired"):
|
||||
code = "session_expired"
|
||||
case strings.Contains(lower, "invalid setup-key"),
|
||||
strings.Contains(lower, "invalid setup key"):
|
||||
code = "invalid_setup_key"
|
||||
case strings.Contains(lower, "permission denied"):
|
||||
code = "permission_denied"
|
||||
case strings.Contains(lower, "no connection could be made"),
|
||||
strings.Contains(lower, "connection refused"),
|
||||
strings.Contains(lower, "context deadline exceeded"):
|
||||
code = "daemon_unreachable"
|
||||
}
|
||||
|
||||
// Fall back to the gRPC status code when the message didn't match a known
|
||||
// substring — the daemon now forwards the innermost code with a clean desc
|
||||
// that no longer contains the English marker text.
|
||||
if code == "unknown" {
|
||||
switch grpcCode {
|
||||
case gcodes.PermissionDenied:
|
||||
code = "permission_denied"
|
||||
case gcodes.Unavailable, gcodes.DeadlineExceeded:
|
||||
code = "daemon_unreachable"
|
||||
}
|
||||
}
|
||||
|
||||
return &ClientError{
|
||||
Code: code,
|
||||
Short: c.translateShort(code),
|
||||
Long: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// translateShort resolves the localised short message for code, returning the
|
||||
// bare "error.<code>" key when no translation is available so the gap stays visible.
|
||||
func (c errorClassifier) translateShort(code string) string {
|
||||
key := "error." + code
|
||||
if c.translator == nil {
|
||||
return key
|
||||
}
|
||||
lang := i18n.DefaultLanguage
|
||||
if c.prefs != nil {
|
||||
if pref := c.prefs.Get().Language; pref != "" {
|
||||
lang = pref
|
||||
}
|
||||
}
|
||||
return c.translator.Translate(lang, key)
|
||||
}
|
||||
50
client/ui/services/errors_test.go
Normal file
50
client/ui/services/errors_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
gcodes "google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestErrorClassifier_Classify(t *testing.T) {
|
||||
c := errorClassifier{} // nil translator → Short is the bare "error.<code>" key
|
||||
|
||||
t.Run("permission denied by gRPC code with a clean desc", func(t *testing.T) {
|
||||
// The daemon now forwards the innermost status: code + clean desc that
|
||||
// no longer carries the English "permission denied" marker.
|
||||
err := gstatus.Error(gcodes.PermissionDenied, "peer is already registered by a different User or a Setup Key")
|
||||
|
||||
ce := c.classify(err)
|
||||
require.NotNil(t, ce)
|
||||
require.Equal(t, "permission_denied", ce.Code)
|
||||
require.Equal(t, "error.permission_denied", ce.Short)
|
||||
require.Equal(t, "peer is already registered by a different User or a Setup Key", ce.Long)
|
||||
})
|
||||
|
||||
t.Run("substring match still wins for unclassified codes", func(t *testing.T) {
|
||||
err := gstatus.Error(gcodes.Unknown, "peer login has expired")
|
||||
|
||||
ce := c.classify(err)
|
||||
require.NotNil(t, ce)
|
||||
require.Equal(t, "session_expired", ce.Code)
|
||||
})
|
||||
|
||||
t.Run("unavailable code maps to daemon_unreachable", func(t *testing.T) {
|
||||
ce := c.classify(gstatus.Error(gcodes.Unavailable, "transport closing"))
|
||||
require.Equal(t, "daemon_unreachable", ce.Code)
|
||||
})
|
||||
|
||||
t.Run("unmatched stays unknown", func(t *testing.T) {
|
||||
ce := c.classify(errors.New("something odd"))
|
||||
require.Equal(t, "unknown", ce.Code)
|
||||
})
|
||||
|
||||
t.Run("nil error", func(t *testing.T) {
|
||||
require.Nil(t, c.classify(nil))
|
||||
})
|
||||
}
|
||||
11
client/ui/services/foreground_other.go
Normal file
11
client/ui/services/foreground_other.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !windows && !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import "github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
func raiseToForeground(w *application.WebviewWindow) {
|
||||
if w != nil {
|
||||
w.Focus()
|
||||
}
|
||||
}
|
||||
43
client/ui/services/foreground_windows.go
Normal file
43
client/ui/services/foreground_windows.go
Normal file
@@ -0,0 +1,43 @@
|
||||
//go:build windows
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/w32"
|
||||
)
|
||||
|
||||
var procAttachThreadInput = syscall.NewLazyDLL("user32.dll").NewProc("AttachThreadInput")
|
||||
|
||||
func attachThreadInput(attach, attachTo w32.HANDLE, on bool) {
|
||||
var flag uintptr
|
||||
if on {
|
||||
flag = 1
|
||||
}
|
||||
_, _, _ = procAttachThreadInput.Call(uintptr(attach), uintptr(attachTo), flag)
|
||||
}
|
||||
|
||||
func raiseToForeground(w *application.WebviewWindow) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
application.InvokeSync(func() {
|
||||
ptr := w.NativeWindow()
|
||||
if ptr == nil {
|
||||
return
|
||||
}
|
||||
hwnd := w32.HWND(uintptr(ptr))
|
||||
|
||||
fgThread, _ := w32.GetWindowThreadProcessId(w32.GetForegroundWindow())
|
||||
appThread := w32.GetCurrentThreadId()
|
||||
if fgThread != appThread {
|
||||
attachThreadInput(fgThread, appThread, true)
|
||||
defer attachThreadInput(fgThread, appThread, false)
|
||||
}
|
||||
w32.ShowWindow(hwnd, w32.SW_SHOW)
|
||||
w32.BringWindowToTop(hwnd)
|
||||
w32.SetForegroundWindow(hwnd)
|
||||
})
|
||||
}
|
||||
83
client/ui/services/forwarding.go
Normal file
83
client/ui/services/forwarding.go
Normal file
@@ -0,0 +1,83 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// PortRange is a port range; both ends are inclusive.
|
||||
type PortRange struct {
|
||||
Start uint32 `json:"start"`
|
||||
End uint32 `json:"end"`
|
||||
}
|
||||
|
||||
// PortInfo holds exactly one of Port or Range (the daemon's oneof).
|
||||
type PortInfo struct {
|
||||
Port *uint32 `json:"port,omitempty"`
|
||||
Range *PortRange `json:"range,omitempty"`
|
||||
}
|
||||
|
||||
// ForwardingRule is one entry from the daemon's reverse-proxy table.
|
||||
type ForwardingRule struct {
|
||||
Protocol string `json:"protocol"`
|
||||
DestinationPort PortInfo `json:"destinationPort"`
|
||||
TranslatedAddress string `json:"translatedAddress"`
|
||||
TranslatedHostname string `json:"translatedHostname"`
|
||||
TranslatedPort PortInfo `json:"translatedPort"`
|
||||
}
|
||||
|
||||
// Forwarding groups the daemon RPCs that surface exposed/forwarded services.
|
||||
type Forwarding struct {
|
||||
conn DaemonConn
|
||||
}
|
||||
|
||||
func NewForwarding(conn DaemonConn) *Forwarding {
|
||||
return &Forwarding{conn: conn}
|
||||
}
|
||||
|
||||
func (s *Forwarding) List(ctx context.Context) ([]ForwardingRule, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := cli.ForwardingRules(ctx, &proto.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]ForwardingRule, 0, len(resp.GetRules()))
|
||||
for _, r := range resp.GetRules() {
|
||||
out = append(out, forwardingRuleFromProto(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func forwardingRuleFromProto(r *proto.ForwardingRule) ForwardingRule {
|
||||
return ForwardingRule{
|
||||
Protocol: r.GetProtocol(),
|
||||
DestinationPort: portInfoFromProto(r.GetDestinationPort()),
|
||||
TranslatedAddress: r.GetTranslatedAddress(),
|
||||
TranslatedHostname: r.GetTranslatedHostname(),
|
||||
TranslatedPort: portInfoFromProto(r.GetTranslatedPort()),
|
||||
}
|
||||
}
|
||||
|
||||
func portInfoFromProto(p *proto.PortInfo) PortInfo {
|
||||
if p == nil {
|
||||
return PortInfo{}
|
||||
}
|
||||
switch sel := p.GetPortSelection().(type) {
|
||||
case *proto.PortInfo_Port:
|
||||
port := sel.Port
|
||||
return PortInfo{Port: &port}
|
||||
case *proto.PortInfo_Range_:
|
||||
r := sel.Range
|
||||
if r == nil {
|
||||
return PortInfo{}
|
||||
}
|
||||
return PortInfo{Range: &PortRange{Start: r.GetStart(), End: r.GetEnd()}}
|
||||
}
|
||||
return PortInfo{}
|
||||
}
|
||||
30
client/ui/services/i18n.go
Normal file
30
client/ui/services/i18n.go
Normal file
@@ -0,0 +1,30 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
)
|
||||
|
||||
// I18n is the Wails-bound facade over i18n.Bundle; the translation logic lives
|
||||
// in client/ui/i18n.
|
||||
type I18n struct {
|
||||
bundle *i18n.Bundle
|
||||
}
|
||||
|
||||
func NewI18n(bundle *i18n.Bundle) *I18n {
|
||||
return &I18n{bundle: bundle}
|
||||
}
|
||||
|
||||
// Languages returns the shipped locales.
|
||||
func (s *I18n) Languages(_ context.Context) ([]i18n.Language, error) {
|
||||
return s.bundle.Languages(), nil
|
||||
}
|
||||
|
||||
// Bundle returns the full key->text map so the React side can drive its own
|
||||
// translation library off the same source bundles.
|
||||
func (s *I18n) Bundle(_ context.Context, code i18n.LanguageCode) (map[string]string, error) {
|
||||
return s.bundle.BundleFor(code)
|
||||
}
|
||||
88
client/ui/services/network.go
Normal file
88
client/ui/services/network.go
Normal file
@@ -0,0 +1,88 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
type Network struct {
|
||||
ID string `json:"id"`
|
||||
Range string `json:"range"`
|
||||
Selected bool `json:"selected"`
|
||||
Domains []string `json:"domains"`
|
||||
ResolvedIPs map[string][]string `json:"resolvedIps"`
|
||||
}
|
||||
|
||||
// SelectNetworksParams: All targets every available network; Append merges IDs into the existing selection.
|
||||
type SelectNetworksParams struct {
|
||||
NetworkIDs []string `json:"networkIds"`
|
||||
Append bool `json:"append"`
|
||||
All bool `json:"all"`
|
||||
}
|
||||
|
||||
type Networks struct {
|
||||
conn DaemonConn
|
||||
}
|
||||
|
||||
func NewNetworks(conn DaemonConn) *Networks {
|
||||
return &Networks{conn: conn}
|
||||
}
|
||||
|
||||
func (s *Networks) List(ctx context.Context) ([]Network, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := cli.ListNetworks(ctx, &proto.ListNetworksRequest{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Network, 0, len(resp.GetRoutes()))
|
||||
for _, n := range resp.GetRoutes() {
|
||||
out = append(out, networkFromProto(n))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Networks) Select(ctx context.Context, p SelectNetworksParams) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.SelectNetworks(ctx, &proto.SelectNetworksRequest{
|
||||
NetworkIDs: p.NetworkIDs,
|
||||
Append: p.Append,
|
||||
All: p.All,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Networks) Deselect(ctx context.Context, p SelectNetworksParams) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.DeselectNetworks(ctx, &proto.SelectNetworksRequest{
|
||||
NetworkIDs: p.NetworkIDs,
|
||||
Append: p.Append,
|
||||
All: p.All,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func networkFromProto(n *proto.Network) Network {
|
||||
resolved := make(map[string][]string, len(n.GetResolvedIPs()))
|
||||
for k, v := range n.GetResolvedIPs() {
|
||||
resolved[k] = append([]string{}, v.GetIps()...)
|
||||
}
|
||||
return Network{
|
||||
ID: n.GetID(),
|
||||
Range: n.GetRange(),
|
||||
Selected: n.GetSelected(),
|
||||
Domains: append([]string{}, n.GetDomains()...),
|
||||
ResolvedIPs: resolved,
|
||||
}
|
||||
}
|
||||
36
client/ui/services/preferences.go
Normal file
36
client/ui/services/preferences.go
Normal file
@@ -0,0 +1,36 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// Preferences is the Wails-bound facade over preferences.Store; the context.Context-first
|
||||
// signatures are what the binding generator requires.
|
||||
type Preferences struct {
|
||||
store *preferences.Store
|
||||
}
|
||||
|
||||
func NewPreferences(store *preferences.Store) *Preferences {
|
||||
return &Preferences{store: store}
|
||||
}
|
||||
|
||||
func (s *Preferences) Get(_ context.Context) (preferences.UIPreferences, error) {
|
||||
return s.store.Get(), nil
|
||||
}
|
||||
|
||||
func (s *Preferences) SetLanguage(_ context.Context, lang i18n.LanguageCode) error {
|
||||
return s.store.SetLanguage(lang)
|
||||
}
|
||||
|
||||
func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode) error {
|
||||
return s.store.SetViewMode(mode)
|
||||
}
|
||||
|
||||
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
|
||||
return s.store.SetOnboardingCompleted(done)
|
||||
}
|
||||
179
client/ui/services/profile.go
Normal file
179
client/ui/services/profile.go
Normal file
@@ -0,0 +1,179 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/user"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
// ID is the daemon-generated on-disk identity of the profile. Display
|
||||
// names can collide and be renamed, so the ID is the stable handle the
|
||||
// daemon resolves switch/remove/logout requests against.
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsActive bool `json:"isActive"`
|
||||
// Email is read from the user-owned per-profile state file (CLI writes it
|
||||
// after SSO login), not via ListProfiles: the daemon runs as root and can't
|
||||
// reach it, while the UI runs as the logged-in user.
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type ProfileRef struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type ActiveProfile struct {
|
||||
// ID is the active profile's stable on-disk identity. Use it (not the
|
||||
// display name) as the handle for daemon requests and active-profile
|
||||
// comparisons, since names can collide.
|
||||
ID string `json:"id"`
|
||||
ProfileName string `json:"profileName"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// RenameProfileParams selects a profile by handle and carries its new display
|
||||
// name.
|
||||
type RenameProfileParams struct {
|
||||
// Handle selects the profile to rename: an exact ID, a unique ID prefix,
|
||||
// or a unique display name. The daemon resolves it server-side.
|
||||
Handle string `json:"handle"`
|
||||
// NewName is the new free-form display name. The daemon sanitizes it
|
||||
// (strips control characters, trims, caps length) but keeps spaces, emoji,
|
||||
// punctuation, and non-ASCII letters.
|
||||
NewName string `json:"newName"`
|
||||
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type Profiles struct {
|
||||
conn DaemonConn
|
||||
}
|
||||
|
||||
func NewProfiles(conn DaemonConn) *Profiles {
|
||||
return &Profiles{conn: conn}
|
||||
}
|
||||
|
||||
// Username returns the OS username the daemon expects for profile lookups.
|
||||
func (s *Profiles) Username() (string, error) {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.Username, nil
|
||||
}
|
||||
|
||||
func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := cli.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pm := profilemanager.NewProfileManager()
|
||||
out := make([]Profile, 0, len(resp.GetProfiles()))
|
||||
for _, p := range resp.GetProfiles() {
|
||||
prof := Profile{ID: p.GetId(), Name: p.GetName(), IsActive: p.GetIsActive()}
|
||||
if state, err := pm.GetProfileState(profilemanager.ID(p.GetId())); err == nil {
|
||||
prof.Email = state.Email
|
||||
}
|
||||
out = append(out, prof)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Profiles) GetActive(ctx context.Context) (ActiveProfile, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return ActiveProfile{}, err
|
||||
}
|
||||
resp, err := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
|
||||
if err != nil {
|
||||
return ActiveProfile{}, err
|
||||
}
|
||||
return ActiveProfile{
|
||||
ID: resp.GetId(),
|
||||
ProfileName: resp.GetProfileName(),
|
||||
Username: resp.GetUsername(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Switch sends a profile switch to the daemon and returns the resolved
|
||||
// on-disk ID of the now-active profile. ProfileName is treated as a handle
|
||||
// (exact ID, unique ID prefix, or unique display name); the daemon resolves
|
||||
// it server-side and echoes back the canonical ID.
|
||||
func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req := &proto.SwitchProfileRequest{}
|
||||
if p.ProfileName != "" {
|
||||
req.ProfileName = ptrStr(p.ProfileName)
|
||||
}
|
||||
if p.Username != "" {
|
||||
req.Username = ptrStr(p.Username)
|
||||
}
|
||||
resp, err := cli.SwitchProfile(ctx, req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.GetId(), nil
|
||||
}
|
||||
|
||||
// Add creates a profile with the given display name and returns its
|
||||
// daemon-generated on-disk ID, so callers can address the new profile by ID
|
||||
// (e.g. to write config or switch to it) without re-resolving the name.
|
||||
func (s *Profiles) Add(ctx context.Context, p ProfileRef) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := cli.AddProfile(ctx, &proto.AddProfileRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.GetId(), nil
|
||||
}
|
||||
|
||||
func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Rename changes a profile's display name. The on-disk ID is unaffected, so
|
||||
// the active profile and any ID-based references stay valid (the default
|
||||
// profile can be renamed too — only its display name changes). Returns the
|
||||
// profile's previous display name as confirmation.
|
||||
func (s *Profiles) Rename(ctx context.Context, p RenameProfileParams) (string, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := cli.RenameProfile(ctx, &proto.RenameProfileRequest{
|
||||
Username: p.Username,
|
||||
Handle: p.Handle,
|
||||
NewProfileName: p.NewName,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.GetOldProfileName(), nil
|
||||
}
|
||||
92
client/ui/services/profileswitcher.go
Normal file
92
client/ui/services/profileswitcher.go
Normal file
@@ -0,0 +1,92 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
)
|
||||
|
||||
// ProfileSwitcher holds the reconnect policy shared by the tray and React
|
||||
// frontend so both flip profiles identically. The policy keys off prevStatus
|
||||
// from DaemonFeed.Get at SwitchActive entry:
|
||||
//
|
||||
// Connected/Connecting → Switch + Down + Up; optimistic Connecting paint.
|
||||
// NeedsLogin/LoginFailed/SessionExpired → Switch + Down; clear stale error for re-login.
|
||||
// Idle → Switch only.
|
||||
type ProfileSwitcher struct {
|
||||
profiles *Profiles
|
||||
connection *Connection
|
||||
feed *DaemonFeed
|
||||
}
|
||||
|
||||
func NewProfileSwitcher(profiles *Profiles, connection *Connection, feed *DaemonFeed) *ProfileSwitcher {
|
||||
return &ProfileSwitcher{profiles: profiles, connection: connection, feed: feed}
|
||||
}
|
||||
|
||||
// SwitchActive switches to the named profile applying the reconnect policy.
|
||||
func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error {
|
||||
prevStatus := ""
|
||||
if st, err := s.feed.Get(ctx); err == nil {
|
||||
prevStatus = st.Status
|
||||
} else {
|
||||
log.Warnf("profileswitcher: get status: %v", err)
|
||||
}
|
||||
|
||||
wasActive := strings.EqualFold(prevStatus, StatusConnected) ||
|
||||
strings.EqualFold(prevStatus, StatusConnecting)
|
||||
needsDown := wasActive ||
|
||||
strings.EqualFold(prevStatus, StatusNeedsLogin) ||
|
||||
strings.EqualFold(prevStatus, StatusLoginFailed) ||
|
||||
strings.EqualFold(prevStatus, StatusSessionExpired)
|
||||
|
||||
log.Infof("profileswitcher: switch profile=%q prevStatus=%q wasActive=%v needsDown=%v",
|
||||
p.ProfileName, prevStatus, wasActive, needsDown)
|
||||
|
||||
// Optimistic Connecting paint only when wasActive: those prevStatuses emit
|
||||
// stale Connected + transient Idle pushes during Down that must be
|
||||
// suppressed until Up resumes the stream (see DaemonFeed suppression table).
|
||||
if wasActive {
|
||||
s.feed.BeginProfileSwitch()
|
||||
}
|
||||
|
||||
resolvedID, err := s.profiles.Switch(ctx, p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("switch profile %q: %w", p.ProfileName, err)
|
||||
}
|
||||
|
||||
// Mirror into the user-side ProfileManager state: the CLI's `netbird up`
|
||||
// reads this file and sends the ID back in the Up RPC, so if it diverges
|
||||
// the daemon reverts the UI switch on the next CLI `up`. Best-effort — the
|
||||
// daemon is authoritative; a failure only leaves the CLI's view stale.
|
||||
// Use the daemon-resolved ID rather than the handle we sent, since the
|
||||
// on-disk state is keyed by ID, not display name.
|
||||
if err := profilemanager.NewProfileManager().SwitchProfile(profilemanager.ID(resolvedID)); err != nil {
|
||||
log.Warnf("profileswitcher: mirror to user-side ProfileManager failed: %v", err)
|
||||
}
|
||||
|
||||
if needsDown {
|
||||
if err := s.connection.Down(ctx); err != nil {
|
||||
log.Errorf("profileswitcher: Down: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if wasActive {
|
||||
if err := s.connection.Up(ctx, UpParams(p)); err != nil {
|
||||
return fmt.Errorf("reconnect %q: %w", p.ProfileName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The daemon emits no profile event, so fan out ourselves or the React
|
||||
// ProfileContext stays on the old profile after a tray-initiated switch.
|
||||
if s.feed != nil && s.feed.emitter != nil {
|
||||
s.feed.emitter.Emit(EventProfileChanged, p)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
48
client/ui/services/session.go
Normal file
48
client/ui/services/session.go
Normal file
@@ -0,0 +1,48 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
)
|
||||
|
||||
// Re-exports so generated bindings reference services.* without importing authsession.
|
||||
type (
|
||||
ExtendStartParams = authsession.ExtendStartParams
|
||||
ExtendStartResult = authsession.ExtendStartResult
|
||||
ExtendWaitParams = authsession.ExtendWaitParams
|
||||
ExtendResult = authsession.ExtendResult
|
||||
)
|
||||
|
||||
// Session wraps authsession.Session, exposing only the subset the React frontend
|
||||
// calls; the tray uses authsession.Session directly, keeping the generated TS surface minimal.
|
||||
type Session struct {
|
||||
inner *authsession.Session
|
||||
classifier errorClassifier
|
||||
}
|
||||
|
||||
// NewSession wraps inner; the caller retains ownership and may use it directly.
|
||||
// translator or prefs may be nil, in which case errors fall back to the bare code key.
|
||||
func NewSession(inner *authsession.Session, translator ErrorTranslator, prefs LanguagePreference) *Session {
|
||||
return &Session{inner: inner, classifier: errorClassifier{translator: translator, prefs: prefs}}
|
||||
}
|
||||
|
||||
// RequestExtend starts the SSO session-extension flow; the result carries the verification URI to open.
|
||||
func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) {
|
||||
res, err := s.inner.RequestExtend(ctx, p)
|
||||
if err != nil {
|
||||
return ExtendStartResult{}, s.classifier.classify(err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// WaitExtend blocks until the RequestExtend flow completes; the deadline is nil when the peer is ineligible.
|
||||
func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) {
|
||||
res, err := s.inner.WaitExtend(ctx, p)
|
||||
if err != nil {
|
||||
return ExtendResult{}, s.classifier.classify(err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
260
client/ui/services/settings.go
Normal file
260
client/ui/services/settings.go
Normal file
@@ -0,0 +1,260 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
type MDMFields struct {
|
||||
ManagementURL string `json:"managementURL"`
|
||||
PreSharedKey bool `json:"preSharedKey"`
|
||||
WireguardPort bool `json:"wireguardPort"`
|
||||
RosenpassEnabled bool `json:"rosenpassEnabled"`
|
||||
RosenpassPermissive bool `json:"rosenpassPermissive"`
|
||||
DisableClientRoutes bool `json:"disableClientRoutes"`
|
||||
DisableServerRoutes bool `json:"disableServerRoutes"`
|
||||
AllowServerSSH *bool `json:"allowServerSSH"`
|
||||
DisableAutoConnect bool `json:"disableAutoConnect"`
|
||||
BlockInbound bool `json:"blockInbound"`
|
||||
DisableMetricsCollection bool `json:"disableMetricsCollection"`
|
||||
SplitTunnelMode bool `json:"splitTunnelMode"`
|
||||
SplitTunnelApps bool `json:"splitTunnelApps"`
|
||||
DisableAdvancedView bool `json:"disableAdvancedView"`
|
||||
}
|
||||
|
||||
type Features struct {
|
||||
DisableProfiles bool `json:"disableProfiles"`
|
||||
DisableNetworks bool `json:"disableNetworks"`
|
||||
DisableUpdateSettings bool `json:"disableUpdateSettings"`
|
||||
}
|
||||
|
||||
type Restrictions struct {
|
||||
MDM MDMFields `json:"mdm"`
|
||||
Features Features `json:"features"`
|
||||
}
|
||||
|
||||
type ConfigParams struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ManagementURL string `json:"managementUrl"`
|
||||
AdminURL string `json:"adminUrl"`
|
||||
ConfigFile string `json:"configFile"`
|
||||
LogFile string `json:"logFile"`
|
||||
PreSharedKeySet bool `json:"preSharedKeySet"`
|
||||
InterfaceName string `json:"interfaceName"`
|
||||
WireguardPort int64 `json:"wireguardPort"`
|
||||
MTU int64 `json:"mtu"`
|
||||
DisableAutoConnect bool `json:"disableAutoConnect"`
|
||||
ServerSSHAllowed bool `json:"serverSshAllowed"`
|
||||
RosenpassEnabled bool `json:"rosenpassEnabled"`
|
||||
RosenpassPermissive bool `json:"rosenpassPermissive"`
|
||||
DisableNotifications bool `json:"disableNotifications"`
|
||||
LazyConnectionEnabled bool `json:"lazyConnectionEnabled"`
|
||||
BlockInbound bool `json:"blockInbound"`
|
||||
NetworkMonitor bool `json:"networkMonitor"`
|
||||
DisableClientRoutes bool `json:"disableClientRoutes"`
|
||||
DisableServerRoutes bool `json:"disableServerRoutes"`
|
||||
DisableDNS bool `json:"disableDns"`
|
||||
DisableIPv6 bool `json:"disableIpv6"`
|
||||
BlockLANAccess bool `json:"blockLanAccess"`
|
||||
EnableSSHRoot bool `json:"enableSshRoot"`
|
||||
EnableSSHSFTP bool `json:"enableSshSftp"`
|
||||
EnableSSHLocalPortForwarding bool `json:"enableSshLocalPortForwarding"`
|
||||
EnableSSHRemotePortForwarding bool `json:"enableSshRemotePortForwarding"`
|
||||
DisableSSHAuth bool `json:"disableSshAuth"`
|
||||
SSHJWTCacheTTL int32 `json:"sshJwtCacheTtl"`
|
||||
}
|
||||
|
||||
// SetConfigParams is a partial update — only non-nil pointer fields are sent
|
||||
// to the daemon; nil fields are preserved.
|
||||
type SetConfigParams struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
Username string `json:"username"`
|
||||
ManagementURL string `json:"managementUrl"`
|
||||
AdminURL string `json:"adminUrl"`
|
||||
InterfaceName *string `json:"interfaceName,omitempty"`
|
||||
WireguardPort *int64 `json:"wireguardPort,omitempty"`
|
||||
MTU *int64 `json:"mtu,omitempty"`
|
||||
PreSharedKey *string `json:"preSharedKey,omitempty"`
|
||||
DisableAutoConnect *bool `json:"disableAutoConnect,omitempty"`
|
||||
ServerSSHAllowed *bool `json:"serverSshAllowed,omitempty"`
|
||||
RosenpassEnabled *bool `json:"rosenpassEnabled,omitempty"`
|
||||
RosenpassPermissive *bool `json:"rosenpassPermissive,omitempty"`
|
||||
DisableNotifications *bool `json:"disableNotifications,omitempty"`
|
||||
LazyConnectionEnabled *bool `json:"lazyConnectionEnabled,omitempty"`
|
||||
BlockInbound *bool `json:"blockInbound,omitempty"`
|
||||
NetworkMonitor *bool `json:"networkMonitor,omitempty"`
|
||||
DisableClientRoutes *bool `json:"disableClientRoutes,omitempty"`
|
||||
DisableServerRoutes *bool `json:"disableServerRoutes,omitempty"`
|
||||
DisableDNS *bool `json:"disableDns,omitempty"`
|
||||
DisableIPv6 *bool `json:"disableIpv6,omitempty"`
|
||||
DisableFirewall *bool `json:"disableFirewall,omitempty"`
|
||||
BlockLANAccess *bool `json:"blockLanAccess,omitempty"`
|
||||
EnableSSHRoot *bool `json:"enableSshRoot,omitempty"`
|
||||
EnableSSHSFTP *bool `json:"enableSshSftp,omitempty"`
|
||||
EnableSSHLocalPortForwarding *bool `json:"enableSshLocalPortForwarding,omitempty"`
|
||||
EnableSSHRemotePortForwarding *bool `json:"enableSshRemotePortForwarding,omitempty"`
|
||||
DisableSSHAuth *bool `json:"disableSshAuth,omitempty"`
|
||||
SSHJWTCacheTTL *int32 `json:"sshJwtCacheTtl,omitempty"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
conn DaemonConn
|
||||
}
|
||||
|
||||
func NewSettings(conn DaemonConn) *Settings {
|
||||
return &Settings{conn: conn}
|
||||
}
|
||||
|
||||
func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
resp, err := cli.GetConfig(ctx, &proto.GetConfigRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
})
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return Config{
|
||||
ManagementURL: resp.GetManagementUrl(),
|
||||
AdminURL: resp.GetAdminURL(),
|
||||
ConfigFile: resp.GetConfigFile(),
|
||||
LogFile: resp.GetLogFile(),
|
||||
PreSharedKeySet: resp.GetPreSharedKey() != "",
|
||||
InterfaceName: resp.GetInterfaceName(),
|
||||
WireguardPort: resp.GetWireguardPort(),
|
||||
MTU: resp.GetMtu(),
|
||||
DisableAutoConnect: resp.GetDisableAutoConnect(),
|
||||
ServerSSHAllowed: resp.GetServerSSHAllowed(),
|
||||
RosenpassEnabled: resp.GetRosenpassEnabled(),
|
||||
RosenpassPermissive: resp.GetRosenpassPermissive(),
|
||||
DisableNotifications: resp.GetDisableNotifications(),
|
||||
LazyConnectionEnabled: resp.GetLazyConnectionEnabled(),
|
||||
BlockInbound: resp.GetBlockInbound(),
|
||||
NetworkMonitor: resp.GetNetworkMonitor(),
|
||||
DisableClientRoutes: resp.GetDisableClientRoutes(),
|
||||
DisableServerRoutes: resp.GetDisableServerRoutes(),
|
||||
DisableDNS: resp.GetDisableDns(),
|
||||
DisableIPv6: resp.GetDisableIpv6(),
|
||||
BlockLANAccess: resp.GetBlockLanAccess(),
|
||||
EnableSSHRoot: resp.GetEnableSSHRoot(),
|
||||
EnableSSHSFTP: resp.GetEnableSSHSFTP(),
|
||||
EnableSSHLocalPortForwarding: resp.GetEnableSSHLocalPortForwarding(),
|
||||
EnableSSHRemotePortForwarding: resp.GetEnableSSHRemotePortForwarding(),
|
||||
DisableSSHAuth: resp.GetDisableSSHAuth(),
|
||||
SSHJWTCacheTTL: resp.GetSshJWTCacheTTL(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req := &proto.SetConfigRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
ManagementUrl: p.ManagementURL,
|
||||
AdminURL: p.AdminURL,
|
||||
InterfaceName: p.InterfaceName,
|
||||
WireguardPort: p.WireguardPort,
|
||||
Mtu: p.MTU,
|
||||
OptionalPreSharedKey: p.PreSharedKey,
|
||||
DisableAutoConnect: p.DisableAutoConnect,
|
||||
ServerSSHAllowed: p.ServerSSHAllowed,
|
||||
RosenpassEnabled: p.RosenpassEnabled,
|
||||
RosenpassPermissive: p.RosenpassPermissive,
|
||||
DisableNotifications: p.DisableNotifications,
|
||||
LazyConnectionEnabled: p.LazyConnectionEnabled,
|
||||
BlockInbound: p.BlockInbound,
|
||||
NetworkMonitor: p.NetworkMonitor,
|
||||
DisableClientRoutes: p.DisableClientRoutes,
|
||||
DisableServerRoutes: p.DisableServerRoutes,
|
||||
DisableDns: p.DisableDNS,
|
||||
DisableIpv6: p.DisableIPv6,
|
||||
DisableFirewall: p.DisableFirewall,
|
||||
BlockLanAccess: p.BlockLANAccess,
|
||||
EnableSSHRoot: p.EnableSSHRoot,
|
||||
EnableSSHSFTP: p.EnableSSHSFTP,
|
||||
EnableSSHLocalPortForwarding: p.EnableSSHLocalPortForwarding,
|
||||
EnableSSHRemotePortForwarding: p.EnableSSHRemotePortForwarding,
|
||||
DisableSSHAuth: p.DisableSSHAuth,
|
||||
SshJWTCacheTTL: p.SSHJWTCacheTTL,
|
||||
}
|
||||
_, err = cli.SetConfig(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return Restrictions{}, err
|
||||
}
|
||||
active, err := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
|
||||
if err != nil {
|
||||
return Restrictions{}, fmt.Errorf("get active profile: %w", err)
|
||||
}
|
||||
cfgResp, err := cli.GetConfig(ctx, &proto.GetConfigRequest{
|
||||
ProfileName: active.GetId(),
|
||||
Username: active.GetUsername(),
|
||||
})
|
||||
if err != nil {
|
||||
return Restrictions{}, err
|
||||
}
|
||||
featResp, err := cli.GetFeatures(ctx, &proto.GetFeaturesRequest{})
|
||||
if err != nil {
|
||||
return Restrictions{}, err
|
||||
}
|
||||
r := Restrictions{
|
||||
Features: Features{
|
||||
DisableProfiles: featResp.GetDisableProfiles(),
|
||||
DisableNetworks: featResp.GetDisableNetworks(),
|
||||
DisableUpdateSettings: featResp.GetDisableUpdateSettings(),
|
||||
},
|
||||
}
|
||||
applyMDMRestrictions(&r.MDM, cfgResp)
|
||||
r.MDM.DisableAdvancedView = featResp.GetDisableAdvancedView()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) {
|
||||
managed := cfgResp.GetMDMManagedFields()
|
||||
if len(managed) == 0 {
|
||||
return
|
||||
}
|
||||
set := make(map[string]struct{}, len(managed))
|
||||
for _, k := range managed {
|
||||
set[k] = struct{}{}
|
||||
}
|
||||
v := reflect.ValueOf(mdm).Elem()
|
||||
t := v.Type()
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
if v.Field(i).Kind() != reflect.Bool {
|
||||
continue
|
||||
}
|
||||
if t.Field(i).Name == "DisableAdvancedView" {
|
||||
continue
|
||||
}
|
||||
if _, ok := set[t.Field(i).Tag.Get("json")]; ok {
|
||||
v.Field(i).SetBool(true)
|
||||
}
|
||||
}
|
||||
if _, ok := set["managementURL"]; ok {
|
||||
mdm.ManagementURL = cfgResp.GetManagementUrl()
|
||||
}
|
||||
if _, ok := set["allowServerSSH"]; ok {
|
||||
allowed := cfgResp.GetServerSSHAllowed()
|
||||
mdm.AllowServerSSH = &allowed
|
||||
}
|
||||
}
|
||||
36
client/ui/services/uilog.go
Normal file
36
client/ui/services/uilog.go
Normal file
@@ -0,0 +1,36 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// UILog forwards frontend console output into logrus, tagging the JS origin
|
||||
// as the "ui" field to stay distinct from logrus's Go-caller source.
|
||||
type UILog struct{}
|
||||
|
||||
func NewUILog() *UILog { return &UILog{} }
|
||||
|
||||
// Log maps an unrecognised level to info; empty source becomes "unknown".
|
||||
func (s *UILog) Log(_ context.Context, level, source, msg string) {
|
||||
origin := "unknown"
|
||||
if source != "" {
|
||||
origin = source
|
||||
}
|
||||
entry := log.WithField("ui", origin)
|
||||
switch level {
|
||||
case "trace":
|
||||
entry.Trace(msg)
|
||||
case "debug":
|
||||
entry.Debug(msg)
|
||||
case "warn", "warning":
|
||||
entry.Warn(msg)
|
||||
case "error":
|
||||
entry.Error(msg)
|
||||
default:
|
||||
entry.Info(msg)
|
||||
}
|
||||
}
|
||||
73
client/ui/services/update.go
Normal file
73
client/ui/services/update.go
Normal file
@@ -0,0 +1,73 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/updater"
|
||||
)
|
||||
|
||||
// UpdateResult mirrors TriggerUpdateResponse.
|
||||
type UpdateResult struct {
|
||||
Success bool `json:"success"`
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
}
|
||||
|
||||
// Update is the Wails-bound facade over the daemon's update RPCs. The state
|
||||
// machine and push event live in client/ui/updater.
|
||||
type Update struct {
|
||||
conn DaemonConn
|
||||
holder *updater.Holder
|
||||
}
|
||||
|
||||
func NewUpdate(conn DaemonConn, holder *updater.Holder) *Update {
|
||||
return &Update{conn: conn, holder: holder}
|
||||
}
|
||||
|
||||
func (s *Update) GetState() updater.State {
|
||||
return s.holder.Get()
|
||||
}
|
||||
|
||||
// Quit exits the app. Scheduled off the calling goroutine so the JS caller's
|
||||
// response returns before the runtime tears down.
|
||||
func (s *Update) Quit() {
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
application.Get().Quit()
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Update) Trigger(ctx context.Context) (UpdateResult, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return UpdateResult{}, err
|
||||
}
|
||||
resp, err := cli.TriggerUpdate(ctx, &proto.TriggerUpdateRequest{})
|
||||
if err != nil {
|
||||
return UpdateResult{}, err
|
||||
}
|
||||
return UpdateResult{
|
||||
Success: resp.GetSuccess(),
|
||||
ErrorMsg: resp.GetErrorMsg(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Update) GetInstallerResult(ctx context.Context) (UpdateResult, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return UpdateResult{}, err
|
||||
}
|
||||
resp, err := cli.GetInstallerResult(ctx, &proto.InstallerResultRequest{})
|
||||
if err != nil {
|
||||
return UpdateResult{}, err
|
||||
}
|
||||
return UpdateResult{
|
||||
Success: resp.GetSuccess(),
|
||||
ErrorMsg: resp.GetErrorMsg(),
|
||||
}, nil
|
||||
}
|
||||
22
client/ui/services/version.go
Normal file
22
client/ui/services/version.go
Normal file
@@ -0,0 +1,22 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// Version reports only the GUI's own version; the daemon version comes from
|
||||
// the status feed's DaemonVersion field.
|
||||
type Version struct{}
|
||||
|
||||
func NewVersion() *Version {
|
||||
return &Version{}
|
||||
}
|
||||
|
||||
// GUI returns the UI binary's version, stamped via ldflags ("development" if un-stamped).
|
||||
func (v *Version) GUI(_ context.Context) string {
|
||||
return version.NetbirdVersion()
|
||||
}
|
||||
576
client/ui/services/windowmanager.go
Normal file
576
client/ui/services/windowmanager.go
Normal file
@@ -0,0 +1,576 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// LanguageSubscriber delivers UI preference changes so window titles follow the language.
|
||||
type LanguageSubscriber interface {
|
||||
Subscribe() (<-chan preferences.UIPreferences, func())
|
||||
}
|
||||
|
||||
// EventTriggerLogin asks the frontend's startLogin() to begin an SSO flow.
|
||||
const EventTriggerLogin = "trigger-login"
|
||||
|
||||
// EventBrowserLoginCancel signals the user dismissed the BrowserLogin popup.
|
||||
const EventBrowserLoginCancel = "browser-login:cancel"
|
||||
|
||||
// EventSettingsOpen tells the mounted settings window which tab to show.
|
||||
const EventSettingsOpen = "netbird:settings:open"
|
||||
|
||||
var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950
|
||||
|
||||
// WindowHeight is shared by the main and Settings windows.
|
||||
const WindowHeight = 660
|
||||
|
||||
// Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed).
|
||||
var microsoftWindowsTheme = &application.WindowTheme{
|
||||
BorderColour: u32ptr(0x00211E1C),
|
||||
TitleBarColour: u32ptr(0x00211E1C),
|
||||
TitleTextColour: u32ptr(0x00E9E7E4),
|
||||
}
|
||||
|
||||
// MicrosoftWindowsAppearanceOptions is the shared Windows chrome (Mica + dark + custom title bar).
|
||||
func MicrosoftWindowsAppearanceOptions() application.WindowsWindow {
|
||||
return application.WindowsWindow{
|
||||
BackdropType: application.Mica,
|
||||
Theme: application.Dark,
|
||||
CustomTheme: application.ThemeSettings{
|
||||
DarkModeActive: microsoftWindowsTheme,
|
||||
DarkModeInactive: microsoftWindowsTheme,
|
||||
LightModeActive: microsoftWindowsTheme,
|
||||
LightModeInactive: microsoftWindowsTheme,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AppleMacOSAppearanceOptions is the shared macOS chrome; FullScreenNone keeps the fixed-size layout.
|
||||
func AppleMacOSAppearanceOptions() application.MacWindow {
|
||||
return application.MacWindow{
|
||||
InvisibleTitleBarHeight: 38,
|
||||
Backdrop: application.MacBackdropNormal,
|
||||
TitleBar: application.MacTitleBarHiddenInset,
|
||||
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
|
||||
}
|
||||
}
|
||||
|
||||
// LinuxAppearanceOptions is the shared Linux chrome; opaque so fake-translucency compositors paint it.
|
||||
func LinuxAppearanceOptions(icon []byte) application.LinuxWindow {
|
||||
return application.LinuxWindow{
|
||||
Icon: icon,
|
||||
WindowIsTranslucent: false,
|
||||
}
|
||||
}
|
||||
|
||||
// DialogWindowOptions is the baseline for every auxiliary dialog window; callers override per-dialog.
|
||||
func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.WebviewWindowOptions {
|
||||
return application.WebviewWindowOptions{
|
||||
Name: name,
|
||||
Title: title,
|
||||
Width: 360,
|
||||
Height: 320,
|
||||
DisableResize: true,
|
||||
AlwaysOnTop: true,
|
||||
Hidden: true,
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
CloseButtonState: application.ButtonEnabled,
|
||||
BackgroundColour: WindowBackgroundColour,
|
||||
URL: url,
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Linux: LinuxAppearanceOptions(linuxIcon),
|
||||
}
|
||||
}
|
||||
|
||||
// WindowManager owns the auxiliary windows (main is created in main.go). Settings is created
|
||||
// eagerly and hidden on close to keep React state; the rest are created on open, destroyed on
|
||||
// close, so the macOS dock-reopen handler finds no hidden window to resurrect.
|
||||
type WindowManager struct {
|
||||
app *application.App
|
||||
mainWindow *application.WebviewWindow
|
||||
translator ErrorTranslator
|
||||
prefs LanguagePreference
|
||||
linuxIcon []byte
|
||||
settings *application.WebviewWindow
|
||||
browserLogin *application.WebviewWindow
|
||||
sessionExpiration *application.WebviewWindow
|
||||
installProgress *application.WebviewWindow
|
||||
welcome *application.WebviewWindow
|
||||
errorDialog *application.WebviewWindow
|
||||
// hiddenForLogin holds windows hidden while the BrowserLogin popup is open, restored on close.
|
||||
hiddenForLogin []application.Window
|
||||
mu sync.Mutex
|
||||
// recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor
|
||||
// restores position; nil on full desktops so re-centering can't fight a user-moved window.
|
||||
recenterOnShow func() bool
|
||||
}
|
||||
|
||||
// NewWindowManager wires the manager to the main app; translator/prefs may be nil (tests). The
|
||||
// Settings window is created here (hidden) so the first OpenSettings is instant.
|
||||
func NewWindowManager(app *application.App, mainWindow *application.WebviewWindow, translator ErrorTranslator, prefs LanguagePreference, linuxIcon []byte) *WindowManager {
|
||||
s := &WindowManager{app: app, mainWindow: mainWindow, translator: translator, prefs: prefs, linuxIcon: linuxIcon}
|
||||
// Re-title live windows on language flip. Wired internally so the binding generator
|
||||
// doesn't try to expose the interface param.
|
||||
if sub, ok := prefs.(LanguageSubscriber); ok && sub != nil {
|
||||
ch, _ := sub.Subscribe()
|
||||
go func() {
|
||||
var last i18n.LanguageCode
|
||||
for p := range ch {
|
||||
if p.Language == "" || p.Language == last {
|
||||
continue
|
||||
}
|
||||
last = p.Language
|
||||
s.retitleAll()
|
||||
}
|
||||
}()
|
||||
}
|
||||
s.settings = app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: "settings",
|
||||
Title: s.title("window.title.settings"),
|
||||
Width: 900,
|
||||
Height: WindowHeight,
|
||||
Hidden: true,
|
||||
DisableResize: true,
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
CloseButtonState: application.ButtonEnabled,
|
||||
BackgroundColour: WindowBackgroundColour,
|
||||
URL: "/#/settings",
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Linux: LinuxAppearanceOptions(linuxIcon),
|
||||
})
|
||||
// Hide (not destroy) on close to keep React state; reset to General for a flash-free reopen.
|
||||
s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
|
||||
e.Cancel()
|
||||
s.app.Event.Emit(EventSettingsOpen, "general")
|
||||
s.settings.Hide()
|
||||
})
|
||||
return s
|
||||
}
|
||||
|
||||
// OpenSettings shows the settings window on tab (empty → General), switching tab via
|
||||
// EventSettingsOpen rather than SetURL (which would remount the provider tree).
|
||||
func (s *WindowManager) OpenSettings(tab string) {
|
||||
target := tab
|
||||
if target == "" {
|
||||
target = "general"
|
||||
}
|
||||
s.app.Event.Emit(EventSettingsOpen, target)
|
||||
s.settings.Show()
|
||||
s.settings.Focus()
|
||||
// Re-center (minimal-WM only; see centerWhenReady).
|
||||
s.centerWhenReady(s.settings)
|
||||
}
|
||||
|
||||
// OpenBrowserLogin shows the SSO popup, creating it on first use.
|
||||
func (s *WindowManager) OpenBrowserLogin(uri string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.browserLogin == nil {
|
||||
startURL := "/#/dialog/browser-login"
|
||||
if uri != "" {
|
||||
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
|
||||
}
|
||||
s.hideOtherWindowsLocked("browser-login")
|
||||
// Prefer the main window's screen (multi-monitor); falls back to OS-default centering.
|
||||
var screen *application.Screen
|
||||
if s.mainWindow != nil {
|
||||
if sc, err := s.mainWindow.GetScreen(); err == nil {
|
||||
screen = sc
|
||||
}
|
||||
}
|
||||
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon)
|
||||
// Not always-on-top: it would obscure the browser tab the user logs in through.
|
||||
opts.AlwaysOnTop = false
|
||||
opts.InitialPosition = application.WindowCentered
|
||||
opts.Screen = screen
|
||||
s.browserLogin = s.app.Window.NewWithOptions(opts)
|
||||
bl := s.browserLogin
|
||||
// Red-X close means cancel: emit the event so startLogin() tears down the SSO wait.
|
||||
bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.app.Event.Emit(EventBrowserLoginCancel)
|
||||
s.mu.Lock()
|
||||
s.browserLogin = nil
|
||||
s.restoreHiddenWindowsLocked()
|
||||
s.mu.Unlock()
|
||||
})
|
||||
s.centerWhenReady(s.browserLogin)
|
||||
return
|
||||
}
|
||||
if uri != "" {
|
||||
s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri))
|
||||
}
|
||||
s.browserLogin.Show()
|
||||
s.browserLogin.Focus()
|
||||
s.centerWhenReady(s.browserLogin)
|
||||
}
|
||||
|
||||
// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the
|
||||
// app's focal window: tray "Open" and dock activation hand off to it, not the main window.
|
||||
func (s *WindowManager) BrowserLoginWindow() *application.WebviewWindow {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.browserLogin
|
||||
}
|
||||
|
||||
// InstallProgressWindow returns the live install-progress window, or nil. Same focal-window
|
||||
// contract as BrowserLoginWindow; install supersedes everything, so check this first.
|
||||
func (s *WindowManager) InstallProgressWindow() *application.WebviewWindow {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.installProgress
|
||||
}
|
||||
|
||||
func (s *WindowManager) CloseBrowserLogin() {
|
||||
s.mu.Lock()
|
||||
w := s.browserLogin
|
||||
s.browserLogin = nil
|
||||
s.mu.Unlock()
|
||||
if w != nil {
|
||||
w.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// OpenSessionExpiration shows the countdown warning on the cursor's display; seconds seeds
|
||||
// the countdown. Singleton, destroyed on close.
|
||||
func (s *WindowManager) OpenSessionExpiration(seconds int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
startURL := "/#/dialog/session-expiration?seconds=" + strconv.Itoa(seconds)
|
||||
if s.sessionExpiration == nil {
|
||||
opts := DialogWindowOptions("session-expiration", s.title("window.title.sessionExpiration"), startURL, s.linuxIcon)
|
||||
opts.Screen = s.getScreenBasedOnCursorPosition()
|
||||
opts.InitialPosition = application.WindowCentered
|
||||
s.sessionExpiration = s.app.Window.NewWithOptions(opts)
|
||||
s.sessionExpiration.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
s.sessionExpiration = nil
|
||||
s.mu.Unlock()
|
||||
})
|
||||
s.centerOnCursorScreen(s.sessionExpiration)
|
||||
return
|
||||
}
|
||||
s.sessionExpiration.SetURL(startURL)
|
||||
s.centerOnCursorScreen(s.sessionExpiration)
|
||||
s.sessionExpiration.Show()
|
||||
s.sessionExpiration.Focus()
|
||||
}
|
||||
|
||||
func (s *WindowManager) CloseSessionExpiration() {
|
||||
s.mu.Lock()
|
||||
w := s.sessionExpiration
|
||||
s.sessionExpiration = nil
|
||||
s.mu.Unlock()
|
||||
if w != nil {
|
||||
w.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// OpenInstallProgress shows the install-progress window and hides the rest for the duration
|
||||
// (restored on close). It owns its own result polling since the daemon restarts mid-install.
|
||||
func (s *WindowManager) OpenInstallProgress(version string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
startURL := "/#/dialog/install-progress"
|
||||
if version != "" {
|
||||
startURL = "/#/dialog/install-progress?version=" + url.QueryEscape(version)
|
||||
}
|
||||
if s.installProgress == nil {
|
||||
s.hideOtherWindowsLocked("install-progress")
|
||||
s.installProgress = s.app.Window.NewWithOptions(
|
||||
DialogWindowOptions("install-progress", s.title("window.title.updating"), startURL, s.linuxIcon),
|
||||
)
|
||||
s.installProgress.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
s.installProgress = nil
|
||||
s.restoreHiddenWindowsLocked()
|
||||
s.mu.Unlock()
|
||||
})
|
||||
s.centerWhenReady(s.installProgress)
|
||||
return
|
||||
}
|
||||
s.installProgress.SetURL(startURL)
|
||||
s.installProgress.Show()
|
||||
s.installProgress.Focus()
|
||||
s.centerWhenReady(s.installProgress)
|
||||
}
|
||||
|
||||
func (s *WindowManager) CloseInstallProgress() {
|
||||
s.mu.Lock()
|
||||
w := s.installProgress
|
||||
s.installProgress = nil
|
||||
s.mu.Unlock()
|
||||
if w != nil {
|
||||
w.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// OpenWelcome shows the first-launch onboarding window. Singleton, destroyed on close.
|
||||
func (s *WindowManager) OpenWelcome() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.welcome == nil {
|
||||
opts := DialogWindowOptions("welcome", s.title("window.title.welcome"), "/#/dialog/welcome", s.linuxIcon)
|
||||
opts.Width = 420
|
||||
opts.InitialPosition = application.WindowCentered
|
||||
s.welcome = s.app.Window.NewWithOptions(opts)
|
||||
w := s.welcome
|
||||
w.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
s.welcome = nil
|
||||
s.mu.Unlock()
|
||||
})
|
||||
s.centerWhenReady(s.welcome)
|
||||
return
|
||||
}
|
||||
s.welcome.Show()
|
||||
s.welcome.Focus()
|
||||
s.centerWhenReady(s.welcome)
|
||||
}
|
||||
|
||||
func (s *WindowManager) CloseWelcome() {
|
||||
s.mu.Lock()
|
||||
w := s.welcome
|
||||
s.welcome = nil
|
||||
s.mu.Unlock()
|
||||
if w != nil {
|
||||
w.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// OpenError shows the custom error dialog; title/message are pre-localised and ride in the
|
||||
// start URL. A second error replaces the open one via SetURL. Singleton, destroyed on close.
|
||||
func (s *WindowManager) OpenError(title, message string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
startURL := errorDialogURL(title, message)
|
||||
if s.errorDialog == nil {
|
||||
s.errorDialog = s.app.Window.NewWithOptions(
|
||||
DialogWindowOptions("error", s.title("window.title.error"), startURL, s.linuxIcon),
|
||||
)
|
||||
s.errorDialog.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
s.mu.Lock()
|
||||
s.errorDialog = nil
|
||||
s.mu.Unlock()
|
||||
})
|
||||
s.centerWhenReady(s.errorDialog)
|
||||
return
|
||||
}
|
||||
s.errorDialog.SetURL(startURL)
|
||||
s.errorDialog.Show()
|
||||
s.errorDialog.Focus()
|
||||
s.centerWhenReady(s.errorDialog)
|
||||
}
|
||||
|
||||
func (s *WindowManager) CloseError() {
|
||||
s.mu.Lock()
|
||||
w := s.errorDialog
|
||||
s.errorDialog = nil
|
||||
s.mu.Unlock()
|
||||
if w != nil {
|
||||
w.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// OpenMain brings the main window forward; the welcome handoff uses it instead of the tray.
|
||||
func (s *WindowManager) OpenMain() {
|
||||
s.ShowMain()
|
||||
}
|
||||
|
||||
// ShowMain brings the main window forward (re-centering on minimal WMs). The single entry
|
||||
// point every surface (tray, SIGUSR1, welcome) should use so centering applies uniformly.
|
||||
func (s *WindowManager) ShowMain() {
|
||||
if s.mainWindow == nil {
|
||||
return
|
||||
}
|
||||
s.mainWindow.Show()
|
||||
s.mainWindow.Focus()
|
||||
// Re-center (minimal-WM only; see centerWhenReady).
|
||||
s.centerWhenReady(s.mainWindow)
|
||||
}
|
||||
|
||||
// SetRecenterOnShow installs the recenterOnShow predicate (see the field).
|
||||
func (s *WindowManager) SetRecenterOnShow(pred func() bool) {
|
||||
s.recenterOnShow = pred
|
||||
}
|
||||
|
||||
// centerWhenReady centers w only on minimal WMs (recenterOnShow); elsewhere it
|
||||
// returns so it never fights a user-moved window. On GTK4 an inline Center()
|
||||
// no-ops until the GdkSurface is realized (async, after Show) and InvokeAsync
|
||||
// would deadlock, so a background goroutine retries until Position is non-zero,
|
||||
// bounded so a window genuinely at the origin can't spin forever.
|
||||
func (s *WindowManager) centerWhenReady(w *application.WebviewWindow) {
|
||||
if w == nil || s.recenterOnShow == nil || !s.recenterOnShow() {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for i := 0; i < 50; i++ { // ~1s budget at 20ms steps
|
||||
w.Center()
|
||||
if x, y := w.Position(); x != 0 || y != 0 {
|
||||
return // surface realized
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// centerOnCursorScreen centers w on the cursor's display; guards no-op on headless sessions.
|
||||
// On minimal WMs it uses the same realize-detection retry loop as centerWhenReady.
|
||||
func (s *WindowManager) centerOnCursorScreen(w *application.WebviewWindow) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
place := func() {
|
||||
screen := s.getScreenBasedOnCursorPosition()
|
||||
if screen == nil {
|
||||
return
|
||||
}
|
||||
width, height := w.Size()
|
||||
if width <= 0 || height <= 0 {
|
||||
return
|
||||
}
|
||||
wa := screen.WorkArea
|
||||
if wa.Width <= 0 || wa.Height <= 0 {
|
||||
return
|
||||
}
|
||||
w.SetPosition(wa.X+(wa.Width-width)/2, wa.Y+(wa.Height-height)/2)
|
||||
}
|
||||
place()
|
||||
if s.recenterOnShow == nil || !s.recenterOnShow() {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for i := 0; i < 50; i++ {
|
||||
place()
|
||||
if x, y := w.Position(); x != 0 || y != 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// title resolves a window-title i18n key in the current language, or the raw key if unavailable.
|
||||
func (s *WindowManager) title(key string) string {
|
||||
if s.translator == nil {
|
||||
return key
|
||||
}
|
||||
lang := i18n.DefaultLanguage
|
||||
if s.prefs != nil {
|
||||
if pref := s.prefs.Get().Language; pref != "" {
|
||||
lang = pref
|
||||
}
|
||||
}
|
||||
return s.translator.Translate(lang, key)
|
||||
}
|
||||
|
||||
// retitleAll re-applies the localised title to every live auxiliary window. Pointers are
|
||||
// snapshotted under s.mu; SetTitle is then safe to call after releasing the lock.
|
||||
func (s *WindowManager) retitleAll() {
|
||||
s.mu.Lock()
|
||||
type pair struct {
|
||||
win *application.WebviewWindow
|
||||
key string
|
||||
}
|
||||
wins := []pair{
|
||||
{s.settings, "window.title.settings"},
|
||||
{s.browserLogin, "window.title.signIn"},
|
||||
{s.sessionExpiration, "window.title.sessionExpiration"},
|
||||
{s.installProgress, "window.title.updating"},
|
||||
{s.welcome, "window.title.welcome"},
|
||||
{s.errorDialog, "window.title.error"},
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, p := range wins {
|
||||
if p.win != nil {
|
||||
p.win.SetTitle(s.title(p.key))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hideOtherWindowsLocked hides every visible window except keepName, recording
|
||||
// them in hiddenForLogin for restoreHiddenWindowsLocked. Caller must hold s.mu.
|
||||
func (s *WindowManager) hideOtherWindowsLocked(keepName string) {
|
||||
for _, w := range s.app.Window.GetAll() {
|
||||
if w == nil || w.Name() == keepName {
|
||||
continue
|
||||
}
|
||||
if !w.IsVisible() {
|
||||
continue
|
||||
}
|
||||
w.Hide()
|
||||
s.hiddenForLogin = append(s.hiddenForLogin, w)
|
||||
}
|
||||
}
|
||||
|
||||
// restoreHiddenWindowsLocked re-shows windows hidden by hideOtherWindowsLocked
|
||||
// (caller holds s.mu). If the main window was among them, raiseToForeground
|
||||
// lifts it above the SSO browser, which still owns the foreground — a plain
|
||||
// Show/Focus would be demoted to a taskbar flash and leave it stranded behind.
|
||||
func (s *WindowManager) restoreHiddenWindowsLocked() {
|
||||
mainRestored := false
|
||||
for _, w := range s.hiddenForLogin {
|
||||
if w == nil {
|
||||
continue
|
||||
}
|
||||
w.Show()
|
||||
if w == s.mainWindow {
|
||||
mainRestored = true
|
||||
}
|
||||
}
|
||||
s.hiddenForLogin = nil
|
||||
if mainRestored && s.mainWindow != nil {
|
||||
raiseToForeground(s.mainWindow)
|
||||
}
|
||||
}
|
||||
|
||||
// getScreenBasedOnCursorPosition returns the cursor's display, falling back to the
|
||||
// main-window screen, then nil (OS-default placement).
|
||||
func (s *WindowManager) getScreenBasedOnCursorPosition() *application.Screen {
|
||||
if s.app == nil || s.app.Screen == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := getCursorPosition(s.app); ok {
|
||||
if sc := s.app.Screen.ScreenNearestDipPoint(p); sc != nil {
|
||||
return sc
|
||||
}
|
||||
}
|
||||
if s.mainWindow != nil {
|
||||
if sc, err := s.mainWindow.GetScreen(); err == nil {
|
||||
return sc
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// errorDialogURL builds the error window's start URL with title/message as escaped query params.
|
||||
func errorDialogURL(title, message string) string {
|
||||
q := url.Values{}
|
||||
if title != "" {
|
||||
q.Set("title", title)
|
||||
}
|
||||
if message != "" {
|
||||
q.Set("message", message)
|
||||
}
|
||||
startURL := "/#/dialog/error"
|
||||
if enc := q.Encode(); enc != "" {
|
||||
startURL += "?" + enc
|
||||
}
|
||||
return startURL
|
||||
}
|
||||
|
||||
// u32ptr returns a pointer to v, for the optional *uint32 Wails theme fields.
|
||||
func u32ptr(v uint32) *uint32 { return &v }
|
||||
Reference in New Issue
Block a user