Files
netbird/client/ui/profile.go
hakansa cb8b6ca59b [client] Feat: Support Multiple Profiles (#3980)
[client] Feat: Support Multiple Profiles (#3980)
2025-07-25 16:54:46 +03:00

602 lines
15 KiB
Go

//go:build !(linux && 386)
package main
import (
"context"
"errors"
"fmt"
"os/user"
"slices"
"sort"
"sync"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"fyne.io/systray"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
)
// showProfilesUI creates and displays the Profiles window with a list of existing profiles,
// a button to add new profiles, allows removal, and lets the user switch the active profile.
func (s *serviceClient) showProfilesUI() {
profiles, err := s.getProfiles()
if err != nil {
log.Errorf("get profiles: %v", err)
return
}
var refresh func()
// List widget for profiles
list := widget.NewList(
func() int { return len(profiles) },
func() fyne.CanvasObject {
// Each item: Selected indicator, Name, spacer, Select & Remove buttons
return container.NewHBox(
widget.NewLabel(""), // indicator
widget.NewLabel(""), // profile name
layout.NewSpacer(),
widget.NewButton("Select", nil),
widget.NewButton("Remove", nil),
)
},
func(i widget.ListItemID, item fyne.CanvasObject) {
// Populate each row
row := item.(*fyne.Container)
indicator := row.Objects[0].(*widget.Label)
nameLabel := row.Objects[1].(*widget.Label)
selectBtn := row.Objects[3].(*widget.Button)
removeBtn := row.Objects[4].(*widget.Button)
profile := profiles[i]
// Show a checkmark if selected
if profile.IsActive {
indicator.SetText("✓")
} else {
indicator.SetText("")
}
nameLabel.SetText(profile.Name)
// Configure Select/Active button
selectBtn.SetText(func() string {
if profile.IsActive {
return "Active"
}
return "Select"
}())
selectBtn.OnTapped = func() {
if profile.IsActive {
return // already active
}
// confirm switch
dialog.ShowConfirm(
"Switch Profile",
fmt.Sprintf("Are you sure you want to switch to '%s'?", profile.Name),
func(confirm bool) {
if !confirm {
return
}
// switch
err = s.switchProfile(profile.Name)
if err != nil {
log.Errorf("failed to switch profile: %v", err)
dialog.ShowError(errors.New("failed to select profile"), s.wProfiles)
return
}
dialog.ShowInformation(
"Profile Switched",
fmt.Sprintf("Profile '%s' switched successfully", profile.Name),
s.wProfiles,
)
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
log.Errorf("failed to get daemon client: %v", err)
return
}
status, err := conn.Status(context.Background(), &proto.StatusRequest{})
if err != nil {
log.Errorf("failed to get status after switching profile: %v", err)
return
}
if status.Status == string(internal.StatusConnected) {
if err := s.menuDownClick(); err != nil {
log.Errorf("failed to handle down click after switching profile: %v", err)
dialog.ShowError(fmt.Errorf("failed to handle down click"), s.wProfiles)
return
}
}
// update slice flags
refresh()
},
s.wProfiles,
)
}
// Remove profile
removeBtn.SetText("Remove")
removeBtn.OnTapped = func() {
dialog.ShowConfirm(
"Delete Profile",
fmt.Sprintf("Are you sure you want to delete '%s'?", profile.Name),
func(confirm bool) {
if !confirm {
return
}
// remove
err = s.removeProfile(profile.Name)
if err != nil {
log.Errorf("failed to remove profile: %v", err)
dialog.ShowError(fmt.Errorf("failed to remove profile"), s.wProfiles)
return
}
dialog.ShowInformation(
"Profile Removed",
fmt.Sprintf("Profile '%s' removed successfully", profile.Name),
s.wProfiles,
)
// update slice
refresh()
},
s.wProfiles,
)
}
},
)
refresh = func() {
newProfiles, err := s.getProfiles()
if err != nil {
dialog.ShowError(err, s.wProfiles)
return
}
profiles = newProfiles // update the slice
list.Refresh() // tell Fyne to re-call length/update on every visible row
}
// Button to add a new profile
newBtn := widget.NewButton("New Profile", func() {
nameEntry := widget.NewEntry()
nameEntry.SetPlaceHolder("Enter Profile Name")
formItems := []*widget.FormItem{{Text: "Name:", Widget: nameEntry}}
dlg := dialog.NewForm(
"New Profile",
"Create",
"Cancel",
formItems,
func(confirm bool) {
if !confirm {
return
}
name := nameEntry.Text
if name == "" {
dialog.ShowError(errors.New("profile name cannot be empty"), s.wProfiles)
return
}
// add profile
err = s.addProfile(name)
if err != nil {
log.Errorf("failed to create profile: %v", err)
dialog.ShowError(fmt.Errorf("failed to create profile"), s.wProfiles)
return
}
dialog.ShowInformation(
"Profile Created",
fmt.Sprintf("Profile '%s' created successfully", name),
s.wProfiles,
)
// update slice
refresh()
},
s.wProfiles,
)
// make dialog wider
dlg.Resize(fyne.NewSize(350, 150))
dlg.Show()
})
// Assemble window content
content := container.NewBorder(nil, newBtn, nil, nil, list)
s.wProfiles = s.app.NewWindow("NetBird Profiles")
s.wProfiles.SetContent(content)
s.wProfiles.Resize(fyne.NewSize(400, 300))
s.wProfiles.SetOnClosed(s.cancel)
s.wProfiles.Show()
}
func (s *serviceClient) addProfile(profileName string) error {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
return fmt.Errorf(getClientFMT, err)
}
currUser, err := user.Current()
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
_, err = conn.AddProfile(context.Background(), &proto.AddProfileRequest{
ProfileName: profileName,
Username: currUser.Username,
})
if err != nil {
return fmt.Errorf("add profile: %w", err)
}
return nil
}
func (s *serviceClient) switchProfile(profileName string) error {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
return fmt.Errorf(getClientFMT, err)
}
currUser, err := user.Current()
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
if _, err := conn.SwitchProfile(context.Background(), &proto.SwitchProfileRequest{
ProfileName: &profileName,
Username: &currUser.Username,
}); err != nil {
return fmt.Errorf("switch profile failed: %w", err)
}
err = s.profileManager.SwitchProfile(profileName)
if err != nil {
return fmt.Errorf("switch profile: %w", err)
}
return nil
}
func (s *serviceClient) removeProfile(profileName string) error {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
return fmt.Errorf(getClientFMT, err)
}
currUser, err := user.Current()
if err != nil {
return fmt.Errorf("get current user: %w", err)
}
_, err = conn.RemoveProfile(context.Background(), &proto.RemoveProfileRequest{
ProfileName: profileName,
Username: currUser.Username,
})
if err != nil {
return fmt.Errorf("remove profile: %w", err)
}
return nil
}
type Profile struct {
Name string
IsActive bool
}
func (s *serviceClient) getProfiles() ([]Profile, error) {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
return nil, fmt.Errorf(getClientFMT, err)
}
currUser, err := user.Current()
if err != nil {
return nil, fmt.Errorf("get current user: %w", err)
}
profilesResp, err := conn.ListProfiles(context.Background(), &proto.ListProfilesRequest{
Username: currUser.Username,
})
if err != nil {
return nil, fmt.Errorf("list profiles: %w", err)
}
var profiles []Profile
for _, profile := range profilesResp.Profiles {
profiles = append(profiles, Profile{
Name: profile.Name,
IsActive: profile.IsActive,
})
}
return profiles, nil
}
type subItem struct {
*systray.MenuItem
ctx context.Context
cancel context.CancelFunc
}
type profileMenu struct {
mu sync.Mutex
ctx context.Context
profileManager *profilemanager.ProfileManager
eventHandler eventHandler
profileMenuItem *systray.MenuItem
emailMenuItem *systray.MenuItem
profileSubItems []*subItem
manageProfilesSubItem *subItem
profilesState []Profile
downClickCallback func() error
upClickCallback func() error
getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error)
loadSettingsCallback func()
}
func newProfileMenu(ctx context.Context, profileManager *profilemanager.ProfileManager,
eventHandler eventHandler, profileMenuItem, emailMenuItem *systray.MenuItem,
downClickCallback, upClickCallback func() error,
getSrvClientCallback func(timeout time.Duration) (proto.DaemonServiceClient, error),
loadSettingsCallback func()) *profileMenu {
p := profileMenu{
ctx: ctx,
profileManager: profileManager,
eventHandler: eventHandler,
profileMenuItem: profileMenuItem,
emailMenuItem: emailMenuItem,
downClickCallback: downClickCallback,
upClickCallback: upClickCallback,
getSrvClientCallback: getSrvClientCallback,
loadSettingsCallback: loadSettingsCallback,
}
p.emailMenuItem.Disable()
p.emailMenuItem.Hide()
p.refresh()
go p.updateMenu()
return &p
}
func (p *profileMenu) getProfiles() ([]Profile, error) {
conn, err := p.getSrvClientCallback(defaultFailTimeout)
if err != nil {
return nil, fmt.Errorf(getClientFMT, err)
}
currUser, err := user.Current()
if err != nil {
return nil, fmt.Errorf("get current user: %w", err)
}
profilesResp, err := conn.ListProfiles(p.ctx, &proto.ListProfilesRequest{
Username: currUser.Username,
})
if err != nil {
return nil, fmt.Errorf("list profiles: %w", err)
}
var profiles []Profile
for _, profile := range profilesResp.Profiles {
profiles = append(profiles, Profile{
Name: profile.Name,
IsActive: profile.IsActive,
})
}
return profiles, nil
}
func (p *profileMenu) refresh() {
p.mu.Lock()
defer p.mu.Unlock()
profiles, err := p.getProfiles()
if err != nil {
log.Errorf("failed to list profiles: %v", err)
return
}
// Clear existing profile items
p.clear(profiles)
currUser, err := user.Current()
if err != nil {
log.Errorf("failed to get current user: %v", err)
return
}
conn, err := p.getSrvClientCallback(defaultFailTimeout)
if err != nil {
log.Errorf("failed to get daemon client: %v", err)
return
}
activeProf, err := conn.GetActiveProfile(p.ctx, &proto.GetActiveProfileRequest{})
if err != nil {
log.Errorf("failed to get active profile: %v", err)
return
}
if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username {
activeProfState, err := p.profileManager.GetProfileState(activeProf.ProfileName)
if err != nil {
log.Warnf("failed to get active profile state: %v", err)
p.emailMenuItem.Hide()
} else if activeProfState.Email != "" {
p.emailMenuItem.SetTitle(fmt.Sprintf("(%s)", activeProfState.Email))
p.emailMenuItem.Show()
}
}
for _, profile := range profiles {
item := p.profileMenuItem.AddSubMenuItem(profile.Name, "")
if profile.IsActive {
item.Check()
}
ctx, cancel := context.WithCancel(context.Background())
p.profileSubItems = append(p.profileSubItems, &subItem{item, ctx, cancel})
go func() {
for {
select {
case <-ctx.Done():
return // context cancelled
case _, ok := <-item.ClickedCh:
if !ok {
return // channel closed
}
// Handle profile selection
if profile.IsActive {
log.Infof("Profile '%s' is already active", profile.Name)
return
}
conn, err := p.getSrvClientCallback(defaultFailTimeout)
if err != nil {
log.Errorf("failed to get daemon client: %v", err)
return
}
_, err = conn.SwitchProfile(ctx, &proto.SwitchProfileRequest{
ProfileName: &profile.Name,
Username: &currUser.Username,
})
if err != nil {
log.Errorf("failed to switch profile: %v", err)
return
}
err = p.profileManager.SwitchProfile(profile.Name)
if err != nil {
log.Errorf("failed to switch profile '%s': %v", profile.Name, err)
return
}
log.Infof("Switched to profile '%s'", profile.Name)
status, err := conn.Status(ctx, &proto.StatusRequest{})
if err != nil {
log.Errorf("failed to get status after switching profile: %v", err)
return
}
if status.Status == string(internal.StatusConnected) {
if err := p.downClickCallback(); err != nil {
log.Errorf("failed to handle down click after switching profile: %v", err)
}
}
if err := p.upClickCallback(); err != nil {
log.Errorf("failed to handle up click after switching profile: %v", err)
}
p.refresh()
p.loadSettingsCallback()
}
}
}()
}
ctx, cancel := context.WithCancel(context.Background())
manageItem := p.profileMenuItem.AddSubMenuItem("Manage Profiles", "")
p.manageProfilesSubItem = &subItem{manageItem, ctx, cancel}
go func() {
for {
select {
case <-ctx.Done():
return // context cancelled
case _, ok := <-manageItem.ClickedCh:
if !ok {
return // channel closed
}
// Handle manage profiles click
p.eventHandler.runSelfCommand(p.ctx, "profiles", "true")
p.refresh()
p.loadSettingsCallback()
}
}
}()
if activeProf.ProfileName == "default" || activeProf.Username == currUser.Username {
p.profileMenuItem.SetTitle(activeProf.ProfileName)
} else {
p.profileMenuItem.SetTitle(fmt.Sprintf("Profile: %s (User: %s)", activeProf.ProfileName, activeProf.Username))
p.emailMenuItem.Hide()
}
}
func (p *profileMenu) clear(profiles []Profile) {
// Clear existing profile items
for _, item := range p.profileSubItems {
item.Remove()
item.cancel()
}
p.profileSubItems = make([]*subItem, 0, len(profiles))
p.profilesState = profiles
if p.manageProfilesSubItem != nil {
// Remove the manage profiles item if it exists
p.manageProfilesSubItem.Remove()
p.manageProfilesSubItem.cancel()
p.manageProfilesSubItem = nil
}
}
func (p *profileMenu) updateMenu() {
// check every second
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// get profilesList
profiles, err := p.getProfiles()
if err != nil {
log.Errorf("failed to list profiles: %v", err)
continue
}
sort.Slice(profiles, func(i, j int) bool {
return profiles[i].Name < profiles[j].Name
})
p.mu.Lock()
state := p.profilesState
p.mu.Unlock()
sort.Slice(state, func(i, j int) bool {
return state[i].Name < state[j].Name
})
if slices.Equal(profiles, state) {
continue
}
p.refresh()
case <-p.ctx.Done():
return // context cancelled
}
}
}