mirror of
https://github.com/netbirdio/netbird.git
synced 2025-08-16 18:11:58 +02:00
[client] Feat: Support Multiple Profiles (#3980)
[client] Feat: Support Multiple Profiles (#3980)
This commit is contained in:
601
client/ui/profile.go
Normal file
601
client/ui/profile.go
Normal file
@ -0,0 +1,601 @@
|
||||
//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
|
||||
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user