diff --git a/api/middleware.go b/api/middleware.go index 562fe38b6..d37511c5f 100644 --- a/api/middleware.go +++ b/api/middleware.go @@ -18,7 +18,10 @@ func TopMiddleware(handler http.Handler) http.Handler { //nolint:varnamelen return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - logger.Infof("[%s %s]", r.Method, r.URL.Path) + // Log only non-GET requests to reduce noise from frequent polling. + if r.Method != http.MethodGet { + logger.Debugf("[%s %s]", r.Method, r.URL.Path) + } // Goes through all local interceptors. req := RedirectToDefaultResourceInterceptor( diff --git a/cmd/massastation/main.go b/cmd/massastation/main.go index b8623a86c..8bf97d737 100644 --- a/cmd/massastation/main.go +++ b/cmd/massastation/main.go @@ -81,12 +81,12 @@ func main() { pluginManager := plugin.NewManager(configDir) - stationGUI, systrayMenu := systray.MakeGUI() // Create shared config manager once and inject into server configManager, err := config.GetConfigManager() if err != nil { logger.Fatalf("Failed to initialize config manager: %s", err.Error()) } + stationGUI, systrayMenu := systray.New() server := api.NewServer(serverFlags, configManager) update.StartUpdateCheck(&stationGUI, systrayMenu) diff --git a/int/config/manager.go b/int/config/manager.go index 4bdf01acd..4d43dc509 100644 --- a/int/config/manager.go +++ b/int/config/manager.go @@ -26,11 +26,16 @@ const ( // ErrNetworkAlreadyExists is returned when trying to add a network with a name that already exists var ErrNetworkAlreadyExists = errors.New("network already exists") +// NetworkChangeCallback is a function type called when the network changes. +type NetworkChangeCallback func() + // MSConfigManager represents a manager for network configurations. type MSConfigManager struct { - Network NetworkConfig - configMutex sync.RWMutex - stopRefresh func() + Network NetworkConfig + configMutex sync.RWMutex + stopRefresh func() + onNetworkChange NetworkChangeCallback + networkChangeMutex sync.RWMutex } // NetworkManager is an alias for backward compatibility @@ -130,7 +135,14 @@ func newMSConfigManager() (*MSConfigManager, error) { } func (n *MSConfigManager) CurrentNetwork() *RPCInfos { - return n.Network.currentNetwork + n.configMutex.RLock() + defer n.configMutex.RUnlock() + if n.Network.currentNetwork == nil { + return nil + } + // Return a copy to avoid data races when caller accesses fields after lock release + networkCopy := *n.Network.currentNetwork + return &networkCopy } // Networks returns a slice of available network names (for backward compatibility) @@ -145,12 +157,28 @@ func (n *MSConfigManager) Networks() *[]string { return &options } +// GetNetworkInfos returns a thread-safe copy of all network configurations. +func (n *MSConfigManager) GetNetworkInfos() []RPCInfos { + n.configMutex.RLock() + defer n.configMutex.RUnlock() + + networks := make([]RPCInfos, len(n.Network.Networks)) + copy(networks, n.Network.Networks) + return networks +} + +// SetNetworkChangeCallback sets a callback function that will be called whenever the network changes. +func (n *MSConfigManager) SetNetworkChangeCallback(callback NetworkChangeCallback) { + n.networkChangeMutex.Lock() + defer n.networkChangeMutex.Unlock() + n.onNetworkChange = callback +} + // SwitchNetwork switches the current network configuration to the specified network. // rpcName: The name of the network configuration to switch to. // Returns any error encountered during the switch operation. func (n *MSConfigManager) SwitchNetwork(rpcName string) error { n.configMutex.Lock() - defer n.configMutex.Unlock() // Find the network with the specified name var targetNetwork *RPCInfos @@ -162,6 +190,7 @@ func (n *MSConfigManager) SwitchNetwork(rpcName string) error { } if targetNetwork == nil { + n.configMutex.Unlock() return fmt.Errorf("unknown network: %s", rpcName) } @@ -170,6 +199,16 @@ func (n *MSConfigManager) SwitchNetwork(rpcName string) error { logger.Debugf("Switched to network: %s", rpcName) + n.configMutex.Unlock() + + // Call the network change callback if set + n.networkChangeMutex.RLock() + callback := n.onNetworkChange + n.networkChangeMutex.RUnlock() + if callback != nil { + callback() + } + return nil } @@ -277,15 +316,16 @@ func (n *MSConfigManager) SaveConfig(cfg *ConfigFile) error { // AddNetwork adds a new network to both memory and persistent configuration func (n *MSConfigManager) AddNetwork(name, url string, makeDefault bool) error { n.configMutex.Lock() - defer n.configMutex.Unlock() if name == "" || url == "" { + n.configMutex.Unlock() return fmt.Errorf("name and url are required") } // Load current persisted configuration cfg, err := LoadConfig() if err != nil { + n.configMutex.Unlock() return fmt.Errorf("load config: %w", err) } @@ -297,6 +337,7 @@ func (n *MSConfigManager) AddNetwork(name, url string, makeDefault bool) error { nameLower := strings.ToLower(strings.TrimSpace(name)) for existingName := range cfg.Networks { if strings.ToLower(strings.TrimSpace(existingName)) == nameLower { + n.configMutex.Unlock() return fmt.Errorf("%w: %s", ErrNetworkAlreadyExists, name) } } @@ -315,6 +356,7 @@ func (n *MSConfigManager) AddNetwork(name, url string, makeDefault bool) error { } if err := saveConfigUnsafe(cfg); err != nil { + n.configMutex.Unlock() return err } @@ -334,8 +376,22 @@ func (n *MSConfigManager) AddNetwork(name, url string, makeDefault bool) error { } n.Network.Networks = append(n.Network.Networks, newNet) + networkChanged := false if makeDefault { n.Network.currentNetwork = &n.Network.Networks[len(n.Network.Networks)-1] + networkChanged = true + } + + n.configMutex.Unlock() + + // Call the network change callback if set + if networkChanged { + n.networkChangeMutex.RLock() + callback := n.onNetworkChange + n.networkChangeMutex.RUnlock() + if callback != nil { + callback() + } } return nil @@ -346,22 +402,25 @@ func (n *MSConfigManager) AddNetwork(name, url string, makeDefault bool) error { // and the current network is switched in memory as well. func (n *MSConfigManager) EditNetwork(currentName string, newURL *string, makeDefault *bool, newName *string) error { n.configMutex.Lock() - defer n.configMutex.Unlock() if currentName == "" { + n.configMutex.Unlock() return fmt.Errorf("currentName is required") } cfg, err := LoadConfig() if err != nil { + n.configMutex.Unlock() return fmt.Errorf("load config: %w", err) } if cfg.Networks == nil { + n.configMutex.Unlock() return fmt.Errorf("no networks configured") } item, exists := cfg.Networks[currentName] if !exists { + n.configMutex.Unlock() return fmt.Errorf("unknown network: %s", currentName) } @@ -371,6 +430,7 @@ func (n *MSConfigManager) EditNetwork(currentName string, newURL *string, makeDe newNameLower := strings.ToLower(strings.TrimSpace(*newName)) for existingName := range cfg.Networks { if strings.ToLower(strings.TrimSpace(existingName)) == newNameLower { + n.configMutex.Unlock() return fmt.Errorf("%w: %s", ErrNetworkAlreadyExists, *newName) } } @@ -405,6 +465,7 @@ func (n *MSConfigManager) EditNetwork(currentName string, newURL *string, makeDe } if err := saveConfigUnsafe(cfg); err != nil { + n.configMutex.Unlock() return err } @@ -450,10 +511,25 @@ func (n *MSConfigManager) EditNetwork(currentName string, newURL *string, makeDe } // Switch current network if default requested or if current was renamed + networkChanged := false if makeDefault != nil && *makeDefault { n.Network.currentNetwork = &n.Network.Networks[targetIdx] + networkChanged = true } else if n.Network.currentNetwork != nil && n.Network.currentNetwork.Name == currentName && targetName != currentName { n.Network.currentNetwork = &n.Network.Networks[targetIdx] + networkChanged = true + } + + n.configMutex.Unlock() + + // Call the network change callback if set + if networkChanged { + n.networkChangeMutex.RLock() + callback := n.onNetworkChange + n.networkChangeMutex.RUnlock() + if callback != nil { + callback() + } } return nil diff --git a/int/systray/systray.go b/int/systray/systray.go index 90e1ec122..7b288d25d 100644 --- a/int/systray/systray.go +++ b/int/systray/systray.go @@ -1,30 +1,201 @@ package systray import ( + "sort" + "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" fyneDesktop "fyne.io/fyne/v2/driver/desktop" + "github.com/massalabs/station/int/config" "github.com/massalabs/station/int/systray/embedded" "github.com/massalabs/station/int/systray/utils" ) -func MakeGUI() (fyne.App, *fyne.Menu) { +// networkMenuRefresher holds references needed to refresh the network menu. +type networkMenuRefresher struct { + networkMenuItems []*fyne.MenuItem + networkSubMenu *fyne.Menu + topLevelMenu *fyne.Menu + desk fyneDesktop.App + cfgMgr *config.MSConfigManager +} + +// buildNetworkMenuItems creates menu items for each network and wires the click handler. +// The onClick factory receives the network name and returns the action closure. +func buildNetworkMenuItems( + networks []config.RPCInfos, + current *config.RPCInfos, + onClick func(name string) func(), +) []*fyne.MenuItem { + items := make([]*fyne.MenuItem, 0, len(networks)) + + for _, netInfo := range networks { + networkName := netInfo.Name + + item := fyne.NewMenuItem(networkName, nil) + if current != nil && current.Name == networkName { + item.Checked = true + } + + if onClick != nil { + item.Action = onClick(networkName) + } + + items = append(items, item) + } + + return items +} + +// refreshNetworkMenu rebuilds the network menu to reflect current networks and checkmarks. +func (n *networkMenuRefresher) refreshNetworkMenu() { + if n == nil || n.cfgMgr == nil || n.networkSubMenu == nil { + return + } + + current := n.cfgMgr.CurrentNetwork() + networks := n.cfgMgr.GetNetworkInfos() + + // Sort networks alphabetically by name + sort.Slice(networks, func(i, j int) bool { + return networks[i].Name < networks[j].Name + }) + + // Rebuild menu items from current network list + n.networkMenuItems = buildNetworkMenuItems( + networks, + current, + func(name string) func() { + return func() { + // Switch the backend network. The registered callback on the + // config manager will trigger a menu refresh. + if err := n.cfgMgr.SwitchNetwork(name); err != nil { + return + } + } + }, + ) + + // Update the submenu with new items + n.networkSubMenu.Items = n.networkMenuItems + n.networkSubMenu.Refresh() + + // Refresh top-level menu and re-set system tray menu + if n.topLevelMenu != nil { + n.topLevelMenu.Refresh() + } + if n.desk != nil { + n.desk.SetSystemTrayMenu(n.topLevelMenu) + } +} + +// New creates the GUI and returns the systray menu. +func New() (fyne.App, *fyne.Menu) { stationGUI := app.New() if desk, ok := stationGUI.(fyneDesktop.App); ok { icon := fyne.NewStaticResource("logo", embedded.Logo) + // Try to load network configuration for the "Network" submenu. + var networkSubMenu *fyne.Menu + var topLevelMenu *fyne.Menu + var refresher *networkMenuRefresher + var cfgMgr *config.MSConfigManager + if mgr, err := config.GetConfigManager(); err == nil && mgr != nil { + cfgMgr = mgr + current := mgr.CurrentNetwork() + var networkMenuItems []*fyne.MenuItem + + // Retrieve a lock-safe copy of the networks. + networks := mgr.GetNetworkInfos() + + // Sort networks alphabetically by name. + sort.Slice(networks, func(i, j int) bool { + return networks[i].Name < networks[j].Name + }) + + // Build the initial set of menu items. + networkMenuItems = buildNetworkMenuItems( + networks, + current, + func(name string) func() { + return func() { + // Switch the backend network. + if err := mgr.SwitchNetwork(name); err != nil { + // If switching fails, leave the UI unchanged. + return + } + } + }, + ) + + // Create the network submenu before wiring the refresher. + if len(networkMenuItems) > 0 { + networkSubMenu = fyne.NewMenu("Network", networkMenuItems...) + + // Create refresher for callback-based updates. + refresher = &networkMenuRefresher{ + networkMenuItems: networkMenuItems, + networkSubMenu: networkSubMenu, + cfgMgr: mgr, + } + + // Now set up the action closures with access to the submenu and desktop app. + for i, netInfo := range networks { + networkName := netInfo.Name + item := networkMenuItems[i] + + // Capture networkName by value in the closure. + item.Action = func(name string) func() { + return func() { + // Switch the backend network. + if err := mgr.SwitchNetwork(name); err != nil { + // If switching fails, leave the UI unchanged. + return + } + } + }(networkName) + } + } + } + homeShortCutMenu := fyne.NewMenuItem("Open MassaStation", nil) homeShortCutMenu.Action = func() { utils.OpenURL(&stationGUI, "https://"+config.MassaStationURL) } - menu := fyne.NewMenu( - "MassaStation", + // Build the top-level systray menu in an idiomatic Fyne way. + menuItems := []*fyne.MenuItem{ fyne.NewMenuItemSeparator(), homeShortCutMenu, - ) + } + + // If we have networks, add a "Network" submenu. + if networkSubMenu != nil { + networkItem := fyne.NewMenuItem("Network", nil) + networkItem.ChildMenu = networkSubMenu + + menuItems = append( + menuItems, + fyne.NewMenuItemSeparator(), + networkItem, + ) + } + + menu := fyne.NewMenu("MassaStation", menuItems...) + topLevelMenu = menu + + // Update refresher with top-level menu and desktop app references + if refresher != nil && cfgMgr != nil { + refresher.topLevelMenu = topLevelMenu + refresher.desk = desk + + // Register the refresher callback with the config manager + cfgMgr.SetNetworkChangeCallback(func() { + refresher.refreshNetworkMenu() + }) + } desk.SetSystemTrayIcon(icon) desk.SetSystemTrayMenu(menu) diff --git a/web/massastation/src/custom/api/useResource.ts b/web/massastation/src/custom/api/useResource.ts index eaa2fad0a..56b837c85 100644 --- a/web/massastation/src/custom/api/useResource.ts +++ b/web/massastation/src/custom/api/useResource.ts @@ -2,11 +2,14 @@ // EXTERNALS import axios, { AxiosResponse } from 'axios'; -import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import { useQuery, UseQueryResult, UseQueryOptions } from '@tanstack/react-query'; // LOCALS -export function useResource(resource: string): UseQueryResult { +export function useResource( + resource: string, + options?: Omit, 'queryKey' | 'queryFn'> +): UseQueryResult { const url = `${import.meta.env.VITE_BASE_API}/${resource}`; return useQuery({ @@ -16,5 +19,6 @@ export function useResource(resource: string): UseQueryResult { return data; }, + ...options, }); } diff --git a/web/massastation/src/layouts/LayoutStation/LayoutStation.tsx b/web/massastation/src/layouts/LayoutStation/LayoutStation.tsx index cb6fb08e7..945c72aea 100644 --- a/web/massastation/src/layouts/LayoutStation/LayoutStation.tsx +++ b/web/massastation/src/layouts/LayoutStation/LayoutStation.tsx @@ -23,6 +23,8 @@ type SwitchNetworkBody = Record; // no body expected export const DEFAULT_THEME = 'theme-dark-v2'; +export const NETWORK_REFRESH_INTERVAL = 3000; + export function LayoutStation(props: LayoutStationProps) { const { children, navigator, onSetTheme, storedTheme } = props; @@ -42,8 +44,11 @@ export function LayoutStation(props: LayoutStationProps) { const navigate = useNavigate(); + // Poll network endpoint every 3 seconds to detect changes from systray const { data: network, isSuccess: isSuccessNetwork } = - useResource(URL.PATH_NETWORKS); + useResource(URL.PATH_NETWORKS, { + refetchInterval: NETWORK_REFRESH_INTERVAL, + }); const {currentNetwork, setCurrentNetwork, setAvailableNetworks} = useNetworkStore();