Skip to content

Commit 8f23068

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

File tree

5 files changed

+217
-13
lines changed

5 files changed

+217
-13
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.New()
9090
server := api.NewServer(serverFlags, configManager)
9191

9292
update.StartUpdateCheck(&stationGUI, systrayMenu)

int/config/manager.go

Lines changed: 69 additions & 5 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
@@ -130,6 +135,8 @@ func newMSConfigManager() (*MSConfigManager, error) {
130135
}
131136

132137
func (n *MSConfigManager) CurrentNetwork() *RPCInfos {
138+
n.configMutex.RLock()
139+
defer n.configMutex.RUnlock()
133140
return n.Network.currentNetwork
134141
}
135142

@@ -145,12 +152,28 @@ func (n *MSConfigManager) Networks() *[]string {
145152
return &options
146153
}
147154

155+
// GetNetworkInfos returns a thread-safe copy of all network configurations.
156+
func (n *MSConfigManager) GetNetworkInfos() []RPCInfos {
157+
n.configMutex.RLock()
158+
defer n.configMutex.RUnlock()
159+
160+
networks := make([]RPCInfos, len(n.Network.Networks))
161+
copy(networks, n.Network.Networks)
162+
return networks
163+
}
164+
165+
// SetNetworkChangeCallback sets a callback function that will be called whenever the network changes.
166+
func (n *MSConfigManager) SetNetworkChangeCallback(callback NetworkChangeCallback) {
167+
n.networkChangeMutex.Lock()
168+
defer n.networkChangeMutex.Unlock()
169+
n.onNetworkChange = callback
170+
}
171+
148172
// SwitchNetwork switches the current network configuration to the specified network.
149173
// rpcName: The name of the network configuration to switch to.
150174
// Returns any error encountered during the switch operation.
151175
func (n *MSConfigManager) SwitchNetwork(rpcName string) error {
152176
n.configMutex.Lock()
153-
defer n.configMutex.Unlock()
154177

155178
// Find the network with the specified name
156179
var targetNetwork *RPCInfos
@@ -162,6 +185,7 @@ func (n *MSConfigManager) SwitchNetwork(rpcName string) error {
162185
}
163186

164187
if targetNetwork == nil {
188+
n.configMutex.Unlock()
165189
return fmt.Errorf("unknown network: %s", rpcName)
166190
}
167191

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

171195
logger.Debugf("Switched to network: %s", rpcName)
172196

197+
n.configMutex.Unlock()
198+
199+
// Call the network change callback if set
200+
n.networkChangeMutex.RLock()
201+
callback := n.onNetworkChange
202+
n.networkChangeMutex.RUnlock()
203+
if callback != nil {
204+
callback()
205+
}
206+
173207
return nil
174208
}
175209

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

282315
if name == "" || url == "" {
316+
n.configMutex.Unlock()
283317
return fmt.Errorf("name and url are required")
284318
}
285319

286320
// Load current persisted configuration
287321
cfg, err := LoadConfig()
288322
if err != nil {
323+
n.configMutex.Unlock()
289324
return fmt.Errorf("load config: %w", err)
290325
}
291326

@@ -297,6 +332,7 @@ func (n *MSConfigManager) AddNetwork(name, url string, makeDefault bool) error {
297332
nameLower := strings.ToLower(strings.TrimSpace(name))
298333
for existingName := range cfg.Networks {
299334
if strings.ToLower(strings.TrimSpace(existingName)) == nameLower {
335+
n.configMutex.Unlock()
300336
return fmt.Errorf("%w: %s", ErrNetworkAlreadyExists, name)
301337
}
302338
}
@@ -315,6 +351,7 @@ func (n *MSConfigManager) AddNetwork(name, url string, makeDefault bool) error {
315351
}
316352

317353
if err := saveConfigUnsafe(cfg); err != nil {
354+
n.configMutex.Unlock()
318355
return err
319356
}
320357

@@ -334,8 +371,22 @@ func (n *MSConfigManager) AddNetwork(name, url string, makeDefault bool) error {
334371
}
335372
n.Network.Networks = append(n.Network.Networks, newNet)
336373

374+
networkChanged := false
337375
if makeDefault {
338376
n.Network.currentNetwork = &n.Network.Networks[len(n.Network.Networks)-1]
377+
networkChanged = true
378+
}
379+
380+
n.configMutex.Unlock()
381+
382+
// Call the network change callback if set
383+
if networkChanged {
384+
n.networkChangeMutex.RLock()
385+
callback := n.onNetworkChange
386+
n.networkChangeMutex.RUnlock()
387+
if callback != nil {
388+
callback()
389+
}
339390
}
340391

341392
return nil
@@ -450,10 +501,23 @@ func (n *MSConfigManager) EditNetwork(currentName string, newURL *string, makeDe
450501
}
451502

452503
// Switch current network if default requested or if current was renamed
504+
networkChanged := false
453505
if makeDefault != nil && *makeDefault {
454506
n.Network.currentNetwork = &n.Network.Networks[targetIdx]
507+
networkChanged = true
455508
} else if n.Network.currentNetwork != nil && n.Network.currentNetwork.Name == currentName && targetName != currentName {
456509
n.Network.currentNetwork = &n.Network.Networks[targetIdx]
510+
networkChanged = true
511+
}
512+
513+
// Call the network change callback if set
514+
if networkChanged {
515+
n.networkChangeMutex.RLock()
516+
callback := n.onNetworkChange
517+
n.networkChangeMutex.RUnlock()
518+
if callback != nil {
519+
callback()
520+
}
457521
}
458522

459523
return nil

int/systray/systray.go

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,161 @@
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

12-
func MakeGUI() (fyne.App, *fyne.Menu) {
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+
53+
// New creates the GUI and returns the systray menu.
54+
func New() (fyne.App, *fyne.Menu) {
1355
stationGUI := app.New()
1456

1557
if desk, ok := stationGUI.(fyneDesktop.App); ok {
1658
icon := fyne.NewStaticResource("logo", embedded.Logo)
1759

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

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

29160
desk.SetSystemTrayIcon(icon)
30161
desk.SetSystemTrayMenu(menu)

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: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ type SwitchNetworkBody = Record<string, never>; // no body expected
2323

2424
export const DEFAULT_THEME = 'theme-dark-v2';
2525

26+
export const NETWORK_REFRESH_INTERVAL = 3000;
27+
2628
export function LayoutStation(props: LayoutStationProps) {
2729
const { children, navigator, onSetTheme, storedTheme } = props;
2830

@@ -42,8 +44,11 @@ export function LayoutStation(props: LayoutStationProps) {
4244

4345
const navigate = useNavigate();
4446

47+
// Poll network endpoint every 2 seconds to detect changes from systray
4548
const { data: network, isSuccess: isSuccessNetwork } =
46-
useResource<NetworkModel>(URL.PATH_NETWORKS);
49+
useResource<NetworkModel>(URL.PATH_NETWORKS, {
50+
refetchInterval: NETWORK_REFRESH_INTERVAL,
51+
});
4752

4853
const {currentNetwork, setCurrentNetwork, setAvailableNetworks} = useNetworkStore();
4954

0 commit comments

Comments
 (0)