diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 789d7c2b2..3d54f5735 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.6 -// protoc v7.34.1 +// protoc v3.21.12 // source: daemon.proto package proto @@ -823,9 +823,15 @@ func (x *WaitSSOLoginResponse) GetEmail() string { } type UpRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - 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"` + state protoimpl.MessageState `protogen:"open.v1"` + 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"` + // 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 sizeCache protoimpl.SizeCache } @@ -874,6 +880,13 @@ func (x *UpRequest) GetUsername() string { return "" } +func (x *UpRequest) GetAsync() bool { + if x != nil { + return x.Async + } + return false +} + type UpResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -6309,10 +6322,11 @@ const file_daemon_proto_rawDesc = "" + "\buserCode\x18\x01 \x01(\tR\buserCode\x12\x1a\n" + "\bhostname\x18\x02 \x01(\tR\bhostname\",\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" + "\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" + "\t_usernameJ\x04\b\x03\x10\x04\"\f\n" + "\n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 1676f255e..aa7b121a0 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -233,6 +233,12 @@ message UpRequest { optional string profileName = 1; optional string username = 2; 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 {} diff --git a/client/server/server.go b/client/server/server.go index 1daec9973..0bc6358e3 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -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) s.mutex.Unlock() + if msg.GetAsync() { + return &proto.UpResponse{}, nil + } 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. 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 } diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts index 9420137d6..d1ed09bda 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/index.ts @@ -6,6 +6,7 @@ import * as Debug from "./debug.js"; import * as Forwarding from "./forwarding.js"; import * as Networks from "./networks.js"; import * as Peers from "./peers.js"; +import * as ProfileSwitcher from "./profileswitcher.js"; import * as Profiles from "./profiles.js"; import * as Settings from "./settings.js"; import * as Update from "./update.js"; @@ -16,6 +17,7 @@ export { Forwarding, Networks, Peers, + ProfileSwitcher, Profiles, Settings, Update, diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/models.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/models.ts index d91da7d75..d561338bf 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/models.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/models.ts @@ -755,6 +755,18 @@ export class Profile { "name": string; "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. */ constructor($$source: Partial = {}) { if (!("name" in $$source)) { @@ -763,6 +775,9 @@ export class Profile { if (!("isActive" in $$source)) { this["isActive"] = false; } + if (!("email" in $$source)) { + this["email"] = ""; + } Object.assign(this, $$source); } diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/profileswitcher.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/profileswitcher.ts new file mode 100644 index 000000000..0f42d6f49 --- /dev/null +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/profileswitcher.ts @@ -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 { + return $Call.ByID(4025913103, p); +} diff --git a/client/ui/frontend/src/screens/Profiles.tsx b/client/ui/frontend/src/screens/Profiles.tsx index 5c9204480..5f66e7c20 100644 --- a/client/ui/frontend/src/screens/Profiles.tsx +++ b/client/ui/frontend/src/screens/Profiles.tsx @@ -3,23 +3,24 @@ import { Plus, RefreshCw } from "lucide-react"; import { Profiles as ProfilesSvc, Connection, -} from "@bindings/services"; -import type { Profile } from "@bindings/services/models.js"; + ProfileSwitcher, +} 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 { Input } from "../components/Input"; import { Card } from "../components/Card"; -import { useProfile } from "@/modules/profile/ProfileContext.tsx"; export default function Profiles() { - const { username, loaded, refresh: refreshProfile, switchProfile } = useProfile(); + const [username, setUsername] = useState(""); const [profiles, setProfiles] = useState([]); const [error, setError] = useState(null); const [adding, setAdding] = useState(false); const refresh = useCallback(async () => { - if (!username) return; 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); setError(null); } catch (e) { @@ -28,13 +29,12 @@ export default function Profiles() { }, [username]); useEffect(() => { - if (loaded) refresh(); - }, [loaded, refresh]); + refresh(); + }, [refresh]); const select = async (name: string) => { try { - await switchProfile(name); - await Connection.Up({ profileName: name, username }); + await ProfileSwitcher.SwitchActive({ profileName: name, username }); await refresh(); } catch (e) { setError(String(e)); @@ -54,7 +54,6 @@ export default function Profiles() { if (name === "default") return; try { await ProfilesSvc.Remove({ profileName: name, username }); - await refreshProfile(); await refresh(); } catch (e) { setError(String(e)); diff --git a/client/ui/main.go b/client/ui/main.go index 03f10a00a..643cda3d0 100644 --- a/client/ui/main.go +++ b/client/ui/main.go @@ -113,6 +113,18 @@ func main() { peers := services.NewPeers(conn, app.Event) update := services.NewUpdate(conn) 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{ Title: "NetBird", @@ -165,12 +177,13 @@ func main() { startStatusNotifierWatcher() tray = NewTray(app, window, TrayServices{ - Connection: connection, - Settings: settings, - Profiles: profiles, - Peers: peers, - Notifier: notifier, - Update: update, + Connection: connection, + Settings: settings, + Profiles: profiles, + Peers: peers, + Notifier: notifier, + Update: update, + ProfileSwitcher: profileSwitcher, }) listenForShowSignal(context.Background(), tray) diff --git a/client/ui/services/connection.go b/client/ui/services/connection.go index 84b4652ba..97653dc33 100644 --- a/client/ui/services/connection.go +++ b/client/ui/services/connection.go @@ -147,7 +147,8 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error { if err != nil { return err } - req := &proto.UpRequest{} + // The UI always uses async mode: status updates flow via SubscribeStatus. + req := &proto.UpRequest{Async: true} if p.ProfileName != "" { req.ProfileName = ptrStr(p.ProfileName) } diff --git a/client/ui/services/peers.go b/client/ui/services/peers.go index b658cf9aa..0adeeba25 100644 --- a/client/ui/services/peers.go +++ b/client/ui/services/peers.go @@ -37,6 +37,15 @@ const ( // permission, etc.). Real daemon statuses come straight from // internal.Status* — none of those collide 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" ) // Emitter is what peers.Watch needs from the host application: a simple diff --git a/client/ui/services/profile.go b/client/ui/services/profile.go index 7efcf46bc..8700df606 100644 --- a/client/ui/services/profile.go +++ b/client/ui/services/profile.go @@ -6,6 +6,7 @@ import ( "context" "os/user" + "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" ) @@ -13,6 +14,15 @@ import ( type Profile struct { Name string `json:"name"` 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. @@ -55,9 +65,14 @@ func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error) if err != nil { return nil, err } + pm := profilemanager.NewProfileManager() out := make([]Profile, 0, len(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 } diff --git a/client/ui/services/profileswitcher.go b/client/ui/services/profileswitcher.go new file mode 100644 index 000000000..1b3d2ba43 --- /dev/null +++ b/client/ui/services/profileswitcher.go @@ -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 +} diff --git a/client/ui/tray.go b/client/ui/tray.go index d68d00d03..a861d84ec 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -75,25 +75,9 @@ const ( notifyIDTrayError = "netbird-tray-error" notifyIDSessionExpired = "netbird-session-expired" - // Daemon status strings mirroring internal.Status* — kept in sync - // with client/internal/state.go. - statusConnected = "Connected" - 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" + // statusError is a tray-only synthetic label used for the error icon; + // it does not come from the daemon and is not exported. + statusError = "Error" // External URLs. urlGitHubRepo = "https://github.com/netbirdio/netbird" @@ -108,12 +92,13 @@ const ( // linter's parameter-count threshold and so adding another service later // is a one-line struct change instead of a NewTray signature break. type TrayServices struct { - Connection *services.Connection - Settings *services.Settings - Profiles *services.Profiles - Peers *services.Peers - Notifier *notifications.NotificationService - Update *services.Update + Connection *services.Connection + Settings *services.Settings + Profiles *services.Profiles + Peers *services.Peers + Notifier *notifications.NotificationService + Update *services.Update + ProfileSwitcher *services.ProfileSwitcher } type Tray struct { @@ -128,7 +113,9 @@ type Tray struct { downItem *application.MenuItem exitNodeItem *application.MenuItem networksItem *application.MenuItem - profileSubmenu *application.Menu + profileSubmenu *application.Menu + profileSubmenuItem *application.MenuItem + profileEmailItem *application.MenuItem settingsItem *application.MenuItem debugItem *application.MenuItem updateItem *application.MenuItem @@ -145,6 +132,7 @@ type Tray struct { notificationsEnabled bool activeProfile string activeUsername string + switchCancel context.CancelFunc } 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, // so the initial fill is gated on the ApplicationStarted hook. 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() // Only the action that applies to the current state is visible: Connect // 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. func (t *Tray) applyStatus(st services.Status) { t.mu.Lock() - connected := strings.EqualFold(st.Status, statusConnected) + connected := strings.EqualFold(st.Status, services.StatusConnected) iconChanged := connected != t.connected || st.Status != t.lastStatus // Detect the transition into SessionExpired: the daemon emits the // state on every Status snapshot for as long as the session stays // expired, so without this guard we would re-fire the notification // on every push. Mirrors the legacy Fyne client's sendNotification // flag in onSessionExpire. - sessionExpiredEnter := strings.EqualFold(st.Status, statusSessionExpired) && - !strings.EqualFold(t.lastStatus, statusSessionExpired) + sessionExpiredEnter := strings.EqualFold(st.Status, services.StatusSessionExpired) && + !strings.EqualFold(t.lastStatus, services.StatusSessionExpired) daemonVersionChanged := st.DaemonVersion != "" && st.DaemonVersion != t.lastDaemonVersion t.connected = connected t.lastStatus = st.Status @@ -477,11 +475,11 @@ func (t *Tray) applyStatus(st services.Status) { if iconChanged { t.applyIcon() - needsLogin := strings.EqualFold(st.Status, statusNeedsLogin) || - strings.EqualFold(st.Status, statusSessionExpired) || - strings.EqualFold(st.Status, statusLoginFailed) + needsLogin := strings.EqualFold(st.Status, services.StatusNeedsLogin) || + strings.EqualFold(st.Status, services.StatusSessionExpired) || + strings.EqualFold(st.Status, services.StatusLoginFailed) daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable) - connecting := strings.EqualFold(st.Status, statusConnecting) + connecting := strings.EqualFold(st.Status, services.StatusConnecting) if t.statusItem != nil { // When the daemon needs re-authentication the status row turns // into the actionable Login entry — Connect would only fail. @@ -491,7 +489,7 @@ func (t *Tray) applyStatus(st services.Status) { switch { case daemonUnavailable: label = menuStatusDaemonUnavailable - case strings.EqualFold(st.Status, statusIdle): + case strings.EqualFold(st.Status, services.StatusIdle): label = menuStatusDisconnected } t.statusItem.SetLabel(label) @@ -591,14 +589,14 @@ func (t *Tray) applyStatusIndicator(status string) { func statusIndicatorBitmap(status string) []byte { switch { - case strings.EqualFold(status, statusConnected): + case strings.EqualFold(status, services.StatusConnected): return iconMenuDotConnected - case strings.EqualFold(status, statusConnecting): + case strings.EqualFold(status, services.StatusConnecting): return iconMenuDotConnecting - case strings.EqualFold(status, statusNeedsLogin), - strings.EqualFold(status, statusSessionExpired): + case strings.EqualFold(status, services.StatusNeedsLogin), + strings.EqualFold(status, services.StatusSessionExpired): return iconMenuDotLogin - case strings.EqualFold(status, statusLoginFailed), + case strings.EqualFold(status, services.StatusLoginFailed), strings.EqualFold(status, statusError): return iconMenuDotError case strings.EqualFold(status, services.StatusDaemonUnavailable): @@ -636,12 +634,12 @@ func (t *Tray) iconForState() (icon, dark []byte) { statusLabel := t.lastStatus t.mu.Unlock() - connecting := strings.EqualFold(statusLabel, statusConnecting) + connecting := strings.EqualFold(statusLabel, services.StatusConnecting) errored := strings.EqualFold(statusLabel, statusError) || strings.EqualFold(statusLabel, services.StatusDaemonUnavailable) - needsLogin := strings.EqualFold(statusLabel, statusNeedsLogin) || - strings.EqualFold(statusLabel, statusSessionExpired) || - strings.EqualFold(statusLabel, statusLoginFailed) + needsLogin := strings.EqualFold(statusLabel, services.StatusNeedsLogin) || + strings.EqualFold(statusLabel, services.StatusSessionExpired) || + strings.EqualFold(statusLabel, services.StatusLoginFailed) if runtime.GOOS == "darwin" { switch { @@ -736,11 +734,21 @@ func (t *Tray) loadProfiles() { log.Infof("tray loadProfiles: received %d profile(s) for user %q", len(profiles), username) t.profileSubmenu.Clear() + var activeName, activeEmail string for _, p := range profiles { name := p.Name active := p.IsActive 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) { log.Infof("tray profile click: profile=%q wasActive=%v", name, active) if active { @@ -748,6 +756,21 @@ func (t *Tray) loadProfiles() { } 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 // 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 -// returns immediately, then reloads the submenu to move the checkmark. -// -// 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. +// switchProfile cancels any in-flight profile switch, then starts a new one. +// Cancelling the previous context aborts its in-flight gRPC calls (Down/Up) +// so rapid clicks always converge to the last selected profile. func (t *Tray) switchProfile(name string) { t.mu.Lock() - prevStatus := t.lastStatus + if t.switchCancel != nil { + t.switchCancel() + } + ctx, cancel := context.WithCancel(context.Background()) + t.switchCancel = cancel t.mu.Unlock() - wasActive := strings.EqualFold(prevStatus, statusConnected) || - strings.EqualFold(prevStatus, statusConnecting) go func() { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - username, err := t.svc.Profiles.Username() if err != nil { - log.Errorf("get current user: %v", err) + log.Errorf("tray switchProfile: get current user: %v", err) return } - log.Infof("tray switchProfile: sending SwitchProfile RPC profile=%q user=%q prevStatus=%q wasActive=%v", - name, username, prevStatus, wasActive) - if err := t.svc.Profiles.Switch(ctx, services.ProfileRef{ + if err := t.svc.ProfileSwitcher.SwitchActive(ctx, services.ProfileRef{ ProfileName: name, Username: username, }); 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)) 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() }() }