golang.zx2c4.com/wireguard/windows@v0.5.4-0.20230123132234-dcc0eb72a04b/ui/tray.go (about) 1 /* SPDX-License-Identifier: MIT 2 * 3 * Copyright (C) 2019-2022 WireGuard LLC. All Rights Reserved. 4 */ 5 6 package ui 7 8 import ( 9 "sort" 10 "strings" 11 "time" 12 13 "golang.zx2c4.com/wireguard/windows/conf" 14 "golang.zx2c4.com/wireguard/windows/l18n" 15 "golang.zx2c4.com/wireguard/windows/manager" 16 17 "github.com/lxn/walk" 18 ) 19 20 // Status + active CIDRs + separator 21 const trayTunnelActionsOffset = 3 22 23 type Tray struct { 24 *walk.NotifyIcon 25 26 // Current known tunnels by name 27 tunnels map[string]*walk.Action 28 tunnelsAreInBreakoutMenu bool 29 30 mtw *ManageTunnelsWindow 31 32 tunnelChangedCB *manager.TunnelChangeCallback 33 tunnelsChangedCB *manager.TunnelsChangeCallback 34 35 clicked func() 36 } 37 38 func NewTray(mtw *ManageTunnelsWindow) (*Tray, error) { 39 var err error 40 41 tray := &Tray{ 42 mtw: mtw, 43 tunnels: make(map[string]*walk.Action), 44 } 45 46 tray.NotifyIcon, err = walk.NewNotifyIcon(mtw) 47 if err != nil { 48 return nil, err 49 } 50 51 return tray, tray.setup() 52 } 53 54 func (tray *Tray) setup() error { 55 tray.clicked = tray.onManageTunnels 56 57 tray.SetToolTip(l18n.Sprintf("WireGuard: Deactivated")) 58 tray.SetVisible(true) 59 if icon, err := loadLogoIcon(16); err == nil { 60 tray.SetIcon(icon) 61 } 62 63 tray.MouseDown().Attach(func(x, y int, button walk.MouseButton) { 64 if button == walk.LeftButton { 65 tray.clicked() 66 } 67 }) 68 tray.MessageClicked().Attach(func() { 69 tray.clicked() 70 }) 71 72 for _, item := range [...]struct { 73 label string 74 handler walk.EventHandler 75 enabled bool 76 hidden bool 77 separator bool 78 defawlt bool 79 }{ 80 {label: l18n.Sprintf("Status: Unknown")}, 81 {label: l18n.Sprintf("Addresses: None"), hidden: true}, 82 {separator: true}, 83 {separator: true}, 84 {label: l18n.Sprintf("&Manage tunnels…"), handler: tray.onManageTunnels, enabled: true, defawlt: true}, 85 {label: l18n.Sprintf("&Import tunnel(s) from file…"), handler: tray.onImport, enabled: true, hidden: !IsAdmin}, 86 {separator: true}, 87 {label: l18n.Sprintf("&About WireGuard…"), handler: tray.onAbout, enabled: true}, 88 {label: l18n.Sprintf("E&xit"), handler: onQuit, enabled: true, hidden: !IsAdmin}, 89 } { 90 var action *walk.Action 91 if item.separator { 92 action = walk.NewSeparatorAction() 93 } else { 94 action = walk.NewAction() 95 action.SetText(item.label) 96 action.SetEnabled(item.enabled) 97 action.SetVisible(!item.hidden) 98 action.SetDefault(item.defawlt) 99 if item.handler != nil { 100 action.Triggered().Attach(item.handler) 101 } 102 } 103 104 tray.ContextMenu().Actions().Add(action) 105 } 106 tray.tunnelChangedCB = manager.IPCClientRegisterTunnelChange(tray.onTunnelChange) 107 tray.tunnelsChangedCB = manager.IPCClientRegisterTunnelsChange(tray.onTunnelsChange) 108 tray.onTunnelsChange() 109 globalState, _ := manager.IPCClientGlobalState() 110 tray.updateGlobalState(globalState) 111 112 return nil 113 } 114 115 func (tray *Tray) Dispose() error { 116 if tray.tunnelChangedCB != nil { 117 tray.tunnelChangedCB.Unregister() 118 tray.tunnelChangedCB = nil 119 } 120 if tray.tunnelsChangedCB != nil { 121 tray.tunnelsChangedCB.Unregister() 122 tray.tunnelsChangedCB = nil 123 } 124 return tray.NotifyIcon.Dispose() 125 } 126 127 func (tray *Tray) onTunnelsChange() { 128 tunnels, err := manager.IPCClientTunnels() 129 if err != nil { 130 return 131 } 132 tray.mtw.Synchronize(func() { 133 tunnelSet := make(map[string]bool, len(tunnels)) 134 for _, tunnel := range tunnels { 135 tunnelSet[tunnel.Name] = true 136 if tray.tunnels[tunnel.Name] == nil { 137 tray.addTunnelAction(&tunnel) 138 } 139 } 140 for trayTunnel := range tray.tunnels { 141 if !tunnelSet[trayTunnel] { 142 tray.removeTunnelAction(trayTunnel) 143 } 144 } 145 }) 146 } 147 148 func (tray *Tray) sortedTunnels() []string { 149 var names []string 150 for name := range tray.tunnels { 151 names = append(names, name) 152 } 153 sort.SliceStable(names, func(i, j int) bool { 154 return conf.TunnelNameIsLess(names[i], names[j]) 155 }) 156 return names 157 } 158 159 func (tray *Tray) addTunnelAction(tunnel *manager.Tunnel) { 160 tunnelAction := walk.NewAction() 161 tunnelAction.SetText(tunnel.Name) 162 tunnelAction.SetEnabled(true) 163 tunnelAction.SetCheckable(true) 164 tclosure := *tunnel 165 tunnelAction.Triggered().Attach(func() { 166 tunnelAction.SetChecked(!tunnelAction.Checked()) 167 go func() { 168 oldState, err := tclosure.Toggle() 169 if err != nil { 170 tray.mtw.Synchronize(func() { 171 raise(tray.mtw.Handle()) 172 tray.mtw.tunnelsPage.listView.selectTunnel(tclosure.Name) 173 tray.mtw.tabs.SetCurrentIndex(0) 174 if oldState == manager.TunnelUnknown { 175 showErrorCustom(tray.mtw, l18n.Sprintf("Failed to determine tunnel state"), err.Error()) 176 } else if oldState == manager.TunnelStopped { 177 showErrorCustom(tray.mtw, l18n.Sprintf("Failed to activate tunnel"), err.Error()) 178 } else if oldState == manager.TunnelStarted { 179 showErrorCustom(tray.mtw, l18n.Sprintf("Failed to deactivate tunnel"), err.Error()) 180 } 181 }) 182 } 183 }() 184 }) 185 tray.tunnels[tunnel.Name] = tunnelAction 186 187 var ( 188 idx int 189 name string 190 ) 191 for idx, name = range tray.sortedTunnels() { 192 if name == tunnel.Name { 193 break 194 } 195 } 196 197 if tray.tunnelsAreInBreakoutMenu { 198 tray.ContextMenu().Actions().At(trayTunnelActionsOffset).Menu().Actions().Insert(idx, tunnelAction) 199 } else { 200 tray.ContextMenu().Actions().Insert(trayTunnelActionsOffset+idx, tunnelAction) 201 } 202 tray.rebalanceTunnelsMenu() 203 204 go func() { 205 state, err := tclosure.State() 206 if err != nil { 207 return 208 } 209 tray.mtw.Synchronize(func() { 210 tray.setTunnelState(&tclosure, state) 211 }) 212 }() 213 } 214 215 func (tray *Tray) removeTunnelAction(tunnelName string) { 216 if tray.tunnelsAreInBreakoutMenu { 217 tray.ContextMenu().Actions().At(trayTunnelActionsOffset).Menu().Actions().Remove(tray.tunnels[tunnelName]) 218 } else { 219 tray.ContextMenu().Actions().Remove(tray.tunnels[tunnelName]) 220 } 221 delete(tray.tunnels, tunnelName) 222 tray.rebalanceTunnelsMenu() 223 } 224 225 func (tray *Tray) rebalanceTunnelsMenu() { 226 if tray.tunnelsAreInBreakoutMenu && len(tray.tunnels) <= 10 { 227 menuAction := tray.ContextMenu().Actions().At(trayTunnelActionsOffset) 228 idx := 1 229 for _, name := range tray.sortedTunnels() { 230 tray.ContextMenu().Actions().Insert(trayTunnelActionsOffset+idx, tray.tunnels[name]) 231 idx++ 232 } 233 tray.ContextMenu().Actions().Remove(menuAction) 234 menuAction.Menu().Dispose() 235 tray.tunnelsAreInBreakoutMenu = false 236 } else if !tray.tunnelsAreInBreakoutMenu && len(tray.tunnels) > 10 { 237 menu, err := walk.NewMenu() 238 if err != nil { 239 return 240 } 241 for _, name := range tray.sortedTunnels() { 242 action := tray.tunnels[name] 243 menu.Actions().Add(action) 244 tray.ContextMenu().Actions().Remove(action) 245 } 246 menuAction, err := tray.ContextMenu().Actions().InsertMenu(trayTunnelActionsOffset, menu) 247 if err != nil { 248 return 249 } 250 menuAction.SetText(l18n.Sprintf("&Tunnels")) 251 tray.tunnelsAreInBreakoutMenu = true 252 } 253 } 254 255 func (tray *Tray) onTunnelChange(tunnel *manager.Tunnel, state, globalState manager.TunnelState, err error) { 256 tray.mtw.Synchronize(func() { 257 tray.updateGlobalState(globalState) 258 if err == nil { 259 tunnelAction := tray.tunnels[tunnel.Name] 260 if tunnelAction != nil { 261 wasChecked := tunnelAction.Checked() 262 switch state { 263 case manager.TunnelStarted: 264 if !wasChecked { 265 icon, _ := iconWithOverlayForState(state, 128) 266 tray.ShowCustom(l18n.Sprintf("WireGuard Activated"), l18n.Sprintf("The %s tunnel has been activated.", tunnel.Name), icon) 267 } 268 269 case manager.TunnelStopped: 270 if wasChecked { 271 icon, _ := loadSystemIcon("imageres", -31, 128) // TODO: this icon isn't very good... 272 tray.ShowCustom(l18n.Sprintf("WireGuard Deactivated"), l18n.Sprintf("The %s tunnel has been deactivated.", tunnel.Name), icon) 273 } 274 } 275 } 276 } else if !tray.mtw.Visible() { 277 tray.ShowError(l18n.Sprintf("WireGuard Tunnel Error"), err.Error()) 278 } 279 tray.setTunnelState(tunnel, state) 280 }) 281 } 282 283 func (tray *Tray) updateGlobalState(globalState manager.TunnelState) { 284 if icon, err := iconWithOverlayForState(globalState, 16); err == nil { 285 tray.SetIcon(icon) 286 } 287 288 actions := tray.ContextMenu().Actions() 289 statusAction := actions.At(0) 290 291 tray.SetToolTip(l18n.Sprintf("WireGuard: %s", textForState(globalState, true))) 292 stateText := textForState(globalState, false) 293 stateIcon, err := iconForState(globalState, 16) 294 if err == nil { 295 statusAction.SetImage(stateIcon) 296 } 297 statusAction.SetText(l18n.Sprintf("Status: %s", stateText)) 298 299 go func() { 300 var addrs []string 301 tunnels, err := manager.IPCClientTunnels() 302 if err == nil { 303 for i := range tunnels { 304 state, err := tunnels[i].State() 305 if err == nil && state == manager.TunnelStarted { 306 config, err := tunnels[i].RuntimeConfig() 307 if err == nil { 308 for _, addr := range config.Interface.Addresses { 309 addrs = append(addrs, addr.String()) 310 } 311 } 312 } 313 } 314 } 315 tray.mtw.Synchronize(func() { 316 activeCIDRsAction := tray.ContextMenu().Actions().At(1) 317 activeCIDRsAction.SetText(l18n.Sprintf("Addresses: %s", strings.Join(addrs, l18n.EnumerationSeparator()))) 318 activeCIDRsAction.SetVisible(len(addrs) > 0) 319 }) 320 }() 321 322 for _, action := range tray.tunnels { 323 action.SetEnabled(globalState == manager.TunnelStarted || globalState == manager.TunnelStopped) 324 } 325 } 326 327 func (tray *Tray) setTunnelState(tunnel *manager.Tunnel, state manager.TunnelState) { 328 tunnelAction := tray.tunnels[tunnel.Name] 329 if tunnelAction == nil { 330 return 331 } 332 333 switch state { 334 case manager.TunnelStarted: 335 tunnelAction.SetEnabled(true) 336 tunnelAction.SetChecked(true) 337 338 case manager.TunnelStopped: 339 tunnelAction.SetChecked(false) 340 } 341 } 342 343 func (tray *Tray) UpdateFound() { 344 action := walk.NewAction() 345 action.SetText(l18n.Sprintf("An Update is Available!")) 346 menuIcon, _ := loadShieldIcon(16) 347 action.SetImage(menuIcon) 348 action.SetDefault(true) 349 showUpdateTab := func() { 350 if !tray.mtw.Visible() { 351 tray.mtw.tunnelsPage.listView.SelectFirstActiveTunnel() 352 } 353 tray.mtw.tabs.SetCurrentIndex(2) 354 raise(tray.mtw.Handle()) 355 } 356 action.Triggered().Attach(showUpdateTab) 357 tray.clicked = showUpdateTab 358 tray.ContextMenu().Actions().Insert(tray.ContextMenu().Actions().Len()-2, action) 359 360 showUpdateBalloon := func() { 361 icon, _ := loadShieldIcon(128) 362 tray.ShowCustom(l18n.Sprintf("WireGuard Update Available"), l18n.Sprintf("An update to WireGuard is now available. You are advised to update as soon as possible."), icon) 363 } 364 365 timeSinceStart := time.Now().Sub(startTime) 366 if timeSinceStart < time.Second*3 { 367 time.AfterFunc(time.Second*3-timeSinceStart, func() { 368 tray.mtw.Synchronize(showUpdateBalloon) 369 }) 370 } else { 371 showUpdateBalloon() 372 } 373 } 374 375 func (tray *Tray) onManageTunnels() { 376 tray.mtw.tunnelsPage.listView.SelectFirstActiveTunnel() 377 tray.mtw.tabs.SetCurrentIndex(0) 378 raise(tray.mtw.Handle()) 379 } 380 381 func (tray *Tray) onAbout() { 382 if tray.mtw.Visible() { 383 onAbout(tray.mtw) 384 } else { 385 onAbout(nil) 386 } 387 } 388 389 func (tray *Tray) onImport() { 390 raise(tray.mtw.Handle()) 391 tray.mtw.tunnelsPage.onImport() 392 }