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:
Victor Lyuboslavsky 2025-08-08 17:50:11 +02:00 committed by GitHub
parent 5de6391205
commit 002c381c97
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 549 additions and 151 deletions

View file

@ -0,0 +1 @@
Fixed bug where "Self-service" was still shown in Fleet Desktop menu when device was offline.

View file

@ -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

View 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()
}

View 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())
})
}

View 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
}