mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 08:58:41 +00:00
Refactored and fixed Fleet Desktop menu. (#31649)
Fixes #31129 Also refactored some of the menu code into its own package with tests. # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## fleetd/orbit/Fleet Desktop - [x] Verified compatibility with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)) - [x] Verified that fleetd runs on macOS, Linux and Windows - [x] Verified auto-update works from the released version of component to the new version (see [tools/tuf/test](../tools/tuf/test/README.md)) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * The "Self-service" option in the Fleet Desktop menu is now hidden when the device is offline. * **Refactor** * The Fleet Desktop menu system has been restructured for improved reliability and maintainability. Menu items are now managed through a unified menu manager, resulting in a cleaner and more consistent user experience. * **New Features** * Introduced a new menu manager to dynamically update menu items based on connection status and device policies. * Added a system tray menu factory for consistent menu item creation and interaction. * **Tests** * Added comprehensive tests to ensure correct menu behavior and state transitions. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
5de6391205
commit
002c381c97
5 changed files with 549 additions and 151 deletions
1
orbit/changes/31129-desktop-menu
Normal file
1
orbit/changes/31129-desktop-menu
Normal file
|
|
@ -0,0 +1 @@
|
|||
Fixed bug where "Self-service" was still shown in Fleet Desktop menu when device was offline.
|
||||
|
|
@ -15,6 +15,7 @@ import (
|
|||
"time"
|
||||
|
||||
"fyne.io/systray"
|
||||
"github.com/fleetdm/fleet/v4/orbit/cmd/desktop/menu"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/go-paniclog"
|
||||
"github.com/fleetdm/fleet/v4/orbit/pkg/migration"
|
||||
|
|
@ -175,34 +176,8 @@ func main() {
|
|||
// least). On macOS this is used as a template icon anyway.
|
||||
systray.SetTemplateIcon(iconDark, iconDark)
|
||||
|
||||
// Add a disabled menu item with the current version
|
||||
versionItem := systray.AddMenuItem(fmt.Sprintf("Fleet Desktop v%s", version), "")
|
||||
versionItem.Disable()
|
||||
systray.AddSeparator()
|
||||
|
||||
migrateMDMItem := systray.AddMenuItem("Migrate to Fleet", "")
|
||||
migrateMDMItem.Disable()
|
||||
// this item is only shown if certain conditions are met below.
|
||||
migrateMDMItem.Hide()
|
||||
// Track the current state of the MDM Migrate item so that on, e.g. token refreshes we can
|
||||
// immediately begin showing the migrator again if we were showing it prior.
|
||||
showMDMMigrator := false
|
||||
|
||||
myDeviceItem := systray.AddMenuItem("My device", "")
|
||||
myDeviceItem.Disable()
|
||||
myDeviceItem.Hide()
|
||||
|
||||
hostOfflineItemOne := systray.AddMenuItem("🛜🚫 Your computer is not connected to Fleet.", "")
|
||||
hostOfflineItemOne.Disable()
|
||||
|
||||
selfServiceItem := systray.AddMenuItem("Self-service", "")
|
||||
selfServiceItem.Disable()
|
||||
selfServiceItem.Hide()
|
||||
systray.AddSeparator()
|
||||
|
||||
transparencyItem := systray.AddMenuItem("About Fleet", "")
|
||||
transparencyItem.Disable()
|
||||
transparencyItem.Hide()
|
||||
// Initialize menu manager with systray factory
|
||||
menuManager := menu.NewManager(version, menu.NewSystrayFactory())
|
||||
|
||||
tokenReader := token.Reader{Path: identifierPath}
|
||||
if _, err := tokenReader.Read(); err != nil {
|
||||
|
|
@ -237,26 +212,6 @@ func main() {
|
|||
return newToken
|
||||
})
|
||||
|
||||
showConnecting := func() {
|
||||
log.Debug().Msg("displaying Connecting...")
|
||||
myDeviceItem.SetTitle("Connecting...")
|
||||
myDeviceItem.Show()
|
||||
myDeviceItem.Disable()
|
||||
|
||||
transparencyItem.Disable()
|
||||
selfServiceItem.Disable()
|
||||
selfServiceItem.Hide()
|
||||
|
||||
migrateMDMItem.Disable()
|
||||
if showMDMMigrator {
|
||||
migrateMDMItem.Show()
|
||||
} else {
|
||||
migrateMDMItem.Hide()
|
||||
}
|
||||
|
||||
hostOfflineItemOne.Hide()
|
||||
}
|
||||
|
||||
reportError := func(err error, info map[string]any) {
|
||||
if !client.GetServerCapabilities().Has(fleet.CapabilityErrorReporting) {
|
||||
log.Info().Msg("skipped reporting error to the server as it doesn't have the capability enabled")
|
||||
|
|
@ -298,7 +253,7 @@ func main() {
|
|||
// checkToken performs API test calls to enable the "My device" item as
|
||||
// soon as the device auth token is registered by Fleet.
|
||||
checkToken := func() <-chan interface{} {
|
||||
showConnecting()
|
||||
menuManager.SetConnecting()
|
||||
done := make(chan interface{})
|
||||
|
||||
go func() {
|
||||
|
|
@ -312,28 +267,8 @@ func main() {
|
|||
|
||||
if err == nil || errors.Is(err, service.ErrMissingLicense) {
|
||||
log.Debug().Msg("enabling tray items")
|
||||
myDeviceItem.SetTitle("My device")
|
||||
myDeviceItem.Show()
|
||||
myDeviceItem.Enable()
|
||||
|
||||
transparencyItem.Enable()
|
||||
transparencyItem.Show()
|
||||
|
||||
hostOfflineItemOne.Hide()
|
||||
|
||||
// Hide Self-Service for Free tier
|
||||
if errors.Is(err, service.ErrMissingLicense) || (summary.SelfService != nil && !*summary.SelfService) {
|
||||
selfServiceItem.Disable()
|
||||
selfServiceItem.Hide()
|
||||
} else {
|
||||
selfServiceItem.Enable()
|
||||
selfServiceItem.Show()
|
||||
}
|
||||
|
||||
if showMDMMigrator {
|
||||
migrateMDMItem.Enable()
|
||||
migrateMDMItem.Show()
|
||||
}
|
||||
isFreeTier := errors.Is(err, service.ErrMissingLicense)
|
||||
menuManager.SetConnected(&summary.DesktopSummary, isFreeTier)
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -380,20 +315,8 @@ func main() {
|
|||
<-deviceEnabledChan
|
||||
|
||||
var (
|
||||
pingErrCount = 0
|
||||
lastDesktopSummaryCheck time.Time
|
||||
offlineIndicatorDisplayed = false
|
||||
showOffline = func() {
|
||||
myDeviceItem.Hide()
|
||||
transparencyItem.Disable()
|
||||
transparencyItem.Hide()
|
||||
migrateMDMItem.Disable()
|
||||
migrateMDMItem.Hide()
|
||||
hostOfflineItemOne.Show()
|
||||
selfServiceItem.Disable()
|
||||
selfServiceItem.Hide()
|
||||
offlineIndicatorDisplayed = true
|
||||
}
|
||||
pingErrCount = 0
|
||||
lastDesktopSummaryCheck time.Time
|
||||
)
|
||||
|
||||
for {
|
||||
|
|
@ -409,7 +332,7 @@ func main() {
|
|||
// We try 5 more times to make sure one bad request doesn't trigger the offline indicator.
|
||||
// So it might take up to ~1m (6 * 10s) for Fleet Desktop to show the offline indicator.
|
||||
if pingErrCount >= 6 {
|
||||
showOffline()
|
||||
menuManager.SetOffline()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
|
@ -418,7 +341,7 @@ func main() {
|
|||
pingErrCount = 0
|
||||
|
||||
// Check if we need to fetch the "Fleet desktop" summary from Fleet.
|
||||
if !offlineIndicatorDisplayed &&
|
||||
if !menuManager.IsOfflineIndicatorDisplayed() &&
|
||||
!fleetDesktopCheckTrigger.Load() &&
|
||||
(!lastDesktopSummaryCheck.IsZero() && time.Since(lastDesktopSummaryCheck) < desktopSummaryInterval) {
|
||||
continue
|
||||
|
|
@ -429,7 +352,7 @@ func main() {
|
|||
// We set offlineIndicatorDisplayed to false because we do not want to retry the
|
||||
// Fleet Desktop summary every 10s if Ping works but DesktopSummary doesn't
|
||||
// (to avoid server load issues).
|
||||
offlineIndicatorDisplayed = false
|
||||
menuManager.SetOfflineIndicatorDisplayed(false)
|
||||
|
||||
sum, err := client.DesktopSummary(tokenReader.GetCached())
|
||||
if err != nil {
|
||||
|
|
@ -437,9 +360,7 @@ func main() {
|
|||
case errors.Is(err, service.ErrMissingLicense):
|
||||
// Policy reporting in Fleet Desktop requires a license,
|
||||
// so we just show the "My device" item as usual.
|
||||
myDeviceItem.SetTitle("My device")
|
||||
myDeviceItem.Show()
|
||||
hostOfflineItemOne.Hide()
|
||||
menuManager.SetConnected(&fleet.DesktopSummary{}, true)
|
||||
case errors.Is(err, service.ErrUnauthenticated):
|
||||
log.Debug().Err(err).Msg("get desktop summary auth failure")
|
||||
// This usually happens every ~1 hour when the token expires.
|
||||
|
|
@ -450,14 +371,8 @@ func main() {
|
|||
continue
|
||||
}
|
||||
|
||||
hostOfflineItemOne.Hide()
|
||||
|
||||
refreshMenuItems(sum.DesktopSummary, selfServiceItem, myDeviceItem)
|
||||
myDeviceItem.Enable()
|
||||
myDeviceItem.Show()
|
||||
|
||||
transparencyItem.Enable()
|
||||
transparencyItem.Show()
|
||||
menuManager.SetConnected(&sum.DesktopSummary, false)
|
||||
menuManager.UpdateFailingPolicies(sum.DesktopSummary.FailingPolicies)
|
||||
|
||||
// Check our file to see if we should migrate
|
||||
var migrationType string
|
||||
|
|
@ -507,13 +422,9 @@ func main() {
|
|||
|
||||
// enable tray items
|
||||
if migrationType != constant.MDMMigrationTypeADE {
|
||||
migrateMDMItem.Enable()
|
||||
migrateMDMItem.Show()
|
||||
showMDMMigrator = true
|
||||
menuManager.SetMDMMigratorVisibility(true)
|
||||
} else {
|
||||
migrateMDMItem.Disable()
|
||||
migrateMDMItem.Hide()
|
||||
showMDMMigrator = false
|
||||
menuManager.SetMDMMigratorVisibility(false)
|
||||
}
|
||||
|
||||
// if the device is unmanaged or we're in force mode and the device needs
|
||||
|
|
@ -531,14 +442,10 @@ func main() {
|
|||
go reportError(err, nil)
|
||||
log.Error().Err(err).Msg("failed to mark MDM migration as completed")
|
||||
}
|
||||
migrateMDMItem.Disable()
|
||||
migrateMDMItem.Hide()
|
||||
showMDMMigrator = false
|
||||
menuManager.SetMDMMigratorVisibility(false)
|
||||
}
|
||||
} else {
|
||||
migrateMDMItem.Disable()
|
||||
migrateMDMItem.Hide()
|
||||
showMDMMigrator = false
|
||||
menuManager.SetMDMMigratorVisibility(false)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
|
@ -546,7 +453,7 @@ func main() {
|
|||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-myDeviceItem.ClickedCh:
|
||||
case <-menuManager.Items.MyDevice.ClickedCh():
|
||||
openURL := client.BrowserPoliciesURL(tokenReader.GetCached())
|
||||
if err := open.Browser(openURL); err != nil {
|
||||
log.Error().Err(err).Str("url", openURL).Msg("open browser policies")
|
||||
|
|
@ -554,12 +461,12 @@ func main() {
|
|||
// Also refresh the device status by forcing the polling ticker to fire
|
||||
fleetDesktopCheckTrigger.Store(true)
|
||||
pingTicker.Reset(1 * time.Millisecond)
|
||||
case <-transparencyItem.ClickedCh:
|
||||
case <-menuManager.Items.Transparency.ClickedCh():
|
||||
openURL := client.BrowserTransparencyURL(tokenReader.GetCached())
|
||||
if err := open.Browser(openURL); err != nil {
|
||||
log.Error().Err(err).Str("url", openURL).Msg("open browser transparency")
|
||||
}
|
||||
case <-selfServiceItem.ClickedCh:
|
||||
case <-menuManager.Items.SelfService.ClickedCh():
|
||||
openURL := client.BrowserSelfServiceURL(tokenReader.GetCached())
|
||||
if err := open.Browser(openURL); err != nil {
|
||||
log.Error().Err(err).Str("url", openURL).Msg("open browser self-service")
|
||||
|
|
@ -567,7 +474,7 @@ func main() {
|
|||
// Also refresh the device status by forcing the polling ticker to fire
|
||||
fleetDesktopCheckTrigger.Store(true)
|
||||
pingTicker.Reset(1 * time.Millisecond)
|
||||
case <-migrateMDMItem.ClickedCh:
|
||||
case <-menuManager.Items.MigrateMDM.ClickedCh():
|
||||
if offline := offlineWatcher.ShowIfOffline(offlineWatcherCtx); offline {
|
||||
continue
|
||||
}
|
||||
|
|
@ -638,42 +545,6 @@ func main() {
|
|||
systray.Run(onReady, onExit)
|
||||
}
|
||||
|
||||
func refreshMenuItems(sum fleet.DesktopSummary, selfServiceItem *systray.MenuItem, myDeviceItem *systray.MenuItem) {
|
||||
// Check for null for backward compatibility with an old Fleet server
|
||||
if sum.SelfService != nil && !*sum.SelfService {
|
||||
selfServiceItem.Disable()
|
||||
selfServiceItem.Hide()
|
||||
} else {
|
||||
selfServiceItem.Enable()
|
||||
selfServiceItem.Show()
|
||||
}
|
||||
|
||||
failingPolicies := 0
|
||||
if sum.FailingPolicies != nil {
|
||||
failingPolicies = int(*sum.FailingPolicies) //nolint:gosec // dismiss G115
|
||||
}
|
||||
|
||||
if failingPolicies > 0 {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows (or maybe just the systray library?) doesn't support color emoji
|
||||
// in the system tray menu, so we use text as an alternative.
|
||||
if failingPolicies == 1 {
|
||||
myDeviceItem.SetTitle("My device (1 issue)")
|
||||
} else {
|
||||
myDeviceItem.SetTitle(fmt.Sprintf("My device (%d issues)", failingPolicies))
|
||||
}
|
||||
} else {
|
||||
myDeviceItem.SetTitle(fmt.Sprintf("🔴 My device (%d)", failingPolicies))
|
||||
}
|
||||
} else {
|
||||
if runtime.GOOS == "windows" {
|
||||
myDeviceItem.SetTitle("My device")
|
||||
} else {
|
||||
myDeviceItem.SetTitle("🟢 My device")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type mdmMigrationHandler struct {
|
||||
client *service.DeviceClient
|
||||
tokenReader *token.Reader
|
||||
|
|
|
|||
217
orbit/cmd/desktop/menu/menu.go
Normal file
217
orbit/cmd/desktop/menu/menu.go
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
package menu
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// Factory is the interface for creating menu items
|
||||
type Factory interface {
|
||||
AddMenuItem(title string, tooltip string) Item
|
||||
AddSeparator()
|
||||
}
|
||||
|
||||
// Item interface abstracts systray.MenuItem for testing
|
||||
// Note: systray.MenuItem.ClickedCh is a field, not a method,
|
||||
// so we need a wrapper to provide method-based access for testability
|
||||
type Item interface {
|
||||
SetTitle(title string)
|
||||
Enable()
|
||||
Disable()
|
||||
Show()
|
||||
Hide()
|
||||
ClickedCh() <-chan struct{}
|
||||
}
|
||||
|
||||
// Items holds all the menu items for the Fleet Desktop systray
|
||||
type Items struct {
|
||||
Version Item
|
||||
MigrateMDM Item
|
||||
MyDevice Item
|
||||
HostOffline Item
|
||||
SelfService Item
|
||||
Transparency Item
|
||||
}
|
||||
|
||||
// Manager handles the state and behavior of the Fleet Desktop menu
|
||||
type Manager struct {
|
||||
Items *Items
|
||||
// Track the current state of the MDM Migrate item so that on, e.g. token refreshes we can
|
||||
// immediately begin showing the migrator again if we were showing it prior.
|
||||
showMDMMigrator atomic.Bool
|
||||
// Track whether the offline indicator is currently displayed
|
||||
offlineIndicatorDisplayed atomic.Bool
|
||||
}
|
||||
|
||||
// NewManager creates a new menu manager with initialized menu items
|
||||
func NewManager(version string, factory Factory) *Manager {
|
||||
items := &Items{}
|
||||
|
||||
// Add version item (always disabled)
|
||||
items.Version = factory.AddMenuItem(fmt.Sprintf("Fleet Desktop v%s", version), "")
|
||||
items.Version.Disable()
|
||||
factory.AddSeparator()
|
||||
|
||||
// Add MDM migration item
|
||||
items.MigrateMDM = factory.AddMenuItem("Migrate to Fleet", "")
|
||||
items.MigrateMDM.Disable()
|
||||
items.MigrateMDM.Hide()
|
||||
|
||||
// Add my device item
|
||||
items.MyDevice = factory.AddMenuItem("My device", "")
|
||||
items.MyDevice.Disable()
|
||||
items.MyDevice.Hide()
|
||||
|
||||
// Add offline warning item
|
||||
items.HostOffline = factory.AddMenuItem("🛜🚫 Your computer is not connected to Fleet.", "")
|
||||
items.HostOffline.Disable()
|
||||
|
||||
// Add self-service item
|
||||
items.SelfService = factory.AddMenuItem("Self-service", "")
|
||||
items.SelfService.Disable()
|
||||
items.SelfService.Hide()
|
||||
factory.AddSeparator()
|
||||
|
||||
// Add transparency item
|
||||
items.Transparency = factory.AddMenuItem("About Fleet", "")
|
||||
items.Transparency.Disable()
|
||||
items.Transparency.Hide()
|
||||
|
||||
m := &Manager{
|
||||
Items: items,
|
||||
}
|
||||
// Initialize atomic fields
|
||||
m.showMDMMigrator.Store(false)
|
||||
m.offlineIndicatorDisplayed.Store(false)
|
||||
return m
|
||||
}
|
||||
|
||||
// SetConnecting sets the menu to the connecting state
|
||||
func (m *Manager) SetConnecting() {
|
||||
log.Debug().Msg("displaying Connecting...")
|
||||
m.Items.MyDevice.SetTitle("Connecting...")
|
||||
m.Items.MyDevice.Show()
|
||||
m.Items.MyDevice.Disable()
|
||||
|
||||
m.Items.Transparency.Disable()
|
||||
m.Items.SelfService.Disable()
|
||||
m.Items.SelfService.Hide()
|
||||
|
||||
m.Items.MigrateMDM.Disable()
|
||||
if m.showMDMMigrator.Load() {
|
||||
m.Items.MigrateMDM.Show()
|
||||
} else {
|
||||
m.Items.MigrateMDM.Hide()
|
||||
}
|
||||
|
||||
m.hideOfflineWarning()
|
||||
}
|
||||
|
||||
// SetConnected sets the menu to the connected state
|
||||
func (m *Manager) SetConnected(summary *fleet.DesktopSummary, isFreeTier bool) {
|
||||
m.Items.MyDevice.SetTitle("My device")
|
||||
m.Items.MyDevice.Show()
|
||||
m.Items.MyDevice.Enable()
|
||||
|
||||
m.Items.Transparency.Enable()
|
||||
m.Items.Transparency.Show()
|
||||
|
||||
m.hideOfflineWarning()
|
||||
m.offlineIndicatorDisplayed.Store(false)
|
||||
|
||||
// Handle self-service visibility. Check for null for backward compatibility with an old Fleet server
|
||||
if isFreeTier || (summary.SelfService != nil && !*summary.SelfService) {
|
||||
m.Items.SelfService.Disable()
|
||||
m.Items.SelfService.Hide()
|
||||
} else {
|
||||
m.Items.SelfService.Enable()
|
||||
m.Items.SelfService.Show()
|
||||
}
|
||||
|
||||
// Show MDM migrator if it was previously shown
|
||||
if m.showMDMMigrator.Load() {
|
||||
m.Items.MigrateMDM.Enable()
|
||||
m.Items.MigrateMDM.Show()
|
||||
}
|
||||
}
|
||||
|
||||
// SetOffline sets the menu to the offline state
|
||||
func (m *Manager) SetOffline() {
|
||||
m.Items.MyDevice.Hide()
|
||||
m.Items.SelfService.Disable()
|
||||
m.Items.SelfService.Hide()
|
||||
m.Items.Transparency.Disable()
|
||||
m.Items.Transparency.Hide()
|
||||
m.Items.MigrateMDM.Disable()
|
||||
m.Items.MigrateMDM.Hide()
|
||||
m.showOfflineWarning()
|
||||
m.offlineIndicatorDisplayed.Store(true)
|
||||
}
|
||||
|
||||
// UpdateFailingPolicies updates the my device item based on failing policies count
|
||||
func (m *Manager) UpdateFailingPolicies(failingPolicies *uint) {
|
||||
count := 0
|
||||
if failingPolicies != nil {
|
||||
count = int(*failingPolicies) // nolint:gosec // dismiss G115
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows doesn't support color emoji in system tray
|
||||
if count == 1 {
|
||||
m.Items.MyDevice.SetTitle("My device (1 issue)")
|
||||
} else {
|
||||
m.Items.MyDevice.SetTitle(fmt.Sprintf("My device (%d issues)", count))
|
||||
}
|
||||
} else {
|
||||
m.Items.MyDevice.SetTitle(fmt.Sprintf("🔴 My device (%d)", count))
|
||||
}
|
||||
} else {
|
||||
if runtime.GOOS == "windows" {
|
||||
m.Items.MyDevice.SetTitle("My device")
|
||||
} else {
|
||||
m.Items.MyDevice.SetTitle("🟢 My device")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetMDMMigratorVisibility controls the visibility of the MDM migration menu item
|
||||
func (m *Manager) SetMDMMigratorVisibility(show bool) {
|
||||
m.showMDMMigrator.Store(show)
|
||||
if show {
|
||||
m.Items.MigrateMDM.Enable()
|
||||
m.Items.MigrateMDM.Show()
|
||||
} else {
|
||||
m.Items.MigrateMDM.Disable()
|
||||
m.Items.MigrateMDM.Hide()
|
||||
}
|
||||
}
|
||||
|
||||
// GetMDMMigratorVisibility returns whether the MDM migrator is currently shown
|
||||
func (m *Manager) GetMDMMigratorVisibility() bool {
|
||||
return m.showMDMMigrator.Load()
|
||||
}
|
||||
|
||||
// IsOfflineIndicatorDisplayed returns whether the offline indicator is currently displayed
|
||||
func (m *Manager) IsOfflineIndicatorDisplayed() bool {
|
||||
return m.offlineIndicatorDisplayed.Load()
|
||||
}
|
||||
|
||||
// SetOfflineIndicatorDisplayed sets the offline indicator display state
|
||||
func (m *Manager) SetOfflineIndicatorDisplayed(displayed bool) {
|
||||
m.offlineIndicatorDisplayed.Store(displayed)
|
||||
}
|
||||
|
||||
// showOfflineWarning displays the offline warning item
|
||||
func (m *Manager) showOfflineWarning() {
|
||||
m.Items.HostOffline.Show()
|
||||
}
|
||||
|
||||
// hideOfflineWarning hides the offline warning item
|
||||
func (m *Manager) hideOfflineWarning() {
|
||||
m.Items.HostOffline.Hide()
|
||||
}
|
||||
272
orbit/cmd/desktop/menu/menu_test.go
Normal file
272
orbit/cmd/desktop/menu/menu_test.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
package menu
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// MockFactory implements Factory interface for testing
|
||||
type MockFactory struct {
|
||||
Items []MockMenuItem
|
||||
Separators int
|
||||
}
|
||||
|
||||
// NewMockFactory creates a new mock factory for testing
|
||||
func NewMockFactory() *MockFactory {
|
||||
return &MockFactory{
|
||||
Items: []MockMenuItem{},
|
||||
Separators: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// AddMenuItem creates a new mock menu item
|
||||
func (m *MockFactory) AddMenuItem(title string, _ string) Item {
|
||||
item := NewMockMenuItem(title)
|
||||
m.Items = append(m.Items, *item)
|
||||
return item
|
||||
}
|
||||
|
||||
// AddSeparator increments the separator count
|
||||
func (m *MockFactory) AddSeparator() {
|
||||
m.Separators++
|
||||
}
|
||||
|
||||
// MockMenuItem provides a testable implementation of Item
|
||||
type MockMenuItem struct {
|
||||
Title string
|
||||
Enabled bool
|
||||
Visible bool
|
||||
clickedCh chan struct{}
|
||||
History []string // Track all operations for testing
|
||||
}
|
||||
|
||||
// NewMockMenuItem creates a new mock menu item
|
||||
func NewMockMenuItem(title string) *MockMenuItem {
|
||||
return &MockMenuItem{
|
||||
Title: title,
|
||||
Enabled: true,
|
||||
Visible: true,
|
||||
clickedCh: make(chan struct{}, 1),
|
||||
History: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockMenuItem) SetTitle(title string) {
|
||||
m.Title = title
|
||||
m.History = append(m.History, "SetTitle:"+title)
|
||||
}
|
||||
|
||||
func (m *MockMenuItem) Enable() {
|
||||
m.Enabled = true
|
||||
m.History = append(m.History, "Enable")
|
||||
}
|
||||
|
||||
func (m *MockMenuItem) Disable() {
|
||||
m.Enabled = false
|
||||
m.History = append(m.History, "Disable")
|
||||
}
|
||||
|
||||
func (m *MockMenuItem) Show() {
|
||||
m.Visible = true
|
||||
m.History = append(m.History, "Show")
|
||||
}
|
||||
|
||||
func (m *MockMenuItem) Hide() {
|
||||
m.Visible = false
|
||||
m.History = append(m.History, "Hide")
|
||||
}
|
||||
|
||||
func (m *MockMenuItem) ClickedCh() <-chan struct{} {
|
||||
return m.clickedCh
|
||||
}
|
||||
|
||||
// SimulateClick simulates a user clicking the menu item
|
||||
func (m *MockMenuItem) SimulateClick() {
|
||||
select {
|
||||
case m.clickedCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerWithMockFactory tests the Manager using a mock factory
|
||||
func TestManagerWithMockFactory(t *testing.T) {
|
||||
factory := NewMockFactory()
|
||||
manager := NewManager("1.0.0", factory)
|
||||
|
||||
t.Run("initial setup", func(t *testing.T) {
|
||||
// Check that all items were created
|
||||
assert.NotNil(t, manager.Items.Version)
|
||||
assert.NotNil(t, manager.Items.MigrateMDM)
|
||||
assert.NotNil(t, manager.Items.MyDevice)
|
||||
assert.NotNil(t, manager.Items.HostOffline)
|
||||
assert.NotNil(t, manager.Items.SelfService)
|
||||
assert.NotNil(t, manager.Items.Transparency)
|
||||
|
||||
// Check that correct number of separators were added
|
||||
assert.Equal(t, 2, factory.Separators)
|
||||
|
||||
// Check that correct number of items were created
|
||||
assert.Equal(t, 6, len(factory.Items)) // Version, MigrateMDM, MyDevice, 1x HostOffline, SelfService, Transparency
|
||||
})
|
||||
|
||||
t.Run("set connecting state", func(t *testing.T) {
|
||||
manager.SetConnecting()
|
||||
|
||||
// Check MyDevice state
|
||||
myDevice := manager.Items.MyDevice.(*MockMenuItem)
|
||||
assert.Equal(t, "Connecting...", myDevice.Title)
|
||||
assert.True(t, myDevice.Visible)
|
||||
assert.False(t, myDevice.Enabled)
|
||||
|
||||
// Check other items are hidden/disabled
|
||||
transparency := manager.Items.Transparency.(*MockMenuItem)
|
||||
assert.False(t, transparency.Enabled)
|
||||
|
||||
selfService := manager.Items.SelfService.(*MockMenuItem)
|
||||
assert.False(t, selfService.Visible)
|
||||
assert.False(t, selfService.Enabled)
|
||||
|
||||
migrateMDM := manager.Items.MigrateMDM.(*MockMenuItem)
|
||||
assert.False(t, migrateMDM.Visible)
|
||||
assert.False(t, migrateMDM.Enabled)
|
||||
})
|
||||
|
||||
t.Run("set connected state", func(t *testing.T) {
|
||||
summary := &fleet.DesktopSummary{
|
||||
SelfService: ptr.Bool(true),
|
||||
}
|
||||
manager.SetConnected(summary, false)
|
||||
|
||||
// Check MyDevice state
|
||||
myDevice := manager.Items.MyDevice.(*MockMenuItem)
|
||||
assert.Equal(t, "My device", myDevice.Title)
|
||||
assert.True(t, myDevice.Visible)
|
||||
assert.True(t, myDevice.Enabled)
|
||||
|
||||
// Check transparency is enabled
|
||||
transparency := manager.Items.Transparency.(*MockMenuItem)
|
||||
assert.True(t, transparency.Enabled)
|
||||
assert.True(t, transparency.Visible)
|
||||
|
||||
// Check self-service is shown (not free tier)
|
||||
selfService := manager.Items.SelfService.(*MockMenuItem)
|
||||
assert.True(t, selfService.Visible)
|
||||
assert.True(t, selfService.Enabled)
|
||||
})
|
||||
|
||||
t.Run("set connected state free tier", func(t *testing.T) {
|
||||
// Test free tier - self-service should be hidden
|
||||
summary := &fleet.DesktopSummary{
|
||||
SelfService: ptr.Bool(true), // Even if enabled in summary, free tier overrides it
|
||||
}
|
||||
manager.SetConnected(summary, true) // true = free tier
|
||||
|
||||
// Check MyDevice state
|
||||
myDevice := manager.Items.MyDevice.(*MockMenuItem)
|
||||
assert.Equal(t, "My device", myDevice.Title)
|
||||
assert.True(t, myDevice.Visible)
|
||||
assert.True(t, myDevice.Enabled)
|
||||
|
||||
// Check transparency is enabled
|
||||
transparency := manager.Items.Transparency.(*MockMenuItem)
|
||||
assert.True(t, transparency.Enabled)
|
||||
assert.True(t, transparency.Visible)
|
||||
|
||||
// Check self-service is hidden and disabled on free tier
|
||||
selfService := manager.Items.SelfService.(*MockMenuItem)
|
||||
assert.False(t, selfService.Visible, "Self-service should be hidden on free tier")
|
||||
assert.False(t, selfService.Enabled, "Self-service should be disabled on free tier")
|
||||
})
|
||||
|
||||
t.Run("set offline state", func(t *testing.T) {
|
||||
// First, set connected state with self-service enabled
|
||||
summary := &fleet.DesktopSummary{
|
||||
SelfService: ptr.Bool(true),
|
||||
}
|
||||
manager.SetConnected(summary, false)
|
||||
|
||||
// Verify self-service is enabled when connected
|
||||
selfService := manager.Items.SelfService.(*MockMenuItem)
|
||||
assert.True(t, selfService.Enabled, "Self-service should be enabled when connected")
|
||||
assert.True(t, selfService.Visible, "Self-service should be visible when connected")
|
||||
|
||||
// Verify offline indicator is not displayed after connecting
|
||||
assert.False(t, manager.IsOfflineIndicatorDisplayed(), "Offline indicator should not be displayed when connected")
|
||||
|
||||
// Now set offline state
|
||||
manager.SetOffline()
|
||||
|
||||
// Check MyDevice is hidden
|
||||
myDevice := manager.Items.MyDevice.(*MockMenuItem)
|
||||
assert.False(t, myDevice.Visible)
|
||||
|
||||
// Check transparency is disabled and hidden
|
||||
transparency := manager.Items.Transparency.(*MockMenuItem)
|
||||
assert.False(t, transparency.Enabled)
|
||||
assert.False(t, transparency.Visible)
|
||||
|
||||
// Check self-service is disabled when offline
|
||||
assert.False(t, selfService.Enabled, "Self-service should be disabled when offline")
|
||||
assert.False(t, selfService.Visible, "Self-service should be hidden when offline")
|
||||
|
||||
// Check offline warning is shown
|
||||
offlineItem := manager.Items.HostOffline.(*MockMenuItem)
|
||||
assert.True(t, offlineItem.Visible)
|
||||
|
||||
// Check offline indicator is displayed
|
||||
assert.True(t, manager.IsOfflineIndicatorDisplayed(), "Offline indicator should be displayed when offline")
|
||||
})
|
||||
|
||||
t.Run("update failing policies", func(t *testing.T) {
|
||||
// Test with failing policies
|
||||
failingCount := uint(3)
|
||||
manager.UpdateFailingPolicies(&failingCount)
|
||||
|
||||
myDevice := manager.Items.MyDevice.(*MockMenuItem)
|
||||
assert.Contains(t, myDevice.Title, "3")
|
||||
|
||||
// Test with no failing policies
|
||||
failingCount = uint(0)
|
||||
manager.UpdateFailingPolicies(&failingCount)
|
||||
assert.Contains(t, myDevice.Title, "My device")
|
||||
})
|
||||
|
||||
t.Run("MDM migrator visibility", func(t *testing.T) {
|
||||
// Initially should be hidden
|
||||
assert.False(t, manager.GetMDMMigratorVisibility())
|
||||
|
||||
// Show MDM migrator
|
||||
manager.SetMDMMigratorVisibility(true)
|
||||
assert.True(t, manager.GetMDMMigratorVisibility())
|
||||
|
||||
migrateMDM := manager.Items.MigrateMDM.(*MockMenuItem)
|
||||
assert.True(t, migrateMDM.Visible)
|
||||
assert.True(t, migrateMDM.Enabled)
|
||||
|
||||
// Hide MDM migrator
|
||||
manager.SetMDMMigratorVisibility(false)
|
||||
assert.False(t, manager.GetMDMMigratorVisibility())
|
||||
assert.False(t, migrateMDM.Visible)
|
||||
assert.False(t, migrateMDM.Enabled)
|
||||
})
|
||||
|
||||
t.Run("offline indicator display state", func(t *testing.T) {
|
||||
// Create a fresh manager for this test
|
||||
testFactory := NewMockFactory()
|
||||
testManager := NewManager("1.0.0", testFactory)
|
||||
|
||||
// Initially should not be displayed
|
||||
assert.False(t, testManager.IsOfflineIndicatorDisplayed())
|
||||
|
||||
// Set offline indicator displayed
|
||||
testManager.SetOfflineIndicatorDisplayed(true)
|
||||
assert.True(t, testManager.IsOfflineIndicatorDisplayed())
|
||||
|
||||
// Clear offline indicator displayed
|
||||
testManager.SetOfflineIndicatorDisplayed(false)
|
||||
assert.False(t, testManager.IsOfflineIndicatorDisplayed())
|
||||
})
|
||||
}
|
||||
37
orbit/cmd/desktop/menu/systray_adapter.go
Normal file
37
orbit/cmd/desktop/menu/systray_adapter.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package menu
|
||||
|
||||
import "fyne.io/systray"
|
||||
|
||||
// SystrayFactory implements Factory interface for the actual systray
|
||||
type SystrayFactory struct{}
|
||||
|
||||
// Ensure SystrayFactory implements Factory
|
||||
var _ Factory = &SystrayFactory{}
|
||||
|
||||
// NewSystrayFactory creates a new systray factory
|
||||
func NewSystrayFactory() *SystrayFactory {
|
||||
return &SystrayFactory{}
|
||||
}
|
||||
|
||||
// AddMenuItem creates a new menu item using systray
|
||||
func (s *SystrayFactory) AddMenuItem(title string, tooltip string) Item {
|
||||
return &SystrayMenuItem{
|
||||
MenuItem: systray.AddMenuItem(title, tooltip),
|
||||
}
|
||||
}
|
||||
|
||||
// AddSeparator adds a separator to the menu
|
||||
func (s *SystrayFactory) AddSeparator() {
|
||||
systray.AddSeparator()
|
||||
}
|
||||
|
||||
// SystrayMenuItem is a thin wrapper around systray.MenuItem that adds the ClickedCh method
|
||||
// This is needed because systray.MenuItem has ClickedCh as a field, not a method
|
||||
type SystrayMenuItem struct {
|
||||
*systray.MenuItem
|
||||
}
|
||||
|
||||
// ClickedCh returns the channel that's notified when the menu item is clicked
|
||||
func (s *SystrayMenuItem) ClickedCh() <-chan struct{} {
|
||||
return s.MenuItem.ClickedCh
|
||||
}
|
||||
Loading…
Reference in a new issue