1
0
mirror of https://git.zx2c4.com/wireguard-windows synced 2024-11-10 16:59:18 +00:00
wireguard-windows/ui/tunnelspage.go
Jason A. Donenfeld 6ed37f30f5 global: bump date
Signed-off-by: Jason A. Donenfeld <Jason@zx2c4.com>
2022-01-06 17:28:13 +01:00

607 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* SPDX-License-Identifier: MIT
*
* Copyright (C) 2019-2022 WireGuard LLC. All Rights Reserved.
*/
package ui
import (
"archive/zip"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"github.com/lxn/walk"
"golang.zx2c4.com/wireguard/windows/conf"
"golang.zx2c4.com/wireguard/windows/l18n"
"golang.zx2c4.com/wireguard/windows/manager"
)
type TunnelsPage struct {
*walk.TabPage
listView *ListView
listContainer walk.Container
listToolbar *walk.ToolBar
confView *ConfView
fillerButton *walk.PushButton
fillerHandler func()
fillerContainer *walk.Composite
currentTunnelContainer *walk.Composite
}
func NewTunnelsPage() (*TunnelsPage, error) {
var err error
var disposables walk.Disposables
defer disposables.Treat()
tp := new(TunnelsPage)
if tp.TabPage, err = walk.NewTabPage(); err != nil {
return nil, err
}
disposables.Add(tp)
tp.SetTitle(l18n.Sprintf("Tunnels"))
tp.SetLayout(walk.NewHBoxLayout())
tp.listContainer, _ = walk.NewComposite(tp)
vlayout := walk.NewVBoxLayout()
vlayout.SetMargins(walk.Margins{})
vlayout.SetSpacing(0)
tp.listContainer.SetLayout(vlayout)
if tp.listView, err = NewListView(tp.listContainer); err != nil {
return nil, err
}
if tp.currentTunnelContainer, err = walk.NewComposite(tp); err != nil {
return nil, err
}
vlayout = walk.NewVBoxLayout()
vlayout.SetMargins(walk.Margins{})
tp.currentTunnelContainer.SetLayout(vlayout)
if tp.fillerContainer, err = walk.NewComposite(tp); err != nil {
return nil, err
}
tp.fillerContainer.SetVisible(false)
hlayout := walk.NewHBoxLayout()
hlayout.SetMargins(walk.Margins{})
tp.fillerContainer.SetLayout(hlayout)
tp.fillerButton, _ = walk.NewPushButton(tp.fillerContainer)
tp.fillerButton.SetMinMaxSize(walk.Size{200, 0}, walk.Size{200, 0})
tp.fillerButton.SetVisible(IsAdmin)
tp.fillerButton.Clicked().Attach(func() {
if tp.fillerHandler != nil {
tp.fillerHandler()
}
})
if tp.confView, err = NewConfView(tp.currentTunnelContainer); err != nil {
return nil, err
}
controlsContainer, err := walk.NewComposite(tp.currentTunnelContainer)
if err != nil {
return nil, err
}
hlayout = walk.NewHBoxLayout()
hlayout.SetMargins(walk.Margins{})
controlsContainer.SetLayout(hlayout)
walk.NewHSpacer(controlsContainer)
editTunnel, err := walk.NewPushButton(controlsContainer)
if err != nil {
return nil, err
}
editTunnel.SetEnabled(false)
tp.listView.CurrentIndexChanged().Attach(func() {
editTunnel.SetEnabled(tp.listView.CurrentIndex() > -1)
})
editTunnel.SetText(l18n.Sprintf("&Edit"))
editTunnel.Clicked().Attach(tp.onEditTunnel)
editTunnel.SetVisible(IsAdmin)
disposables.Spare()
tp.listView.ItemCountChanged().Attach(tp.onTunnelsChanged)
tp.listView.SelectedIndexesChanged().Attach(tp.onSelectedTunnelsChanged)
tp.listView.ItemActivated().Attach(tp.onTunnelsViewItemActivated)
tp.listView.CurrentIndexChanged().Attach(tp.updateConfView)
tp.listView.Load(false)
tp.onTunnelsChanged()
return tp, nil
}
func (tp *TunnelsPage) CreateToolbar() error {
if tp.listToolbar != nil {
return nil
}
// HACK: Because of https://github.com/lxn/walk/issues/481
// we need to put the ToolBar into its own Composite.
toolBarContainer, err := walk.NewComposite(tp.listContainer)
if err != nil {
return err
}
toolBarContainer.SetDoubleBuffering(true)
hlayout := walk.NewHBoxLayout()
hlayout.SetMargins(walk.Margins{})
toolBarContainer.SetLayout(hlayout)
toolBarContainer.SetVisible(IsAdmin)
if tp.listToolbar, err = walk.NewToolBarWithOrientationAndButtonStyle(toolBarContainer, walk.Horizontal, walk.ToolBarButtonImageBeforeText); err != nil {
return err
}
addMenu, err := walk.NewMenu()
if err != nil {
return err
}
tp.AddDisposable(addMenu)
importAction := walk.NewAction()
importAction.SetText(l18n.Sprintf("&Import tunnel(s) from file…"))
importActionIcon, _ := loadSystemIcon("imageres", -3, 16)
importAction.SetImage(importActionIcon)
importAction.SetShortcut(walk.Shortcut{walk.ModControl, walk.KeyO})
importAction.SetDefault(true)
importAction.Triggered().Attach(tp.onImport)
addMenu.Actions().Add(importAction)
addAction := walk.NewAction()
addAction.SetText(l18n.Sprintf("Add &empty tunnel…"))
addActionIcon, _ := loadSystemIcon("imageres", -2, 16)
addAction.SetImage(addActionIcon)
addAction.SetShortcut(walk.Shortcut{walk.ModControl, walk.KeyN})
addAction.Triggered().Attach(tp.onAddTunnel)
addMenu.Actions().Add(addAction)
addMenuAction := walk.NewMenuAction(addMenu)
addMenuActionIcon, _ := loadSystemIcon("shell32", -258, 16)
addMenuAction.SetImage(addMenuActionIcon)
addMenuAction.SetText(l18n.Sprintf("Add Tunnel"))
addMenuAction.SetToolTip(importAction.Text())
addMenuAction.Triggered().Attach(tp.onImport)
tp.listToolbar.Actions().Add(addMenuAction)
tp.listToolbar.Actions().Add(walk.NewSeparatorAction())
deleteAction := walk.NewAction()
deleteActionIcon, _ := loadSystemIcon("shell32", -240, 16)
deleteAction.SetImage(deleteActionIcon)
deleteAction.SetShortcut(walk.Shortcut{0, walk.KeyDelete})
deleteAction.SetToolTip(l18n.Sprintf("Remove selected tunnel(s)"))
deleteAction.Triggered().Attach(tp.onDelete)
tp.listToolbar.Actions().Add(deleteAction)
tp.listToolbar.Actions().Add(walk.NewSeparatorAction())
exportAction := walk.NewAction()
exportActionIcon, _ := loadSystemIcon("imageres", -174, 16)
exportAction.SetImage(exportActionIcon)
exportAction.SetToolTip(l18n.Sprintf("Export all tunnels to zip"))
exportAction.Triggered().Attach(tp.onExportTunnels)
tp.listToolbar.Actions().Add(exportAction)
fixContainerWidthToToolbarWidth := func() {
toolbarWidth := tp.listToolbar.SizeHint().Width
tp.listContainer.SetMinMaxSizePixels(walk.Size{toolbarWidth, 0}, walk.Size{toolbarWidth, 0})
}
fixContainerWidthToToolbarWidth()
tp.listToolbar.SizeChanged().Attach(fixContainerWidthToToolbarWidth)
contextMenu, err := walk.NewMenu()
if err != nil {
return err
}
tp.listView.AddDisposable(contextMenu)
toggleAction := walk.NewAction()
toggleAction.SetText(l18n.Sprintf("&Toggle"))
toggleAction.SetDefault(true)
toggleAction.Triggered().Attach(tp.onTunnelsViewItemActivated)
contextMenu.Actions().Add(toggleAction)
contextMenu.Actions().Add(walk.NewSeparatorAction())
importAction2 := walk.NewAction()
importAction2.SetText(l18n.Sprintf("&Import tunnel(s) from file…"))
importAction2.SetShortcut(walk.Shortcut{walk.ModControl, walk.KeyO})
importAction2.Triggered().Attach(tp.onImport)
importAction2.SetVisible(IsAdmin)
contextMenu.Actions().Add(importAction2)
tp.ShortcutActions().Add(importAction2)
addAction2 := walk.NewAction()
addAction2.SetText(l18n.Sprintf("Add &empty tunnel…"))
addAction2.SetShortcut(walk.Shortcut{walk.ModControl, walk.KeyN})
addAction2.Triggered().Attach(tp.onAddTunnel)
addAction2.SetVisible(IsAdmin)
contextMenu.Actions().Add(addAction2)
tp.ShortcutActions().Add(addAction2)
exportAction2 := walk.NewAction()
exportAction2.SetText(l18n.Sprintf("Export all tunnels to &zip…"))
exportAction2.Triggered().Attach(tp.onExportTunnels)
exportAction2.SetVisible(IsAdmin)
contextMenu.Actions().Add(exportAction2)
contextMenu.Actions().Add(walk.NewSeparatorAction())
editAction := walk.NewAction()
editAction.SetText(l18n.Sprintf("Edit &selected tunnel…"))
editAction.SetShortcut(walk.Shortcut{walk.ModControl, walk.KeyE})
editAction.SetVisible(IsAdmin)
editAction.Triggered().Attach(tp.onEditTunnel)
contextMenu.Actions().Add(editAction)
tp.ShortcutActions().Add(editAction)
deleteAction2 := walk.NewAction()
deleteAction2.SetText(l18n.Sprintf("&Remove selected tunnel(s)"))
deleteAction2.SetShortcut(walk.Shortcut{0, walk.KeyDelete})
deleteAction2.SetVisible(IsAdmin)
deleteAction2.Triggered().Attach(tp.onDelete)
contextMenu.Actions().Add(deleteAction2)
tp.listView.ShortcutActions().Add(deleteAction2)
selectAllAction := walk.NewAction()
selectAllAction.SetText(l18n.Sprintf("Select &all"))
selectAllAction.SetShortcut(walk.Shortcut{walk.ModControl, walk.KeyA})
selectAllAction.SetVisible(IsAdmin)
selectAllAction.Triggered().Attach(tp.onSelectAll)
contextMenu.Actions().Add(selectAllAction)
tp.listView.ShortcutActions().Add(selectAllAction)
tp.listView.SetContextMenu(contextMenu)
setSelectionOrientedOptions := func() {
selected := len(tp.listView.SelectedIndexes())
all := len(tp.listView.model.tunnels)
deleteAction.SetEnabled(selected > 0)
deleteAction2.SetEnabled(selected > 0)
toggleAction.SetEnabled(selected == 1)
selectAllAction.SetEnabled(selected < all)
editAction.SetEnabled(selected == 1)
}
tp.listView.SelectedIndexesChanged().Attach(setSelectionOrientedOptions)
setSelectionOrientedOptions()
setExport := func() {
all := len(tp.listView.model.tunnels)
exportAction.SetEnabled(all > 0)
exportAction2.SetEnabled(all > 0)
}
setExportRange := func(from, to int) { setExport() }
tp.listView.model.RowsInserted().Attach(setExportRange)
tp.listView.model.RowsRemoved().Attach(setExportRange)
tp.listView.model.RowsReset().Attach(setExport)
setExport()
return nil
}
func (tp *TunnelsPage) updateConfView() {
tp.confView.SetTunnel(tp.listView.CurrentTunnel())
}
func (tp *TunnelsPage) importFiles(paths []string) {
go func() {
syncedMsgBox := func(title, message string, flags walk.MsgBoxStyle) {
tp.Synchronize(func() {
walk.MsgBox(tp.Form(), title, message, flags)
})
}
type unparsedConfig struct {
Name string
Config string
}
var (
unparsedConfigs []unparsedConfig
lastErr error
)
for _, path := range paths {
switch strings.ToLower(filepath.Ext(path)) {
case ".conf":
textConfig, err := os.ReadFile(path)
if err != nil {
lastErr = err
continue
}
unparsedConfigs = append(unparsedConfigs, unparsedConfig{Name: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)), Config: string(textConfig)})
case ".zip":
// 1 .conf + 1 error .zip edge case?
r, err := zip.OpenReader(path)
if err != nil {
lastErr = err
continue
}
for _, f := range r.File {
if strings.ToLower(filepath.Ext(f.Name)) != ".conf" {
continue
}
rc, err := f.Open()
if err != nil {
lastErr = err
continue
}
textConfig, err := io.ReadAll(rc)
rc.Close()
if err != nil {
lastErr = err
continue
}
unparsedConfigs = append(unparsedConfigs, unparsedConfig{Name: strings.TrimSuffix(filepath.Base(f.Name), filepath.Ext(f.Name)), Config: string(textConfig)})
}
r.Close()
}
}
if lastErr != nil || unparsedConfigs == nil {
if lastErr == nil {
lastErr = errors.New(l18n.Sprintf("no configuration files were found"))
}
syncedMsgBox(l18n.Sprintf("Error"), l18n.Sprintf("Could not import selected configuration: %v", lastErr), walk.MsgBoxIconWarning)
return
}
// Add in reverse order so that the first one is selected.
sort.Slice(unparsedConfigs, func(i, j int) bool {
return conf.TunnelNameIsLess(unparsedConfigs[j].Name, unparsedConfigs[i].Name)
})
existingTunnelList, err := manager.IPCClientTunnels()
if err != nil {
syncedMsgBox(l18n.Sprintf("Error"), l18n.Sprintf("Could not enumerate existing tunnels: %v", lastErr), walk.MsgBoxIconWarning)
return
}
existingLowerTunnels := make(map[string]bool, len(existingTunnelList))
for _, tunnel := range existingTunnelList {
existingLowerTunnels[strings.ToLower(tunnel.Name)] = true
}
configCount := 0
tp.listView.SetSuspendTunnelsUpdate(true)
for _, unparsedConfig := range unparsedConfigs {
if existingLowerTunnels[strings.ToLower(unparsedConfig.Name)] {
lastErr = errors.New(l18n.Sprintf("Another tunnel already exists with the name %s", unparsedConfig.Name))
continue
}
config, err := conf.FromWgQuickWithUnknownEncoding(unparsedConfig.Config, unparsedConfig.Name)
if err != nil {
lastErr = err
continue
}
_, err = manager.IPCClientNewTunnel(config)
if err != nil {
lastErr = err
continue
}
configCount++
}
tp.listView.SetSuspendTunnelsUpdate(false)
m, n := configCount, len(unparsedConfigs)
switch {
case n == 1 && m != n:
syncedMsgBox(l18n.Sprintf("Error"), l18n.Sprintf("Unable to import configuration: %v", lastErr), walk.MsgBoxIconWarning)
case n == 1 && m == n:
// nothing
case m == n:
syncedMsgBox(l18n.Sprintf("Imported tunnels"), l18n.Sprintf("Imported %d tunnels", m), walk.MsgBoxIconInformation)
case m != n:
syncedMsgBox(l18n.Sprintf("Imported tunnels"), l18n.Sprintf("Imported %d of %d tunnels", m, n), walk.MsgBoxIconWarning)
}
}()
}
func (tp *TunnelsPage) exportTunnels(filePath string) {
writeFileWithOverwriteHandling(tp.Form(), filePath, func(file *os.File) error {
writer := zip.NewWriter(file)
for _, tunnel := range tp.listView.model.tunnels {
cfg, err := tunnel.StoredConfig()
if err != nil {
return fmt.Errorf("onExportTunnels: tunnel.StoredConfig failed: %w", err)
}
w, err := writer.Create(tunnel.Name + ".conf")
if err != nil {
return fmt.Errorf("onExportTunnels: writer.Create failed: %w", err)
}
if _, err := w.Write(([]byte)(cfg.ToWgQuick())); err != nil {
return fmt.Errorf("onExportTunnels: cfg.ToWgQuick failed: %w", err)
}
}
return writer.Close()
})
}
func (tp *TunnelsPage) addTunnel(config *conf.Config) {
_, err := manager.IPCClientNewTunnel(config)
if err != nil {
showErrorCustom(tp.Form(), l18n.Sprintf("Unable to create tunnel"), err.Error())
}
}
// Handlers
func (tp *TunnelsPage) onTunnelsViewItemActivated() {
go func() {
globalState, err := manager.IPCClientGlobalState()
if err != nil || (globalState != manager.TunnelStarted && globalState != manager.TunnelStopped) {
return
}
oldState, err := tp.listView.CurrentTunnel().Toggle()
if err != nil {
tp.Synchronize(func() {
if oldState == manager.TunnelUnknown {
showErrorCustom(tp.Form(), l18n.Sprintf("Failed to determine tunnel state"), err.Error())
} else if oldState == manager.TunnelStopped {
showErrorCustom(tp.Form(), l18n.Sprintf("Failed to activate tunnel"), err.Error())
} else if oldState == manager.TunnelStarted {
showErrorCustom(tp.Form(), l18n.Sprintf("Failed to deactivate tunnel"), err.Error())
}
})
return
}
}()
}
func (tp *TunnelsPage) onEditTunnel() {
tunnel := tp.listView.CurrentTunnel()
if tunnel == nil {
return
}
if config := runEditDialog(tp.Form(), tunnel); config != nil {
go func() {
priorState, err := tunnel.State()
tunnel.Delete()
tunnel.WaitForStop()
tunnel, err2 := manager.IPCClientNewTunnel(config)
if err == nil && err2 == nil && (priorState == manager.TunnelStarting || priorState == manager.TunnelStarted) {
tunnel.Start()
}
}()
}
}
func (tp *TunnelsPage) onAddTunnel() {
if config := runEditDialog(tp.Form(), nil); config != nil {
// Save new
tp.addTunnel(config)
}
}
func (tp *TunnelsPage) onDelete() {
indices := tp.listView.SelectedIndexes()
if len(indices) == 0 {
return
}
var title, question string
if len(indices) > 1 {
tunnelCount := len(indices)
title = l18n.Sprintf("Delete %d tunnels", tunnelCount)
question = l18n.Sprintf("Are you sure you would like to delete %d tunnels?", tunnelCount)
} else {
tunnelName := tp.listView.model.tunnels[indices[0]].Name
title = l18n.Sprintf("Delete tunnel %s", tunnelName)
question = l18n.Sprintf("Are you sure you would like to delete tunnel %s?", tunnelName)
}
if walk.DlgCmdNo == walk.MsgBox(
tp.Form(),
title,
l18n.Sprintf("%s You cannot undo this action.", question),
walk.MsgBoxYesNo|walk.MsgBoxIconWarning) {
return
}
selectTunnelAfter := ""
if len(indices) < len(tp.listView.model.tunnels) {
sort.Ints(indices)
max := 0
for i, idx := range indices {
if idx+1 < len(tp.listView.model.tunnels) && (i+1 == len(indices) || idx+1 != indices[i+1]) {
max = idx + 1
} else if idx-1 >= 0 && (i == 0 || idx-1 != indices[i-1]) {
max = idx - 1
}
}
selectTunnelAfter = tp.listView.model.tunnels[max].Name
}
if len(selectTunnelAfter) > 0 {
tp.listView.selectTunnel(selectTunnelAfter)
}
tunnelsToDelete := make([]manager.Tunnel, len(indices))
for i, j := range indices {
tunnelsToDelete[i] = tp.listView.model.tunnels[j]
}
go func() {
tp.listView.SetSuspendTunnelsUpdate(true)
var errors []error
for _, tunnel := range tunnelsToDelete {
err := tunnel.Delete()
if err != nil && (len(errors) == 0 || errors[len(errors)-1].Error() != err.Error()) {
errors = append(errors, err)
}
}
tp.listView.SetSuspendTunnelsUpdate(false)
if len(errors) > 0 {
tp.listView.Synchronize(func() {
if len(errors) == 1 {
showErrorCustom(tp.Form(), l18n.Sprintf("Unable to delete tunnel"), l18n.Sprintf("A tunnel was unable to be removed: %s", errors[0].Error()))
} else {
showErrorCustom(tp.Form(), l18n.Sprintf("Unable to delete tunnels"), l18n.Sprintf("%d tunnels were unable to be removed.", len(errors)))
}
})
}
}()
}
func (tp *TunnelsPage) onSelectAll() {
tp.listView.SetSelectedIndexes([]int{-1})
}
func (tp *TunnelsPage) onImport() {
dlg := walk.FileDialog{
Filter: l18n.Sprintf("Configuration Files (*.zip, *.conf)|*.zip;*.conf|All Files (*.*)|*.*"),
Title: l18n.Sprintf("Import tunnel(s) from file"),
}
if ok, _ := dlg.ShowOpenMultiple(tp.Form()); !ok {
return
}
tp.importFiles(dlg.FilePaths)
}
func (tp *TunnelsPage) onExportTunnels() {
dlg := walk.FileDialog{
Filter: l18n.Sprintf("Configuration ZIP Files (*.zip)|*.zip"),
Title: l18n.Sprintf("Export tunnels to zip"),
}
if ok, _ := dlg.ShowSave(tp.Form()); !ok {
return
}
if !strings.HasSuffix(dlg.FilePath, ".zip") {
dlg.FilePath += ".zip"
}
tp.exportTunnels(dlg.FilePath)
}
func (tp *TunnelsPage) swapFiller(enabled bool) bool {
if tp.fillerContainer.Visible() == enabled {
return enabled
}
tp.SetSuspended(true)
tp.fillerContainer.SetVisible(enabled)
tp.currentTunnelContainer.SetVisible(!enabled)
tp.SetSuspended(false)
return enabled
}
func (tp *TunnelsPage) onTunnelsChanged() {
if tp.swapFiller(tp.listView.model.RowCount() == 0) {
tp.fillerButton.SetText(l18n.Sprintf("Import tunnel(s) from file"))
tp.fillerHandler = tp.onImport
}
}
func (tp *TunnelsPage) onSelectedTunnelsChanged() {
if tp.listView.model.RowCount() == 0 {
return
}
indices := tp.listView.SelectedIndexes()
tunnelCount := len(indices)
if tp.swapFiller(tunnelCount > 1) {
tp.fillerButton.SetText(l18n.Sprintf("Delete %d tunnels", tunnelCount))
tp.fillerHandler = tp.onDelete
}
}