mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-17 14:19:54 +00:00
Merge branch 'refs/heads/ui-refactor' into ui-refactor-ui
# Conflicts: # client/ui/frontend/src/screens/Profiles.tsx # client/ui/main.go
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.6
|
// protoc-gen-go v1.36.6
|
||||||
// protoc v7.34.1
|
// protoc v3.21.12
|
||||||
// source: daemon.proto
|
// source: daemon.proto
|
||||||
|
|
||||||
package proto
|
package proto
|
||||||
@@ -823,9 +823,15 @@ func (x *WaitSSOLoginResponse) GetEmail() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpRequest struct {
|
type UpRequest struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"`
|
ProfileName *string `protobuf:"bytes,1,opt,name=profileName,proto3,oneof" json:"profileName,omitempty"`
|
||||||
Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"`
|
Username *string `protobuf:"bytes,2,opt,name=username,proto3,oneof" json:"username,omitempty"`
|
||||||
|
// async instructs the daemon to start the connection attempt and return
|
||||||
|
// immediately without waiting for the engine to become ready. Status updates
|
||||||
|
// are delivered via the SubscribeStatus stream. When false (the default) the
|
||||||
|
// RPC blocks until the engine is running or gives up, which is the behaviour
|
||||||
|
// needed by the CLI.
|
||||||
|
Async bool `protobuf:"varint,4,opt,name=async,proto3" json:"async,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -874,6 +880,13 @@ func (x *UpRequest) GetUsername() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *UpRequest) GetAsync() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.Async
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
type UpResponse struct {
|
type UpResponse struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
@@ -6309,10 +6322,11 @@ const file_daemon_proto_rawDesc = "" +
|
|||||||
"\buserCode\x18\x01 \x01(\tR\buserCode\x12\x1a\n" +
|
"\buserCode\x18\x01 \x01(\tR\buserCode\x12\x1a\n" +
|
||||||
"\bhostname\x18\x02 \x01(\tR\bhostname\",\n" +
|
"\bhostname\x18\x02 \x01(\tR\bhostname\",\n" +
|
||||||
"\x14WaitSSOLoginResponse\x12\x14\n" +
|
"\x14WaitSSOLoginResponse\x12\x14\n" +
|
||||||
"\x05email\x18\x01 \x01(\tR\x05email\"v\n" +
|
"\x05email\x18\x01 \x01(\tR\x05email\"\x8c\x01\n" +
|
||||||
"\tUpRequest\x12%\n" +
|
"\tUpRequest\x12%\n" +
|
||||||
"\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" +
|
"\vprofileName\x18\x01 \x01(\tH\x00R\vprofileName\x88\x01\x01\x12\x1f\n" +
|
||||||
"\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01B\x0e\n" +
|
"\busername\x18\x02 \x01(\tH\x01R\busername\x88\x01\x01\x12\x14\n" +
|
||||||
|
"\x05async\x18\x04 \x01(\bR\x05asyncB\x0e\n" +
|
||||||
"\f_profileNameB\v\n" +
|
"\f_profileNameB\v\n" +
|
||||||
"\t_usernameJ\x04\b\x03\x10\x04\"\f\n" +
|
"\t_usernameJ\x04\b\x03\x10\x04\"\f\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
|
|||||||
@@ -233,6 +233,12 @@ message UpRequest {
|
|||||||
optional string profileName = 1;
|
optional string profileName = 1;
|
||||||
optional string username = 2;
|
optional string username = 2;
|
||||||
reserved 3;
|
reserved 3;
|
||||||
|
// async instructs the daemon to start the connection attempt and return
|
||||||
|
// immediately without waiting for the engine to become ready. Status updates
|
||||||
|
// are delivered via the SubscribeStatus stream. When false (the default) the
|
||||||
|
// RPC blocks until the engine is running or gives up, which is the behaviour
|
||||||
|
// needed by the CLI.
|
||||||
|
bool async = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message UpResponse {}
|
message UpResponse {}
|
||||||
|
|||||||
@@ -747,6 +747,9 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR
|
|||||||
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
|
||||||
|
|
||||||
s.mutex.Unlock()
|
s.mutex.Unlock()
|
||||||
|
if msg.GetAsync() {
|
||||||
|
return &proto.UpResponse{}, nil
|
||||||
|
}
|
||||||
return s.waitForUp(callerCtx)
|
return s.waitForUp(callerCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,6 +869,15 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
|
|||||||
// stuck at Connecting long after the user asked to disconnect.
|
// stuck at Connecting long after the user asked to disconnect.
|
||||||
internal.CtxGetState(s.rootCtx).Set(internal.StatusIdle)
|
internal.CtxGetState(s.rootCtx).Set(internal.StatusIdle)
|
||||||
|
|
||||||
|
// Clear stale management/signal errors so the next Up() (typically for a
|
||||||
|
// different profile) starts with a clean status snapshot. Without this,
|
||||||
|
// a managementError left over from a LoginFailed cycle persists in the
|
||||||
|
// statusRecorder and appears in the new profile's initial
|
||||||
|
// SubscribeStatus snapshot, making the new profile look like it also
|
||||||
|
// failed to log in.
|
||||||
|
s.statusRecorder.MarkManagementDisconnected(nil)
|
||||||
|
s.statusRecorder.MarkSignalDisconnected(nil)
|
||||||
|
|
||||||
return &proto.DownResponse{}, nil
|
return &proto.DownResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import * as Debug from "./debug.js";
|
|||||||
import * as Forwarding from "./forwarding.js";
|
import * as Forwarding from "./forwarding.js";
|
||||||
import * as Networks from "./networks.js";
|
import * as Networks from "./networks.js";
|
||||||
import * as Peers from "./peers.js";
|
import * as Peers from "./peers.js";
|
||||||
|
import * as ProfileSwitcher from "./profileswitcher.js";
|
||||||
import * as Profiles from "./profiles.js";
|
import * as Profiles from "./profiles.js";
|
||||||
import * as Settings from "./settings.js";
|
import * as Settings from "./settings.js";
|
||||||
import * as Update from "./update.js";
|
import * as Update from "./update.js";
|
||||||
@@ -16,6 +17,7 @@ export {
|
|||||||
Forwarding,
|
Forwarding,
|
||||||
Networks,
|
Networks,
|
||||||
Peers,
|
Peers,
|
||||||
|
ProfileSwitcher,
|
||||||
Profiles,
|
Profiles,
|
||||||
Settings,
|
Settings,
|
||||||
Update,
|
Update,
|
||||||
|
|||||||
@@ -755,6 +755,18 @@ export class Profile {
|
|||||||
"name": string;
|
"name": string;
|
||||||
"isActive": boolean;
|
"isActive": boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email is the account address associated with this profile, sourced from
|
||||||
|
* the per-profile state file written by the CLI after a successful SSO
|
||||||
|
* login (e.g. ~/Library/Application Support/netbird/default.state.json on
|
||||||
|
* macOS). The daemon always runs as root, so its getConfigDir() resolves to
|
||||||
|
* the root home directory and cannot reach the user-owned state file. The
|
||||||
|
* UI process runs as the logged-in user and can read it directly via
|
||||||
|
* profilemanager.ProfileManager, which is why the email is fetched here
|
||||||
|
* instead of being returned by the ListProfiles RPC.
|
||||||
|
*/
|
||||||
|
"email": string;
|
||||||
|
|
||||||
/** Creates a new Profile instance. */
|
/** Creates a new Profile instance. */
|
||||||
constructor($$source: Partial<Profile> = {}) {
|
constructor($$source: Partial<Profile> = {}) {
|
||||||
if (!("name" in $$source)) {
|
if (!("name" in $$source)) {
|
||||||
@@ -763,6 +775,9 @@ export class Profile {
|
|||||||
if (!("isActive" in $$source)) {
|
if (!("isActive" in $$source)) {
|
||||||
this["isActive"] = false;
|
this["isActive"] = false;
|
||||||
}
|
}
|
||||||
|
if (!("email" in $$source)) {
|
||||||
|
this["email"] = "";
|
||||||
|
}
|
||||||
|
|
||||||
Object.assign(this, $$source);
|
Object.assign(this, $$source);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProfileSwitcher encapsulates the full profile-switching reconnect policy so
|
||||||
|
* both the tray and the React frontend use identical logic.
|
||||||
|
*
|
||||||
|
* Reconnect policy:
|
||||||
|
*
|
||||||
|
* ┌─────────────────┬──────────────────────┬────────────────────────────────────┐
|
||||||
|
* │ Previous status │ Action │ Rationale │
|
||||||
|
* ├─────────────────┼──────────────────────┼────────────────────────────────────┤
|
||||||
|
* │ Connected │ Switch + Down + Up │ Reconnect with the new profile. │
|
||||||
|
* │ Connecting │ Switch + Down + Up │ Stop old retry loop, restart. │
|
||||||
|
* │ NeedsLogin │ Switch + Down │ Clear stale error; user logs in. │
|
||||||
|
* │ LoginFailed │ Switch + Down │ Clear stale error; user logs in. │
|
||||||
|
* │ SessionExpired │ Switch + Down │ Clear stale error; user logs in. │
|
||||||
|
* │ Idle │ Switch only │ User chose offline; don't connect. │
|
||||||
|
* └─────────────────┴──────────────────────┴────────────────────────────────────┘
|
||||||
|
* @module
|
||||||
|
*/
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore: Unused imports
|
||||||
|
import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "@wailsio/runtime";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore: Unused imports
|
||||||
|
import * as $models from "./models.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SwitchActive switches to the named profile applying the reconnect policy.
|
||||||
|
* All RPCs complete quickly: Up uses async mode so the daemon starts the
|
||||||
|
* connection attempt and returns immediately; status updates flow via the
|
||||||
|
* SubscribeStatus stream.
|
||||||
|
*/
|
||||||
|
export function SwitchActive(p: $models.ProfileRef): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(4025913103, p);
|
||||||
|
}
|
||||||
@@ -3,23 +3,24 @@ import { Plus, RefreshCw } from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
Profiles as ProfilesSvc,
|
Profiles as ProfilesSvc,
|
||||||
Connection,
|
Connection,
|
||||||
} from "@bindings/services";
|
ProfileSwitcher,
|
||||||
import type { Profile } from "@bindings/services/models.js";
|
} from "../../bindings/github.com/netbirdio/netbird/client/ui/services";
|
||||||
|
import type { Profile } from "../../bindings/github.com/netbirdio/netbird/client/ui/services/models.js";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
import { Input } from "../components/Input";
|
import { Input } from "../components/Input";
|
||||||
import { Card } from "../components/Card";
|
import { Card } from "../components/Card";
|
||||||
import { useProfile } from "@/modules/profile/ProfileContext.tsx";
|
|
||||||
|
|
||||||
export default function Profiles() {
|
export default function Profiles() {
|
||||||
const { username, loaded, refresh: refreshProfile, switchProfile } = useProfile();
|
const [username, setUsername] = useState("");
|
||||||
const [profiles, setProfiles] = useState<Profile[]>([]);
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!username) return;
|
|
||||||
try {
|
try {
|
||||||
const list = await ProfilesSvc.List(username);
|
const u = username || (await ProfilesSvc.Username());
|
||||||
|
if (!username) setUsername(u);
|
||||||
|
const list = await ProfilesSvc.List(u);
|
||||||
setProfiles(list);
|
setProfiles(list);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -28,13 +29,12 @@ export default function Profiles() {
|
|||||||
}, [username]);
|
}, [username]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loaded) refresh();
|
refresh();
|
||||||
}, [loaded, refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
const select = async (name: string) => {
|
const select = async (name: string) => {
|
||||||
try {
|
try {
|
||||||
await switchProfile(name);
|
await ProfileSwitcher.SwitchActive({ profileName: name, username });
|
||||||
await Connection.Up({ profileName: name, username });
|
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
@@ -54,7 +54,6 @@ export default function Profiles() {
|
|||||||
if (name === "default") return;
|
if (name === "default") return;
|
||||||
try {
|
try {
|
||||||
await ProfilesSvc.Remove({ profileName: name, username });
|
await ProfilesSvc.Remove({ profileName: name, username });
|
||||||
await refreshProfile();
|
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
|
|||||||
@@ -113,6 +113,18 @@ func main() {
|
|||||||
peers := services.NewPeers(conn, app.Event)
|
peers := services.NewPeers(conn, app.Event)
|
||||||
update := services.NewUpdate(conn)
|
update := services.NewUpdate(conn)
|
||||||
notifier := notifications.New()
|
notifier := notifications.New()
|
||||||
|
profileSwitcher := services.NewProfileSwitcher(profiles, connection, peers)
|
||||||
|
|
||||||
|
app.RegisterService(application.NewService(connection))
|
||||||
|
app.RegisterService(application.NewService(settings))
|
||||||
|
app.RegisterService(application.NewService(services.NewNetworks(conn)))
|
||||||
|
app.RegisterService(application.NewService(services.NewForwarding(conn)))
|
||||||
|
app.RegisterService(application.NewService(profiles))
|
||||||
|
app.RegisterService(application.NewService(services.NewDebug(conn)))
|
||||||
|
app.RegisterService(application.NewService(update))
|
||||||
|
app.RegisterService(application.NewService(peers))
|
||||||
|
app.RegisterService(application.NewService(notifier))
|
||||||
|
app.RegisterService(application.NewService(profileSwitcher))
|
||||||
|
|
||||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||||
Title: "NetBird",
|
Title: "NetBird",
|
||||||
@@ -165,12 +177,13 @@ func main() {
|
|||||||
startStatusNotifierWatcher()
|
startStatusNotifierWatcher()
|
||||||
|
|
||||||
tray = NewTray(app, window, TrayServices{
|
tray = NewTray(app, window, TrayServices{
|
||||||
Connection: connection,
|
Connection: connection,
|
||||||
Settings: settings,
|
Settings: settings,
|
||||||
Profiles: profiles,
|
Profiles: profiles,
|
||||||
Peers: peers,
|
Peers: peers,
|
||||||
Notifier: notifier,
|
Notifier: notifier,
|
||||||
Update: update,
|
Update: update,
|
||||||
|
ProfileSwitcher: profileSwitcher,
|
||||||
})
|
})
|
||||||
listenForShowSignal(context.Background(), tray)
|
listenForShowSignal(context.Background(), tray)
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,8 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req := &proto.UpRequest{}
|
// The UI always uses async mode: status updates flow via SubscribeStatus.
|
||||||
|
req := &proto.UpRequest{Async: true}
|
||||||
if p.ProfileName != "" {
|
if p.ProfileName != "" {
|
||||||
req.ProfileName = ptrStr(p.ProfileName)
|
req.ProfileName = ptrStr(p.ProfileName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,15 @@ const (
|
|||||||
// permission, etc.). Real daemon statuses come straight from
|
// permission, etc.). Real daemon statuses come straight from
|
||||||
// internal.Status* — none of those collide with this label.
|
// internal.Status* — none of those collide with this label.
|
||||||
StatusDaemonUnavailable = "DaemonUnavailable"
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Emitter is what peers.Watch needs from the host application: a simple
|
// Emitter is what peers.Watch needs from the host application: a simple
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os/user"
|
"os/user"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,6 +14,15 @@ import (
|
|||||||
type Profile struct {
|
type Profile struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
IsActive bool `json:"isActive"`
|
IsActive bool `json:"isActive"`
|
||||||
|
// Email is the account address associated with this profile, sourced from
|
||||||
|
// the per-profile state file written by the CLI after a successful SSO
|
||||||
|
// login (e.g. ~/Library/Application Support/netbird/default.state.json on
|
||||||
|
// macOS). The daemon always runs as root, so its getConfigDir() resolves to
|
||||||
|
// the root home directory and cannot reach the user-owned state file. The
|
||||||
|
// UI process runs as the logged-in user and can read it directly via
|
||||||
|
// profilemanager.ProfileManager, which is why the email is fetched here
|
||||||
|
// instead of being returned by the ListProfiles RPC.
|
||||||
|
Email string `json:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfileRef identifies a profile by name+username.
|
// ProfileRef identifies a profile by name+username.
|
||||||
@@ -55,9 +65,14 @@ func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
pm := profilemanager.NewProfileManager()
|
||||||
out := make([]Profile, 0, len(resp.GetProfiles()))
|
out := make([]Profile, 0, len(resp.GetProfiles()))
|
||||||
for _, p := range resp.GetProfiles() {
|
for _, p := range resp.GetProfiles() {
|
||||||
out = append(out, Profile{Name: p.GetName(), IsActive: p.GetIsActive()})
|
prof := Profile{Name: p.GetName(), IsActive: p.GetIsActive()}
|
||||||
|
if state, err := pm.GetProfileState(p.GetName()); err == nil {
|
||||||
|
prof.Email = state.Email
|
||||||
|
}
|
||||||
|
out = append(out, prof)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
78
client/ui/services/profileswitcher.go
Normal file
78
client/ui/services/profileswitcher.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
//go:build !android && !ios && !freebsd && !js
|
||||||
|
|
||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProfileSwitcher encapsulates the full profile-switching reconnect policy so
|
||||||
|
// both the tray and the React frontend use identical logic.
|
||||||
|
//
|
||||||
|
// Reconnect policy:
|
||||||
|
//
|
||||||
|
// ┌─────────────────┬──────────────────────┬────────────────────────────────────┐
|
||||||
|
// │ Previous status │ Action │ Rationale │
|
||||||
|
// ├─────────────────┼──────────────────────┼────────────────────────────────────┤
|
||||||
|
// │ Connected │ Switch + Down + Up │ Reconnect with the new profile. │
|
||||||
|
// │ Connecting │ Switch + Down + Up │ Stop old retry loop, restart. │
|
||||||
|
// │ NeedsLogin │ Switch + Down │ Clear stale error; user logs in. │
|
||||||
|
// │ LoginFailed │ Switch + Down │ Clear stale error; user logs in. │
|
||||||
|
// │ SessionExpired │ Switch + Down │ Clear stale error; user logs in. │
|
||||||
|
// │ Idle │ Switch only │ User chose offline; don't connect. │
|
||||||
|
// └─────────────────┴──────────────────────┴────────────────────────────────────┘
|
||||||
|
type ProfileSwitcher struct {
|
||||||
|
profiles *Profiles
|
||||||
|
connection *Connection
|
||||||
|
peers *Peers
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProfileSwitcher creates a ProfileSwitcher backed by the given services.
|
||||||
|
func NewProfileSwitcher(profiles *Profiles, connection *Connection, peers *Peers) *ProfileSwitcher {
|
||||||
|
return &ProfileSwitcher{profiles: profiles, connection: connection, peers: peers}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchActive switches to the named profile applying the reconnect policy.
|
||||||
|
// All RPCs complete quickly: Up uses async mode so the daemon starts the
|
||||||
|
// connection attempt and returns immediately; status updates flow via the
|
||||||
|
// SubscribeStatus stream.
|
||||||
|
func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error {
|
||||||
|
prevStatus := ""
|
||||||
|
if st, err := s.peers.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)
|
||||||
|
|
||||||
|
if err := s.profiles.Switch(ctx, p); err != nil {
|
||||||
|
return fmt.Errorf("switch profile %q: %w", p.ProfileName, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -75,25 +75,9 @@ const (
|
|||||||
notifyIDTrayError = "netbird-tray-error"
|
notifyIDTrayError = "netbird-tray-error"
|
||||||
notifyIDSessionExpired = "netbird-session-expired"
|
notifyIDSessionExpired = "netbird-session-expired"
|
||||||
|
|
||||||
// Daemon status strings mirroring internal.Status* — kept in sync
|
// statusError is a tray-only synthetic label used for the error icon;
|
||||||
// with client/internal/state.go.
|
// it does not come from the daemon and is not exported.
|
||||||
statusConnected = "Connected"
|
statusError = "Error"
|
||||||
statusConnecting = "Connecting"
|
|
||||||
statusIdle = "Idle"
|
|
||||||
statusError = "Error"
|
|
||||||
// Daemon status string for an SSO session that has expired and needs
|
|
||||||
// re-authentication. Mirrors internal.StatusSessionExpired.
|
|
||||||
statusSessionExpired = "SessionExpired"
|
|
||||||
// statusNeedsLogin is what the daemon publishes before the user has
|
|
||||||
// completed an SSO authentication on this profile. Mirrors
|
|
||||||
// internal.StatusNeedsLogin.
|
|
||||||
statusNeedsLogin = "NeedsLogin"
|
|
||||||
// statusLoginFailed is what the daemon publishes when a login attempt
|
|
||||||
// failed with a non-auth error (management unreachable, init error,
|
|
||||||
// etc.). The CLI groups it with NeedsLogin/SessionExpired and prompts
|
|
||||||
// the user to run "netbird up", so we mirror that here. Mirrors
|
|
||||||
// internal.StatusLoginFailed.
|
|
||||||
statusLoginFailed = "LoginFailed"
|
|
||||||
|
|
||||||
// External URLs.
|
// External URLs.
|
||||||
urlGitHubRepo = "https://github.com/netbirdio/netbird"
|
urlGitHubRepo = "https://github.com/netbirdio/netbird"
|
||||||
@@ -108,12 +92,13 @@ const (
|
|||||||
// linter's parameter-count threshold and so adding another service later
|
// linter's parameter-count threshold and so adding another service later
|
||||||
// is a one-line struct change instead of a NewTray signature break.
|
// is a one-line struct change instead of a NewTray signature break.
|
||||||
type TrayServices struct {
|
type TrayServices struct {
|
||||||
Connection *services.Connection
|
Connection *services.Connection
|
||||||
Settings *services.Settings
|
Settings *services.Settings
|
||||||
Profiles *services.Profiles
|
Profiles *services.Profiles
|
||||||
Peers *services.Peers
|
Peers *services.Peers
|
||||||
Notifier *notifications.NotificationService
|
Notifier *notifications.NotificationService
|
||||||
Update *services.Update
|
Update *services.Update
|
||||||
|
ProfileSwitcher *services.ProfileSwitcher
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tray struct {
|
type Tray struct {
|
||||||
@@ -128,7 +113,9 @@ type Tray struct {
|
|||||||
downItem *application.MenuItem
|
downItem *application.MenuItem
|
||||||
exitNodeItem *application.MenuItem
|
exitNodeItem *application.MenuItem
|
||||||
networksItem *application.MenuItem
|
networksItem *application.MenuItem
|
||||||
profileSubmenu *application.Menu
|
profileSubmenu *application.Menu
|
||||||
|
profileSubmenuItem *application.MenuItem
|
||||||
|
profileEmailItem *application.MenuItem
|
||||||
settingsItem *application.MenuItem
|
settingsItem *application.MenuItem
|
||||||
debugItem *application.MenuItem
|
debugItem *application.MenuItem
|
||||||
updateItem *application.MenuItem
|
updateItem *application.MenuItem
|
||||||
@@ -145,6 +132,7 @@ type Tray struct {
|
|||||||
notificationsEnabled bool
|
notificationsEnabled bool
|
||||||
activeProfile string
|
activeProfile string
|
||||||
activeUsername string
|
activeUsername string
|
||||||
|
switchCancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray {
|
func NewTray(app *application.App, window *application.WebviewWindow, svc TrayServices) *Tray {
|
||||||
@@ -220,6 +208,16 @@ func (t *Tray) buildMenu() *application.Menu {
|
|||||||
// has started — Menu.Update() is a no-op before app.running is true,
|
// has started — Menu.Update() is a no-op before app.running is true,
|
||||||
// so the initial fill is gated on the ApplicationStarted hook.
|
// so the initial fill is gated on the ApplicationStarted hook.
|
||||||
t.profileSubmenu = menu.AddSubmenu(menuProfiles)
|
t.profileSubmenu = menu.AddSubmenu(menuProfiles)
|
||||||
|
// profileSubmenuItem is the parent MenuItem whose label is the active
|
||||||
|
// profile name. AddSubmenu returns the child *Menu, so we retrieve the
|
||||||
|
// parent *MenuItem via FindByLabel immediately after insertion.
|
||||||
|
t.profileSubmenuItem = menu.FindByLabel(menuProfiles)
|
||||||
|
// profileEmailItem shows the account email of the active profile directly
|
||||||
|
// in the main menu, below the Profiles submenu — matching the behaviour of
|
||||||
|
// the legacy Fyne/systray UI. It is hidden until loadProfiles resolves a
|
||||||
|
// non-empty email for the active profile.
|
||||||
|
t.profileEmailItem = menu.Add("").SetEnabled(false)
|
||||||
|
t.profileEmailItem.SetHidden(true)
|
||||||
menu.AddSeparator()
|
menu.AddSeparator()
|
||||||
// Only the action that applies to the current state is visible: Connect
|
// Only the action that applies to the current state is visible: Connect
|
||||||
// when disconnected, Disconnect when connected. applyStatus swaps them on
|
// when disconnected, Disconnect when connected. applyStatus swaps them on
|
||||||
@@ -454,15 +452,15 @@ func (t *Tray) onUpdateProgress(ev *application.CustomEvent) {
|
|||||||
// otherwise spam Shell_NotifyIcon and the log.
|
// otherwise spam Shell_NotifyIcon and the log.
|
||||||
func (t *Tray) applyStatus(st services.Status) {
|
func (t *Tray) applyStatus(st services.Status) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
connected := strings.EqualFold(st.Status, statusConnected)
|
connected := strings.EqualFold(st.Status, services.StatusConnected)
|
||||||
iconChanged := connected != t.connected || st.Status != t.lastStatus
|
iconChanged := connected != t.connected || st.Status != t.lastStatus
|
||||||
// Detect the transition into SessionExpired: the daemon emits the
|
// Detect the transition into SessionExpired: the daemon emits the
|
||||||
// state on every Status snapshot for as long as the session stays
|
// state on every Status snapshot for as long as the session stays
|
||||||
// expired, so without this guard we would re-fire the notification
|
// expired, so without this guard we would re-fire the notification
|
||||||
// on every push. Mirrors the legacy Fyne client's sendNotification
|
// on every push. Mirrors the legacy Fyne client's sendNotification
|
||||||
// flag in onSessionExpire.
|
// flag in onSessionExpire.
|
||||||
sessionExpiredEnter := strings.EqualFold(st.Status, statusSessionExpired) &&
|
sessionExpiredEnter := strings.EqualFold(st.Status, services.StatusSessionExpired) &&
|
||||||
!strings.EqualFold(t.lastStatus, statusSessionExpired)
|
!strings.EqualFold(t.lastStatus, services.StatusSessionExpired)
|
||||||
daemonVersionChanged := st.DaemonVersion != "" && st.DaemonVersion != t.lastDaemonVersion
|
daemonVersionChanged := st.DaemonVersion != "" && st.DaemonVersion != t.lastDaemonVersion
|
||||||
t.connected = connected
|
t.connected = connected
|
||||||
t.lastStatus = st.Status
|
t.lastStatus = st.Status
|
||||||
@@ -477,11 +475,11 @@ func (t *Tray) applyStatus(st services.Status) {
|
|||||||
|
|
||||||
if iconChanged {
|
if iconChanged {
|
||||||
t.applyIcon()
|
t.applyIcon()
|
||||||
needsLogin := strings.EqualFold(st.Status, statusNeedsLogin) ||
|
needsLogin := strings.EqualFold(st.Status, services.StatusNeedsLogin) ||
|
||||||
strings.EqualFold(st.Status, statusSessionExpired) ||
|
strings.EqualFold(st.Status, services.StatusSessionExpired) ||
|
||||||
strings.EqualFold(st.Status, statusLoginFailed)
|
strings.EqualFold(st.Status, services.StatusLoginFailed)
|
||||||
daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable)
|
daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable)
|
||||||
connecting := strings.EqualFold(st.Status, statusConnecting)
|
connecting := strings.EqualFold(st.Status, services.StatusConnecting)
|
||||||
if t.statusItem != nil {
|
if t.statusItem != nil {
|
||||||
// When the daemon needs re-authentication the status row turns
|
// When the daemon needs re-authentication the status row turns
|
||||||
// into the actionable Login entry — Connect would only fail.
|
// into the actionable Login entry — Connect would only fail.
|
||||||
@@ -491,7 +489,7 @@ func (t *Tray) applyStatus(st services.Status) {
|
|||||||
switch {
|
switch {
|
||||||
case daemonUnavailable:
|
case daemonUnavailable:
|
||||||
label = menuStatusDaemonUnavailable
|
label = menuStatusDaemonUnavailable
|
||||||
case strings.EqualFold(st.Status, statusIdle):
|
case strings.EqualFold(st.Status, services.StatusIdle):
|
||||||
label = menuStatusDisconnected
|
label = menuStatusDisconnected
|
||||||
}
|
}
|
||||||
t.statusItem.SetLabel(label)
|
t.statusItem.SetLabel(label)
|
||||||
@@ -591,14 +589,14 @@ func (t *Tray) applyStatusIndicator(status string) {
|
|||||||
|
|
||||||
func statusIndicatorBitmap(status string) []byte {
|
func statusIndicatorBitmap(status string) []byte {
|
||||||
switch {
|
switch {
|
||||||
case strings.EqualFold(status, statusConnected):
|
case strings.EqualFold(status, services.StatusConnected):
|
||||||
return iconMenuDotConnected
|
return iconMenuDotConnected
|
||||||
case strings.EqualFold(status, statusConnecting):
|
case strings.EqualFold(status, services.StatusConnecting):
|
||||||
return iconMenuDotConnecting
|
return iconMenuDotConnecting
|
||||||
case strings.EqualFold(status, statusNeedsLogin),
|
case strings.EqualFold(status, services.StatusNeedsLogin),
|
||||||
strings.EqualFold(status, statusSessionExpired):
|
strings.EqualFold(status, services.StatusSessionExpired):
|
||||||
return iconMenuDotLogin
|
return iconMenuDotLogin
|
||||||
case strings.EqualFold(status, statusLoginFailed),
|
case strings.EqualFold(status, services.StatusLoginFailed),
|
||||||
strings.EqualFold(status, statusError):
|
strings.EqualFold(status, statusError):
|
||||||
return iconMenuDotError
|
return iconMenuDotError
|
||||||
case strings.EqualFold(status, services.StatusDaemonUnavailable):
|
case strings.EqualFold(status, services.StatusDaemonUnavailable):
|
||||||
@@ -636,12 +634,12 @@ func (t *Tray) iconForState() (icon, dark []byte) {
|
|||||||
statusLabel := t.lastStatus
|
statusLabel := t.lastStatus
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
connecting := strings.EqualFold(statusLabel, statusConnecting)
|
connecting := strings.EqualFold(statusLabel, services.StatusConnecting)
|
||||||
errored := strings.EqualFold(statusLabel, statusError) ||
|
errored := strings.EqualFold(statusLabel, statusError) ||
|
||||||
strings.EqualFold(statusLabel, services.StatusDaemonUnavailable)
|
strings.EqualFold(statusLabel, services.StatusDaemonUnavailable)
|
||||||
needsLogin := strings.EqualFold(statusLabel, statusNeedsLogin) ||
|
needsLogin := strings.EqualFold(statusLabel, services.StatusNeedsLogin) ||
|
||||||
strings.EqualFold(statusLabel, statusSessionExpired) ||
|
strings.EqualFold(statusLabel, services.StatusSessionExpired) ||
|
||||||
strings.EqualFold(statusLabel, statusLoginFailed)
|
strings.EqualFold(statusLabel, services.StatusLoginFailed)
|
||||||
|
|
||||||
if runtime.GOOS == "darwin" {
|
if runtime.GOOS == "darwin" {
|
||||||
switch {
|
switch {
|
||||||
@@ -736,11 +734,21 @@ func (t *Tray) loadProfiles() {
|
|||||||
|
|
||||||
log.Infof("tray loadProfiles: received %d profile(s) for user %q", len(profiles), username)
|
log.Infof("tray loadProfiles: received %d profile(s) for user %q", len(profiles), username)
|
||||||
t.profileSubmenu.Clear()
|
t.profileSubmenu.Clear()
|
||||||
|
var activeName, activeEmail string
|
||||||
for _, p := range profiles {
|
for _, p := range profiles {
|
||||||
name := p.Name
|
name := p.Name
|
||||||
active := p.IsActive
|
active := p.IsActive
|
||||||
log.Infof("tray loadProfiles: profile=%q active=%v", name, active)
|
log.Infof("tray loadProfiles: profile=%q active=%v", name, active)
|
||||||
item := t.profileSubmenu.AddCheckbox(name, active)
|
// Use Add instead of AddCheckbox: Wails auto-toggles a checkbox's
|
||||||
|
// checked state on click (before the OnClick handler fires), so with
|
||||||
|
// AddCheckbox both the old and the new profile would briefly show as
|
||||||
|
// checked while the switchProfile goroutine is running. A plain item
|
||||||
|
// with a "✓ " prefix avoids the race entirely.
|
||||||
|
label := name
|
||||||
|
if active {
|
||||||
|
label = "✓ " + name
|
||||||
|
}
|
||||||
|
item := t.profileSubmenu.Add(label)
|
||||||
item.OnClick(func(*application.Context) {
|
item.OnClick(func(*application.Context) {
|
||||||
log.Infof("tray profile click: profile=%q wasActive=%v", name, active)
|
log.Infof("tray profile click: profile=%q wasActive=%v", name, active)
|
||||||
if active {
|
if active {
|
||||||
@@ -748,6 +756,21 @@ func (t *Tray) loadProfiles() {
|
|||||||
}
|
}
|
||||||
t.switchProfile(name)
|
t.switchProfile(name)
|
||||||
})
|
})
|
||||||
|
if active {
|
||||||
|
activeName = name
|
||||||
|
activeEmail = p.Email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.profileSubmenuItem != nil && activeName != "" {
|
||||||
|
t.profileSubmenuItem.SetLabel(activeName)
|
||||||
|
}
|
||||||
|
if t.profileEmailItem != nil {
|
||||||
|
if activeEmail != "" {
|
||||||
|
t.profileEmailItem.SetLabel(fmt.Sprintf("(%s)", activeEmail))
|
||||||
|
t.profileEmailItem.SetHidden(false)
|
||||||
|
} else {
|
||||||
|
t.profileEmailItem.SetHidden(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Wails v3 alpha's submenu.Update() builds a fresh, detached NSMenu on
|
// Wails v3 alpha's submenu.Update() builds a fresh, detached NSMenu on
|
||||||
// darwin that never replaces the empty NSMenu attached to the parent
|
// darwin that never replaces the empty NSMenu attached to the parent
|
||||||
@@ -762,73 +785,35 @@ func (t *Tray) loadProfiles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// switchProfile runs the daemon RPC in a goroutine so the menu click
|
// switchProfile cancels any in-flight profile switch, then starts a new one.
|
||||||
// returns immediately, then reloads the submenu to move the checkmark.
|
// Cancelling the previous context aborts its in-flight gRPC calls (Down/Up)
|
||||||
//
|
// so rapid clicks always converge to the last selected profile.
|
||||||
// Reconnect policy by previous daemon status:
|
|
||||||
//
|
|
||||||
// ┌─────────────────┬──────────────────────┬───────────────────────────────────┐
|
|
||||||
// │ Previous status │ Tray action │ Rationale │
|
|
||||||
// ├─────────────────┼──────────────────────┼───────────────────────────────────┤
|
|
||||||
// │ Connected │ Switch + Down + Up │ Reconnect with the new profile. │
|
|
||||||
// │ Connecting │ Switch + Down + Up │ Stop the retry loop still dialing │
|
|
||||||
// │ │ │ the old management server, then │
|
|
||||||
// │ │ │ restart with new config. │
|
|
||||||
// │ Idle │ Switch only │ User chose to be offline; don't │
|
|
||||||
// │ │ │ silently flip the daemon online. │
|
|
||||||
// │ NeedsLogin │ Switch only │ Login needs interactive SSO; let │
|
|
||||||
// │ LoginFailed │ Switch only │ the user trigger the next step. │
|
|
||||||
// │ SessionExpired │ Switch only │ │
|
|
||||||
// └─────────────────┴──────────────────────┴───────────────────────────────────┘
|
|
||||||
//
|
|
||||||
// Rule of thumb: auto-reconnect only when the daemon was actively trying
|
|
||||||
// to be online (Connected or Connecting). Any other state is a deliberate
|
|
||||||
// waiting point — keep the user in control of the next action.
|
|
||||||
func (t *Tray) switchProfile(name string) {
|
func (t *Tray) switchProfile(name string) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
prevStatus := t.lastStatus
|
if t.switchCancel != nil {
|
||||||
|
t.switchCancel()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.switchCancel = cancel
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
wasActive := strings.EqualFold(prevStatus, statusConnected) ||
|
|
||||||
strings.EqualFold(prevStatus, statusConnecting)
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
username, err := t.svc.Profiles.Username()
|
username, err := t.svc.Profiles.Username()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorf("get current user: %v", err)
|
log.Errorf("tray switchProfile: get current user: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Infof("tray switchProfile: sending SwitchProfile RPC profile=%q user=%q prevStatus=%q wasActive=%v",
|
if err := t.svc.ProfileSwitcher.SwitchActive(ctx, services.ProfileRef{
|
||||||
name, username, prevStatus, wasActive)
|
|
||||||
if err := t.svc.Profiles.Switch(ctx, services.ProfileRef{
|
|
||||||
ProfileName: name,
|
ProfileName: name,
|
||||||
Username: username,
|
Username: username,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Errorf("tray switchProfile: SwitchProfile RPC failed profile=%q err=%v", name, err)
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Errorf("tray switchProfile: %v", err)
|
||||||
t.notifyError(fmt.Sprintf("Failed to switch to %s", name))
|
t.notifyError(fmt.Sprintf("Failed to switch to %s", name))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Infof("tray switchProfile: SwitchProfile RPC succeeded profile=%q", name)
|
|
||||||
|
|
||||||
if wasActive {
|
|
||||||
// Stop the in-flight (or established) connection that's still
|
|
||||||
// pointing at the previous profile's management server, then
|
|
||||||
// bring it back up against the new profile.
|
|
||||||
log.Infof("tray switchProfile: was active (%s), reconnecting with new profile %q", prevStatus, name)
|
|
||||||
if err := t.svc.Connection.Down(ctx); err != nil {
|
|
||||||
log.Errorf("tray switchProfile: Down failed: %v", err)
|
|
||||||
}
|
|
||||||
if err := t.svc.Connection.Up(ctx, services.UpParams{
|
|
||||||
ProfileName: name,
|
|
||||||
Username: username,
|
|
||||||
}); err != nil {
|
|
||||||
log.Errorf("tray switchProfile: Up failed: %v", err)
|
|
||||||
t.notifyError(fmt.Sprintf("Failed to reconnect with %s", name))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.loadProfiles()
|
t.loadProfiles()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user