golang.zx2c4.com/wireguard/windows@v0.5.4-0.20230123132234-dcc0eb72a04b/ui/tunnelspage.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  	"archive/zip"
    10  	"errors"
    11  	"fmt"
    12  	"io"
    13  	"os"
    14  	"path/filepath"
    15  	"sort"
    16  	"strings"
    17  
    18  	"github.com/lxn/walk"
    19  
    20  	"golang.zx2c4.com/wireguard/windows/conf"
    21  	"golang.zx2c4.com/wireguard/windows/l18n"
    22  	"golang.zx2c4.com/wireguard/windows/manager"
    23  )
    24  
    25  type TunnelsPage struct {
    26  	*walk.TabPage
    27  
    28  	listView      *ListView
    29  	listContainer walk.Container
    30  	listToolbar   *walk.ToolBar
    31  	confView      *ConfView
    32  	fillerButton  *walk.PushButton
    33  	fillerHandler func()
    34  
    35  	fillerContainer        *walk.Composite
    36  	currentTunnelContainer *walk.Composite
    37  }
    38  
    39  func NewTunnelsPage() (*TunnelsPage, error) {
    40  	var err error
    41  	var disposables walk.Disposables
    42  	defer disposables.Treat()
    43  
    44  	tp := new(TunnelsPage)
    45  	if tp.TabPage, err = walk.NewTabPage(); err != nil {
    46  		return nil, err
    47  	}
    48  	disposables.Add(tp)
    49  
    50  	tp.SetTitle(l18n.Sprintf("Tunnels"))
    51  	tp.SetLayout(walk.NewHBoxLayout())
    52  
    53  	tp.listContainer, _ = walk.NewComposite(tp)
    54  	vlayout := walk.NewVBoxLayout()
    55  	vlayout.SetMargins(walk.Margins{})
    56  	vlayout.SetSpacing(0)
    57  	tp.listContainer.SetLayout(vlayout)
    58  
    59  	if tp.listView, err = NewListView(tp.listContainer); err != nil {
    60  		return nil, err
    61  	}
    62  
    63  	if tp.currentTunnelContainer, err = walk.NewComposite(tp); err != nil {
    64  		return nil, err
    65  	}
    66  	vlayout = walk.NewVBoxLayout()
    67  	vlayout.SetMargins(walk.Margins{})
    68  	tp.currentTunnelContainer.SetLayout(vlayout)
    69  
    70  	if tp.fillerContainer, err = walk.NewComposite(tp); err != nil {
    71  		return nil, err
    72  	}
    73  	tp.fillerContainer.SetVisible(false)
    74  	hlayout := walk.NewHBoxLayout()
    75  	hlayout.SetMargins(walk.Margins{})
    76  	tp.fillerContainer.SetLayout(hlayout)
    77  	tp.fillerButton, _ = walk.NewPushButton(tp.fillerContainer)
    78  	tp.fillerButton.SetMinMaxSize(walk.Size{200, 0}, walk.Size{200, 0})
    79  	tp.fillerButton.SetVisible(IsAdmin)
    80  	tp.fillerButton.Clicked().Attach(func() {
    81  		if tp.fillerHandler != nil {
    82  			tp.fillerHandler()
    83  		}
    84  	})
    85  
    86  	if tp.confView, err = NewConfView(tp.currentTunnelContainer); err != nil {
    87  		return nil, err
    88  	}
    89  
    90  	controlsContainer, err := walk.NewComposite(tp.currentTunnelContainer)
    91  	if err != nil {
    92  		return nil, err
    93  	}
    94  	hlayout = walk.NewHBoxLayout()
    95  	hlayout.SetMargins(walk.Margins{})
    96  	controlsContainer.SetLayout(hlayout)
    97  
    98  	walk.NewHSpacer(controlsContainer)
    99  
   100  	editTunnel, err := walk.NewPushButton(controlsContainer)
   101  	if err != nil {
   102  		return nil, err
   103  	}
   104  	editTunnel.SetEnabled(false)
   105  	tp.listView.CurrentIndexChanged().Attach(func() {
   106  		editTunnel.SetEnabled(tp.listView.CurrentIndex() > -1)
   107  	})
   108  	editTunnel.SetText(l18n.Sprintf("&Edit"))
   109  	editTunnel.Clicked().Attach(tp.onEditTunnel)
   110  	editTunnel.SetVisible(IsAdmin)
   111  
   112  	disposables.Spare()
   113  
   114  	tp.listView.ItemCountChanged().Attach(tp.onTunnelsChanged)
   115  	tp.listView.SelectedIndexesChanged().Attach(tp.onSelectedTunnelsChanged)
   116  	tp.listView.ItemActivated().Attach(tp.onTunnelsViewItemActivated)
   117  	tp.listView.CurrentIndexChanged().Attach(tp.updateConfView)
   118  	tp.listView.Load(false)
   119  	tp.onTunnelsChanged()
   120  
   121  	return tp, nil
   122  }
   123  
   124  func (tp *TunnelsPage) CreateToolbar() error {
   125  	if tp.listToolbar != nil {
   126  		return nil
   127  	}
   128  
   129  	// HACK: Because of https://github.com/lxn/walk/issues/481
   130  	// we need to put the ToolBar into its own Composite.
   131  	toolBarContainer, err := walk.NewComposite(tp.listContainer)
   132  	if err != nil {
   133  		return err
   134  	}
   135  	toolBarContainer.SetDoubleBuffering(true)
   136  	hlayout := walk.NewHBoxLayout()
   137  	hlayout.SetMargins(walk.Margins{})
   138  	toolBarContainer.SetLayout(hlayout)
   139  	toolBarContainer.SetVisible(IsAdmin)
   140  
   141  	if tp.listToolbar, err = walk.NewToolBarWithOrientationAndButtonStyle(toolBarContainer, walk.Horizontal, walk.ToolBarButtonImageBeforeText); err != nil {
   142  		return err
   143  	}
   144  
   145  	addMenu, err := walk.NewMenu()
   146  	if err != nil {
   147  		return err
   148  	}
   149  	tp.AddDisposable(addMenu)
   150  	importAction := walk.NewAction()
   151  	importAction.SetText(l18n.Sprintf("&Import tunnel(s) from file…"))
   152  	importActionIcon, _ := loadSystemIcon("imageres", -3, 16)
   153  	importAction.SetImage(importActionIcon)
   154  	importAction.SetShortcut(walk.Shortcut{walk.ModControl, walk.KeyO})
   155  	importAction.SetDefault(true)
   156  	importAction.Triggered().Attach(tp.onImport)
   157  	addMenu.Actions().Add(importAction)
   158  	addAction := walk.NewAction()
   159  	addAction.SetText(l18n.Sprintf("Add &empty tunnel…"))
   160  	addActionIcon, _ := loadSystemIcon("imageres", -2, 16)
   161  	addAction.SetImage(addActionIcon)
   162  	addAction.SetShortcut(walk.Shortcut{walk.ModControl, walk.KeyN})
   163  	addAction.Triggered().Attach(tp.onAddTunnel)
   164  	addMenu.Actions().Add(addAction)
   165  	addMenuAction := walk.NewMenuAction(addMenu)
   166  	addMenuActionIcon, _ := loadSystemIcon("shell32", -258, 16)
   167  	addMenuAction.SetImage(addMenuActionIcon)
   168  	addMenuAction.SetText(l18n.Sprintf("Add Tunnel"))
   169  	addMenuAction.SetToolTip(importAction.Text())
   170  	addMenuAction.Triggered().Attach(tp.onImport)
   171  	tp.listToolbar.Actions().Add(addMenuAction)
   172  
   173  	tp.listToolbar.Actions().Add(walk.NewSeparatorAction())
   174  
   175  	deleteAction := walk.NewAction()
   176  	deleteActionIcon, _ := loadSystemIcon("shell32", -240, 16)
   177  	deleteAction.SetImage(deleteActionIcon)
   178  	deleteAction.SetShortcut(walk.Shortcut{0, walk.KeyDelete})
   179  	deleteAction.SetToolTip(l18n.Sprintf("Remove selected tunnel(s)"))
   180  	deleteAction.Triggered().Attach(tp.onDelete)
   181  	tp.listToolbar.Actions().Add(deleteAction)
   182  	tp.listToolbar.Actions().Add(walk.NewSeparatorAction())
   183  
   184  	exportAction := walk.NewAction()
   185  	exportActionIcon, _ := loadSystemIcon("imageres", -174, 16)
   186  	exportAction.SetImage(exportActionIcon)
   187  	exportAction.SetToolTip(l18n.Sprintf("Export all tunnels to zip"))
   188  	exportAction.Triggered().Attach(tp.onExportTunnels)
   189  	tp.listToolbar.Actions().Add(exportAction)
   190  
   191  	fixContainerWidthToToolbarWidth := func() {
   192  		toolbarWidth := tp.listToolbar.SizeHint().Width
   193  		tp.listContainer.SetMinMaxSizePixels(walk.Size{toolbarWidth, 0}, walk.Size{toolbarWidth, 0})
   194  	}
   195  	fixContainerWidthToToolbarWidth()
   196  	tp.listToolbar.SizeChanged().Attach(fixContainerWidthToToolbarWidth)
   197  
   198  	contextMenu, err := walk.NewMenu()
   199  	if err != nil {
   200  		return err
   201  	}
   202  	tp.listView.AddDisposable(contextMenu)
   203  	toggleAction := walk.NewAction()
   204  	toggleAction.SetText(l18n.Sprintf("&Toggle"))
   205  	toggleAction.SetDefault(true)
   206  	toggleAction.Triggered().Attach(tp.onTunnelsViewItemActivated)
   207  	contextMenu.Actions().Add(toggleAction)
   208  	contextMenu.Actions().Add(walk.NewSeparatorAction())
   209  	importAction2 := walk.NewAction()
   210  	importAction2.SetText(l18n.Sprintf("&Import tunnel(s) from file…"))
   211  	importAction2.SetShortcut(walk.Shortcut{walk.ModControl, walk.KeyO})
   212  	importAction2.Triggered().Attach(tp.onImport)
   213  	importAction2.SetVisible(IsAdmin)
   214  	contextMenu.Actions().Add(importAction2)
   215  	tp.ShortcutActions().Add(importAction2)
   216  	addAction2 := walk.NewAction()
   217  	addAction2.SetText(l18n.Sprintf("Add &empty tunnel…"))
   218  	addAction2.SetShortcut(walk.Shortcut{walk.ModControl, walk.KeyN})
   219  	addAction2.Triggered().Attach(tp.onAddTunnel)
   220  	addAction2.SetVisible(IsAdmin)
   221  	contextMenu.Actions().Add(addAction2)
   222  	tp.ShortcutActions().Add(addAction2)
   223  	exportAction2 := walk.NewAction()
   224  	exportAction2.SetText(l18n.Sprintf("Export all tunnels to &zip…"))
   225  	exportAction2.Triggered().Attach(tp.onExportTunnels)
   226  	exportAction2.SetVisible(IsAdmin)
   227  	contextMenu.Actions().Add(exportAction2)
   228  	contextMenu.Actions().Add(walk.NewSeparatorAction())
   229  	editAction := walk.NewAction()
   230  	editAction.SetText(l18n.Sprintf("Edit &selected tunnel…"))
   231  	editAction.SetShortcut(walk.Shortcut{walk.ModControl, walk.KeyE})
   232  	editAction.SetVisible(IsAdmin)
   233  	editAction.Triggered().Attach(tp.onEditTunnel)
   234  	contextMenu.Actions().Add(editAction)
   235  	tp.ShortcutActions().Add(editAction)
   236  	deleteAction2 := walk.NewAction()
   237  	deleteAction2.SetText(l18n.Sprintf("&Remove selected tunnel(s)"))
   238  	deleteAction2.SetShortcut(walk.Shortcut{0, walk.KeyDelete})
   239  	deleteAction2.SetVisible(IsAdmin)
   240  	deleteAction2.Triggered().Attach(tp.onDelete)
   241  	contextMenu.Actions().Add(deleteAction2)
   242  	tp.listView.ShortcutActions().Add(deleteAction2)
   243  	selectAllAction := walk.NewAction()
   244  	selectAllAction.SetText(l18n.Sprintf("Select &all"))
   245  	selectAllAction.SetShortcut(walk.Shortcut{walk.ModControl, walk.KeyA})
   246  	selectAllAction.SetVisible(IsAdmin)
   247  	selectAllAction.Triggered().Attach(tp.onSelectAll)
   248  	contextMenu.Actions().Add(selectAllAction)
   249  	tp.listView.ShortcutActions().Add(selectAllAction)
   250  	tp.listView.SetContextMenu(contextMenu)
   251  
   252  	setSelectionOrientedOptions := func() {
   253  		selected := len(tp.listView.SelectedIndexes())
   254  		all := len(tp.listView.model.tunnels)
   255  		deleteAction.SetEnabled(selected > 0)
   256  		deleteAction2.SetEnabled(selected > 0)
   257  		toggleAction.SetEnabled(selected == 1)
   258  		selectAllAction.SetEnabled(selected < all)
   259  		editAction.SetEnabled(selected == 1)
   260  	}
   261  	tp.listView.SelectedIndexesChanged().Attach(setSelectionOrientedOptions)
   262  	setSelectionOrientedOptions()
   263  	setExport := func() {
   264  		all := len(tp.listView.model.tunnels)
   265  		exportAction.SetEnabled(all > 0)
   266  		exportAction2.SetEnabled(all > 0)
   267  	}
   268  	setExportRange := func(from, to int) { setExport() }
   269  	tp.listView.model.RowsInserted().Attach(setExportRange)
   270  	tp.listView.model.RowsRemoved().Attach(setExportRange)
   271  	tp.listView.model.RowsReset().Attach(setExport)
   272  	setExport()
   273  
   274  	return nil
   275  }
   276  
   277  func (tp *TunnelsPage) updateConfView() {
   278  	tp.confView.SetTunnel(tp.listView.CurrentTunnel())
   279  }
   280  
   281  func (tp *TunnelsPage) importFiles(paths []string) {
   282  	go func() {
   283  		syncedMsgBox := func(title, message string, flags walk.MsgBoxStyle) {
   284  			tp.Synchronize(func() {
   285  				walk.MsgBox(tp.Form(), title, message, flags)
   286  			})
   287  		}
   288  		type unparsedConfig struct {
   289  			Name   string
   290  			Config string
   291  		}
   292  
   293  		var (
   294  			unparsedConfigs []unparsedConfig
   295  			lastErr         error
   296  		)
   297  
   298  		for _, path := range paths {
   299  			switch strings.ToLower(filepath.Ext(path)) {
   300  			case ".conf":
   301  				textConfig, err := os.ReadFile(path)
   302  				if err != nil {
   303  					lastErr = err
   304  					continue
   305  				}
   306  				unparsedConfigs = append(unparsedConfigs, unparsedConfig{Name: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), Config: string(textConfig)})
   307  			case ".zip":
   308  				// 1 .conf + 1 error .zip edge case?
   309  				r, err := zip.OpenReader(path)
   310  				if err != nil {
   311  					lastErr = err
   312  					continue
   313  				}
   314  
   315  				for _, f := range r.File {
   316  					if strings.ToLower(filepath.Ext(f.Name)) != ".conf" {
   317  						continue
   318  					}
   319  
   320  					rc, err := f.Open()
   321  					if err != nil {
   322  						lastErr = err
   323  						continue
   324  					}
   325  					textConfig, err := io.ReadAll(rc)
   326  					rc.Close()
   327  					if err != nil {
   328  						lastErr = err
   329  						continue
   330  					}
   331  					unparsedConfigs = append(unparsedConfigs, unparsedConfig{Name: strings.TrimSuffix(filepath.Base(f.Name), filepath.Ext(f.Name)), Config: string(textConfig)})
   332  				}
   333  
   334  				r.Close()
   335  			}
   336  		}
   337  
   338  		if lastErr != nil || unparsedConfigs == nil {
   339  			if lastErr == nil {
   340  				lastErr = errors.New(l18n.Sprintf("no configuration files were found"))
   341  			}
   342  			syncedMsgBox(l18n.Sprintf("Error"), l18n.Sprintf("Could not import selected configuration: %v", lastErr), walk.MsgBoxIconWarning)
   343  			return
   344  		}
   345  
   346  		// Add in reverse order so that the first one is selected.
   347  		sort.Slice(unparsedConfigs, func(i, j int) bool {
   348  			return conf.TunnelNameIsLess(unparsedConfigs[j].Name, unparsedConfigs[i].Name)
   349  		})
   350  
   351  		existingTunnelList, err := manager.IPCClientTunnels()
   352  		if err != nil {
   353  			syncedMsgBox(l18n.Sprintf("Error"), l18n.Sprintf("Could not enumerate existing tunnels: %v", lastErr), walk.MsgBoxIconWarning)
   354  			return
   355  		}
   356  		existingLowerTunnels := make(map[string]bool, len(existingTunnelList))
   357  		for _, tunnel := range existingTunnelList {
   358  			existingLowerTunnels[strings.ToLower(tunnel.Name)] = true
   359  		}
   360  
   361  		configCount := 0
   362  		tp.listView.SetSuspendTunnelsUpdate(true)
   363  		for _, unparsedConfig := range unparsedConfigs {
   364  			if existingLowerTunnels[strings.ToLower(unparsedConfig.Name)] {
   365  				lastErr = errors.New(l18n.Sprintf("Another tunnel already exists with the name ‘%s’", unparsedConfig.Name))
   366  				continue
   367  			}
   368  			config, err := conf.FromWgQuickWithUnknownEncoding(unparsedConfig.Config, unparsedConfig.Name)
   369  			if err != nil {
   370  				lastErr = err
   371  				continue
   372  			}
   373  			_, err = manager.IPCClientNewTunnel(config)
   374  			if err != nil {
   375  				lastErr = err
   376  				continue
   377  			}
   378  			configCount++
   379  		}
   380  		tp.listView.SetSuspendTunnelsUpdate(false)
   381  
   382  		m, n := configCount, len(unparsedConfigs)
   383  		switch {
   384  		case n == 1 && m != n:
   385  			syncedMsgBox(l18n.Sprintf("Error"), l18n.Sprintf("Unable to import configuration: %v", lastErr), walk.MsgBoxIconWarning)
   386  		case n == 1 && m == n:
   387  			// nothing
   388  		case m == n:
   389  			syncedMsgBox(l18n.Sprintf("Imported tunnels"), l18n.Sprintf("Imported %d tunnels", m), walk.MsgBoxIconInformation)
   390  		case m != n:
   391  			syncedMsgBox(l18n.Sprintf("Imported tunnels"), l18n.Sprintf("Imported %d of %d tunnels", m, n), walk.MsgBoxIconWarning)
   392  		}
   393  	}()
   394  }
   395  
   396  func (tp *TunnelsPage) exportTunnels(filePath string) {
   397  	writeFileWithOverwriteHandling(tp.Form(), filePath, func(file *os.File) error {
   398  		writer := zip.NewWriter(file)
   399  
   400  		for _, tunnel := range tp.listView.model.tunnels {
   401  			cfg, err := tunnel.StoredConfig()
   402  			if err != nil {
   403  				return fmt.Errorf("onExportTunnels: tunnel.StoredConfig failed: %w", err)
   404  			}
   405  
   406  			w, err := writer.Create(tunnel.Name + ".conf")
   407  			if err != nil {
   408  				return fmt.Errorf("onExportTunnels: writer.Create failed: %w", err)
   409  			}
   410  
   411  			if _, err := w.Write(([]byte)(cfg.ToWgQuick())); err != nil {
   412  				return fmt.Errorf("onExportTunnels: cfg.ToWgQuick failed: %w", err)
   413  			}
   414  		}
   415  
   416  		return writer.Close()
   417  	})
   418  }
   419  
   420  func (tp *TunnelsPage) addTunnel(config *conf.Config) {
   421  	_, err := manager.IPCClientNewTunnel(config)
   422  	if err != nil {
   423  		showErrorCustom(tp.Form(), l18n.Sprintf("Unable to create tunnel"), err.Error())
   424  	}
   425  }
   426  
   427  // Handlers
   428  
   429  func (tp *TunnelsPage) onTunnelsViewItemActivated() {
   430  	go func() {
   431  		globalState, err := manager.IPCClientGlobalState()
   432  		if err != nil || (globalState != manager.TunnelStarted && globalState != manager.TunnelStopped) {
   433  			return
   434  		}
   435  		oldState, err := tp.listView.CurrentTunnel().Toggle()
   436  		if err != nil {
   437  			tp.Synchronize(func() {
   438  				if oldState == manager.TunnelUnknown {
   439  					showErrorCustom(tp.Form(), l18n.Sprintf("Failed to determine tunnel state"), err.Error())
   440  				} else if oldState == manager.TunnelStopped {
   441  					showErrorCustom(tp.Form(), l18n.Sprintf("Failed to activate tunnel"), err.Error())
   442  				} else if oldState == manager.TunnelStarted {
   443  					showErrorCustom(tp.Form(), l18n.Sprintf("Failed to deactivate tunnel"), err.Error())
   444  				}
   445  			})
   446  			return
   447  		}
   448  	}()
   449  }
   450  
   451  func (tp *TunnelsPage) onEditTunnel() {
   452  	tunnel := tp.listView.CurrentTunnel()
   453  	if tunnel == nil {
   454  		return
   455  	}
   456  
   457  	if config := runEditDialog(tp.Form(), tunnel); config != nil {
   458  		go func() {
   459  			priorState, err := tunnel.State()
   460  			tunnel.Delete()
   461  			tunnel.WaitForStop()
   462  			tunnel, err2 := manager.IPCClientNewTunnel(config)
   463  			if err == nil && err2 == nil && (priorState == manager.TunnelStarting || priorState == manager.TunnelStarted) {
   464  				tunnel.Start()
   465  			}
   466  		}()
   467  	}
   468  }
   469  
   470  func (tp *TunnelsPage) onAddTunnel() {
   471  	if config := runEditDialog(tp.Form(), nil); config != nil {
   472  		// Save new
   473  		tp.addTunnel(config)
   474  	}
   475  }
   476  
   477  func (tp *TunnelsPage) onDelete() {
   478  	indices := tp.listView.SelectedIndexes()
   479  	if len(indices) == 0 {
   480  		return
   481  	}
   482  
   483  	var title, question string
   484  	if len(indices) > 1 {
   485  		tunnelCount := len(indices)
   486  		title = l18n.Sprintf("Delete %d tunnels", tunnelCount)
   487  		question = l18n.Sprintf("Are you sure you would like to delete %d tunnels?", tunnelCount)
   488  	} else {
   489  		tunnelName := tp.listView.model.tunnels[indices[0]].Name
   490  		title = l18n.Sprintf("Delete tunnel ‘%s’", tunnelName)
   491  		question = l18n.Sprintf("Are you sure you would like to delete tunnel ‘%s’?", tunnelName)
   492  	}
   493  	if walk.DlgCmdNo == walk.MsgBox(
   494  		tp.Form(),
   495  		title,
   496  		l18n.Sprintf("%s You cannot undo this action.", question),
   497  		walk.MsgBoxYesNo|walk.MsgBoxIconWarning) {
   498  		return
   499  	}
   500  
   501  	selectTunnelAfter := ""
   502  	if len(indices) < len(tp.listView.model.tunnels) {
   503  		sort.Ints(indices)
   504  		max := 0
   505  		for i, idx := range indices {
   506  			if idx+1 < len(tp.listView.model.tunnels) && (i+1 == len(indices) || idx+1 != indices[i+1]) {
   507  				max = idx + 1
   508  			} else if idx-1 >= 0 && (i == 0 || idx-1 != indices[i-1]) {
   509  				max = idx - 1
   510  			}
   511  		}
   512  		selectTunnelAfter = tp.listView.model.tunnels[max].Name
   513  	}
   514  	if len(selectTunnelAfter) > 0 {
   515  		tp.listView.selectTunnel(selectTunnelAfter)
   516  	}
   517  
   518  	tunnelsToDelete := make([]manager.Tunnel, len(indices))
   519  	for i, j := range indices {
   520  		tunnelsToDelete[i] = tp.listView.model.tunnels[j]
   521  	}
   522  	go func() {
   523  		tp.listView.SetSuspendTunnelsUpdate(true)
   524  		var errors []error
   525  		for _, tunnel := range tunnelsToDelete {
   526  			err := tunnel.Delete()
   527  			if err != nil && (len(errors) == 0 || errors[len(errors)-1].Error() != err.Error()) {
   528  				errors = append(errors, err)
   529  			}
   530  		}
   531  		tp.listView.SetSuspendTunnelsUpdate(false)
   532  		if len(errors) > 0 {
   533  			tp.listView.Synchronize(func() {
   534  				if len(errors) == 1 {
   535  					showErrorCustom(tp.Form(), l18n.Sprintf("Unable to delete tunnel"), l18n.Sprintf("A tunnel was unable to be removed: %s", errors[0].Error()))
   536  				} else {
   537  					showErrorCustom(tp.Form(), l18n.Sprintf("Unable to delete tunnels"), l18n.Sprintf("%d tunnels were unable to be removed.", len(errors)))
   538  				}
   539  			})
   540  		}
   541  	}()
   542  }
   543  
   544  func (tp *TunnelsPage) onSelectAll() {
   545  	tp.listView.SetSelectedIndexes([]int{-1})
   546  }
   547  
   548  func (tp *TunnelsPage) onImport() {
   549  	dlg := walk.FileDialog{
   550  		Filter: l18n.Sprintf("Configuration Files (*.zip, *.conf)|*.zip;*.conf|All Files (*.*)|*.*"),
   551  		Title:  l18n.Sprintf("Import tunnel(s) from file"),
   552  	}
   553  
   554  	if ok, _ := dlg.ShowOpenMultiple(tp.Form()); !ok {
   555  		return
   556  	}
   557  
   558  	tp.importFiles(dlg.FilePaths)
   559  }
   560  
   561  func (tp *TunnelsPage) onExportTunnels() {
   562  	dlg := walk.FileDialog{
   563  		Filter: l18n.Sprintf("Configuration ZIP Files (*.zip)|*.zip"),
   564  		Title:  l18n.Sprintf("Export tunnels to zip"),
   565  	}
   566  
   567  	if ok, _ := dlg.ShowSave(tp.Form()); !ok {
   568  		return
   569  	}
   570  
   571  	if !strings.HasSuffix(dlg.FilePath, ".zip") {
   572  		dlg.FilePath += ".zip"
   573  	}
   574  
   575  	tp.exportTunnels(dlg.FilePath)
   576  }
   577  
   578  func (tp *TunnelsPage) swapFiller(enabled bool) bool {
   579  	if tp.fillerContainer.Visible() == enabled {
   580  		return enabled
   581  	}
   582  	tp.SetSuspended(true)
   583  	tp.fillerContainer.SetVisible(enabled)
   584  	tp.currentTunnelContainer.SetVisible(!enabled)
   585  	tp.SetSuspended(false)
   586  	return enabled
   587  }
   588  
   589  func (tp *TunnelsPage) onTunnelsChanged() {
   590  	if tp.swapFiller(tp.listView.model.RowCount() == 0) {
   591  		tp.fillerButton.SetText(l18n.Sprintf("Import tunnel(s) from file"))
   592  		tp.fillerHandler = tp.onImport
   593  	}
   594  }
   595  
   596  func (tp *TunnelsPage) onSelectedTunnelsChanged() {
   597  	if tp.listView.model.RowCount() == 0 {
   598  		return
   599  	}
   600  	indices := tp.listView.SelectedIndexes()
   601  	tunnelCount := len(indices)
   602  	if tp.swapFiller(tunnelCount > 1) {
   603  		tp.fillerButton.SetText(l18n.Sprintf("Delete %d tunnels", tunnelCount))
   604  		tp.fillerHandler = tp.onDelete
   605  	}
   606  }