diff --git a/orbit/changes/31129-desktop-menu b/orbit/changes/31129-desktop-menu new file mode 100644 index 0000000000..5a7713a276 --- /dev/null +++ b/orbit/changes/31129-desktop-menu @@ -0,0 +1 @@ +Fixed bug where "Self-service" was still shown in Fleet Desktop menu when device was offline. \ No newline at end of file diff --git a/orbit/cmd/desktop/desktop.go b/orbit/cmd/desktop/desktop.go index 4158e385ab..13bda18e42 100644 --- a/orbit/cmd/desktop/desktop.go +++ b/orbit/cmd/desktop/desktop.go @@ -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 diff --git a/orbit/cmd/desktop/menu/menu.go b/orbit/cmd/desktop/menu/menu.go new file mode 100644 index 0000000000..a6ed8df0e9 --- /dev/null +++ b/orbit/cmd/desktop/menu/menu.go @@ -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() +} diff --git a/orbit/cmd/desktop/menu/menu_test.go b/orbit/cmd/desktop/menu/menu_test.go new file mode 100644 index 0000000000..88f9770a22 --- /dev/null +++ b/orbit/cmd/desktop/menu/menu_test.go @@ -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()) + }) +} diff --git a/orbit/cmd/desktop/menu/systray_adapter.go b/orbit/cmd/desktop/menu/systray_adapter.go new file mode 100644 index 0000000000..02186c7de5 --- /dev/null +++ b/orbit/cmd/desktop/menu/systray_adapter.go @@ -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 +}