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  }