mirror of
https://github.com/netbirdio/netbird.git
synced 2025-08-06 06:06:35 +02:00
602 lines
15 KiB
Go
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
|
|
|
|
}
|
|
}
|
|
}
|