Skip to content

Commit cb6fb44

Browse files
committed
add systray menu network selection
1 parent cda2cb4 commit cb6fb44

File tree

5 files changed

+212
-15
lines changed

5 files changed

+212
-15
lines changed

cmd/massastation/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,12 +81,12 @@ func main() {
8181

8282
pluginManager := plugin.NewManager(configDir)
8383

84-
stationGUI, systrayMenu := systray.MakeGUI()
8584
// Create shared config manager once and inject into server
8685
configManager, err := config.GetConfigManager()
8786
if err != nil {
8887
logger.Fatalf("Failed to initialize config manager: %s", err.Error())
8988
}
89+
stationGUI, systrayMenu, _ := systray.MakeGUIWithRefresher()
9090
server := api.NewServer(serverFlags, configManager)
9191

9292
update.StartUpdateCheck(&stationGUI, systrayMenu)

int/config/manager.go

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,16 @@ const (
2626
// ErrNetworkAlreadyExists is returned when trying to add a network with a name that already exists
2727
var ErrNetworkAlreadyExists = errors.New("network already exists")
2828

29+
// NetworkChangeCallback is a function type called when the network changes.
30+
type NetworkChangeCallback func()
31+
2932
// MSConfigManager represents a manager for network configurations.
3033
type MSConfigManager struct {
31-
Network NetworkConfig
32-
configMutex sync.RWMutex
33-
stopRefresh func()
34+
Network NetworkConfig
35+
configMutex sync.RWMutex
36+
stopRefresh func()
37+
onNetworkChange NetworkChangeCallback
38+
networkChangeMutex sync.RWMutex
3439
}
3540

3641
// NetworkManager is an alias for backward compatibility
@@ -145,12 +150,18 @@ func (n *MSConfigManager) Networks() *[]string {
145150
return &options
146151
}
147152

153+
// SetNetworkChangeCallback sets a callback function that will be called whenever the network changes.
154+
func (n *MSConfigManager) SetNetworkChangeCallback(callback NetworkChangeCallback) {
155+
n.networkChangeMutex.Lock()
156+
defer n.networkChangeMutex.Unlock()
157+
n.onNetworkChange = callback
158+
}
159+
148160
// SwitchNetwork switches the current network configuration to the specified network.
149161
// rpcName: The name of the network configuration to switch to.
150162
// Returns any error encountered during the switch operation.
151163
func (n *MSConfigManager) SwitchNetwork(rpcName string) error {
152164
n.configMutex.Lock()
153-
defer n.configMutex.Unlock()
154165

155166
// Find the network with the specified name
156167
var targetNetwork *RPCInfos
@@ -162,6 +173,7 @@ func (n *MSConfigManager) SwitchNetwork(rpcName string) error {
162173
}
163174

164175
if targetNetwork == nil {
176+
n.configMutex.Unlock()
165177
return fmt.Errorf("unknown network: %s", rpcName)
166178
}
167179

@@ -170,6 +182,16 @@ func (n *MSConfigManager) SwitchNetwork(rpcName string) error {
170182

171183
logger.Debugf("Switched to network: %s", rpcName)
172184

185+
n.configMutex.Unlock()
186+
187+
// Call the network change callback if set
188+
n.networkChangeMutex.RLock()
189+
callback := n.onNetworkChange
190+
n.networkChangeMutex.RUnlock()
191+
if callback != nil {
192+
callback()
193+
}
194+
173195
return nil
174196
}
175197

@@ -277,15 +299,16 @@ func (n *MSConfigManager) SaveConfig(cfg *ConfigFile) error {
277299
// AddNetwork adds a new network to both memory and persistent configuration
278300
func (n *MSConfigManager) AddNetwork(name, url string, makeDefault bool) error {
279301
n.configMutex.Lock()
280-
defer n.configMutex.Unlock()
281302

282303
if name == "" || url == "" {
304+
n.configMutex.Unlock()
283305
return fmt.Errorf("name and url are required")
284306
}
285307

286308
// Load current persisted configuration
287309
cfg, err := LoadConfig()
288310
if err != nil {
311+
n.configMutex.Unlock()
289312
return fmt.Errorf("load config: %w", err)
290313
}
291314

@@ -297,6 +320,7 @@ func (n *MSConfigManager) AddNetwork(name, url string, makeDefault bool) error {
297320
nameLower := strings.ToLower(strings.TrimSpace(name))
298321
for existingName := range cfg.Networks {
299322
if strings.ToLower(strings.TrimSpace(existingName)) == nameLower {
323+
n.configMutex.Unlock()
300324
return fmt.Errorf("%w: %s", ErrNetworkAlreadyExists, name)
301325
}
302326
}
@@ -315,6 +339,7 @@ func (n *MSConfigManager) AddNetwork(name, url string, makeDefault bool) error {
315339
}
316340

317341
if err := saveConfigUnsafe(cfg); err != nil {
342+
n.configMutex.Unlock()
318343
return err
319344
}
320345

@@ -334,8 +359,22 @@ func (n *MSConfigManager) AddNetwork(name, url string, makeDefault bool) error {
334359
}
335360
n.Network.Networks = append(n.Network.Networks, newNet)
336361

362+
networkChanged := false
337363
if makeDefault {
338364
n.Network.currentNetwork = &n.Network.Networks[len(n.Network.Networks)-1]
365+
networkChanged = true
366+
}
367+
368+
n.configMutex.Unlock()
369+
370+
// Call the network change callback if set
371+
if networkChanged {
372+
n.networkChangeMutex.RLock()
373+
callback := n.onNetworkChange
374+
n.networkChangeMutex.RUnlock()
375+
if callback != nil {
376+
callback()
377+
}
339378
}
340379

341380
return nil
@@ -346,7 +385,6 @@ func (n *MSConfigManager) AddNetwork(name, url string, makeDefault bool) error {
346385
// and the current network is switched in memory as well.
347386
func (n *MSConfigManager) EditNetwork(currentName string, newURL *string, makeDefault *bool, newName *string) error {
348387
n.configMutex.Lock()
349-
defer n.configMutex.Unlock()
350388

351389
if currentName == "" {
352390
return fmt.Errorf("currentName is required")
@@ -450,10 +488,25 @@ func (n *MSConfigManager) EditNetwork(currentName string, newURL *string, makeDe
450488
}
451489

452490
// Switch current network if default requested or if current was renamed
491+
networkChanged := false
453492
if makeDefault != nil && *makeDefault {
454493
n.Network.currentNetwork = &n.Network.Networks[targetIdx]
494+
networkChanged = true
455495
} else if n.Network.currentNetwork != nil && n.Network.currentNetwork.Name == currentName && targetName != currentName {
456496
n.Network.currentNetwork = &n.Network.Networks[targetIdx]
497+
networkChanged = true
498+
}
499+
500+
n.configMutex.Unlock()
501+
502+
// Call the network change callback if set
503+
if networkChanged {
504+
n.networkChangeMutex.RLock()
505+
callback := n.onNetworkChange
506+
n.networkChangeMutex.RUnlock()
507+
if callback != nil {
508+
callback()
509+
}
457510
}
458511

459512
return nil

int/systray/systray.go

Lines changed: 142 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,173 @@
11
package systray
22

33
import (
4+
"sort"
5+
46
"fyne.io/fyne/v2"
57
"fyne.io/fyne/v2/app"
68
fyneDesktop "fyne.io/fyne/v2/driver/desktop"
9+
710
"github.com/massalabs/station/int/config"
811
"github.com/massalabs/station/int/systray/embedded"
912
"github.com/massalabs/station/int/systray/utils"
1013
)
1114

15+
// NetworkMenuRefresher holds references needed to refresh the network menu.
16+
type NetworkMenuRefresher struct {
17+
networkMenuItems []*fyne.MenuItem
18+
networkSubMenu *fyne.Menu
19+
topLevelMenu *fyne.Menu
20+
desk fyneDesktop.App
21+
cfgMgr *config.MSConfigManager
22+
}
23+
24+
// RefreshNetworkMenu updates the checkmarks in the network menu based on the current network.
25+
func (n *NetworkMenuRefresher) RefreshNetworkMenu() {
26+
if n == nil || n.cfgMgr == nil {
27+
return
28+
}
29+
30+
current := n.cfgMgr.CurrentNetwork()
31+
if current == nil {
32+
return
33+
}
34+
35+
// Update checkmarks to reflect the current network.
36+
for _, mi := range n.networkMenuItems {
37+
mi.Checked = mi.Label == current.Name
38+
}
39+
40+
// Refresh both the submenu and top-level menu to update visual checkmarks.
41+
if n.networkSubMenu != nil {
42+
n.networkSubMenu.Refresh()
43+
}
44+
if n.topLevelMenu != nil {
45+
n.topLevelMenu.Refresh()
46+
}
47+
// Also re-set the menu to ensure the system tray updates.
48+
if n.desk != nil {
49+
n.desk.SetSystemTrayMenu(n.topLevelMenu)
50+
}
51+
}
52+
1253
func MakeGUI() (fyne.App, *fyne.Menu) {
54+
stationGUI, _, _ := MakeGUIWithRefresher()
55+
return stationGUI, nil
56+
}
57+
58+
// MakeGUIWithRefresher creates the GUI and returns a refresher for the network menu.
59+
func MakeGUIWithRefresher() (fyne.App, *fyne.Menu, *NetworkMenuRefresher) {
1360
stationGUI := app.New()
1461

1562
if desk, ok := stationGUI.(fyneDesktop.App); ok {
1663
icon := fyne.NewStaticResource("logo", embedded.Logo)
1764

65+
// Try to load network configuration for the "Network" submenu.
66+
var networkSubMenu *fyne.Menu
67+
var topLevelMenu *fyne.Menu
68+
var refresher *NetworkMenuRefresher
69+
var cfgMgr *config.MSConfigManager
70+
if mgr, err := config.GetConfigManager(); err == nil && mgr != nil {
71+
cfgMgr = mgr
72+
current := mgr.CurrentNetwork()
73+
var networkMenuItems []*fyne.MenuItem
74+
75+
// Sort networks alphabetically by name.
76+
networks := make([]config.RPCInfos, len(mgr.Network.Networks))
77+
copy(networks, mgr.Network.Networks)
78+
sort.Slice(networks, func(i, j int) bool {
79+
return networks[i].Name < networks[j].Name
80+
})
81+
82+
for _, netInfo := range networks {
83+
networkName := netInfo.Name
84+
85+
item := fyne.NewMenuItem(networkName, nil)
86+
if current != nil && current.Name == networkName {
87+
item.Checked = true
88+
}
89+
90+
networkMenuItems = append(networkMenuItems, item)
91+
}
92+
93+
// Create the network submenu before setting up actions so closures can reference it.
94+
if len(networkMenuItems) > 0 {
95+
networkSubMenu = fyne.NewMenu("Network", networkMenuItems...)
96+
97+
// Create refresher for callback-based updates
98+
refresher = &NetworkMenuRefresher{
99+
networkMenuItems: networkMenuItems,
100+
networkSubMenu: networkSubMenu,
101+
cfgMgr: mgr,
102+
}
103+
104+
// Now set up the action closures with access to the submenu and desktop app.
105+
for i, netInfo := range networks {
106+
networkName := netInfo.Name
107+
item := networkMenuItems[i]
108+
109+
// Capture networkName by value in the closure.
110+
item.Action = func(name string) func() {
111+
return func() {
112+
// Switch the backend network.
113+
if err := mgr.SwitchNetwork(name); err != nil {
114+
// If switching fails, leave the UI unchanged.
115+
return
116+
}
117+
118+
// Update checkmarks using the refresher.
119+
// Note: The callback will also be triggered, but refreshing here ensures immediate UI update.
120+
if refresher != nil {
121+
refresher.RefreshNetworkMenu()
122+
}
123+
}
124+
}(networkName)
125+
}
126+
}
127+
}
128+
18129
homeShortCutMenu := fyne.NewMenuItem("Open MassaStation", nil)
19130
homeShortCutMenu.Action = func() {
20131
utils.OpenURL(&stationGUI, "https://"+config.MassaStationURL)
21132
}
22133

23-
menu := fyne.NewMenu(
24-
"MassaStation",
134+
// Build the top-level systray menu in an idiomatic Fyne way.
135+
menuItems := []*fyne.MenuItem{
25136
fyne.NewMenuItemSeparator(),
26137
homeShortCutMenu,
27-
)
138+
}
139+
140+
// If we have networks, add a "Network" submenu.
141+
if networkSubMenu != nil {
142+
networkItem := fyne.NewMenuItem("Network", nil)
143+
networkItem.ChildMenu = networkSubMenu
144+
145+
menuItems = append(
146+
menuItems,
147+
fyne.NewMenuItemSeparator(),
148+
networkItem,
149+
)
150+
}
151+
152+
menu := fyne.NewMenu("MassaStation", menuItems...)
153+
topLevelMenu = menu
154+
155+
// Update refresher with top-level menu and desktop app references
156+
if refresher != nil && cfgMgr != nil {
157+
refresher.topLevelMenu = topLevelMenu
158+
refresher.desk = desk
159+
160+
// Register the refresher callback with the config manager
161+
cfgMgr.SetNetworkChangeCallback(func() {
162+
refresher.RefreshNetworkMenu()
163+
})
164+
}
28165

29166
desk.SetSystemTrayIcon(icon)
30167
desk.SetSystemTrayMenu(menu)
31168

32-
return stationGUI, menu
169+
return stationGUI, menu, refresher
33170
}
34171

35-
return stationGUI, nil
172+
return stationGUI, nil, nil
36173
}

web/massastation/src/custom/api/useResource.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22

33
// EXTERNALS
44
import axios, { AxiosResponse } from 'axios';
5-
import { useQuery, UseQueryResult } from '@tanstack/react-query';
5+
import { useQuery, UseQueryResult, UseQueryOptions } from '@tanstack/react-query';
66

77
// LOCALS
88

9-
export function useResource<T>(resource: string): UseQueryResult<T, undefined> {
9+
export function useResource<T>(
10+
resource: string,
11+
options?: Omit<UseQueryOptions<T, undefined>, 'queryKey' | 'queryFn'>
12+
): UseQueryResult<T, undefined> {
1013
const url = `${import.meta.env.VITE_BASE_API}/${resource}`;
1114

1215
return useQuery<T, undefined>({
@@ -16,5 +19,6 @@ export function useResource<T>(resource: string): UseQueryResult<T, undefined> {
1619

1720
return data;
1821
},
22+
...options,
1923
});
2024
}

web/massastation/src/layouts/LayoutStation/LayoutStation.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,11 @@ export function LayoutStation(props: LayoutStationProps) {
4242

4343
const navigate = useNavigate();
4444

45+
// Poll network endpoint every 2 seconds to detect changes from systray
4546
const { data: network, isSuccess: isSuccessNetwork } =
46-
useResource<NetworkModel>(URL.PATH_NETWORKS);
47+
useResource<NetworkModel>(URL.PATH_NETWORKS, {
48+
refetchInterval: 2000, // Refetch every 2 seconds
49+
});
4750

4851
const {currentNetwork, setCurrentNetwork, setAvailableNetworks} = useNetworkStore();
4952

0 commit comments

Comments
 (0)