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:
Eduard Gert
2026-05-13 16:51:57 +02:00
13 changed files with 309 additions and 121 deletions

View File

@@ -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,

View File

@@ -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<Profile> = {}) {
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);
}

View File

@@ -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);
}

View File

@@ -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<Profile[]>([]);
const [error, setError] = useState<string | null>(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));

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
}

View 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
}

View File

@@ -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()
}()
}