From 48f11e502cc7ee03a9c1dd554c3665883fc90cff Mon Sep 17 00:00:00 2001 From: Alexander Neumann Date: Wed, 24 Apr 2019 15:29:38 +0200 Subject: ui: use tabs in main window and refactor tunnels ui and log dialog into tab pages requires https://github.com/lxn/walk/commit/edb74ee350e9585ddd212acad445ec383950f2cc for status image background Signed-off-by: Alexander Neumann Signed-off-by: Jason A. Donenfeld --- ui/logdialog.go | 194 ------------------------- ui/logpage.go | 184 +++++++++++++++++++++++ ui/logview.go | 128 ---------------- ui/manage_tunnels.go | 398 +++----------------------------------------------- ui/tray.go | 10 +- ui/tunnelspage.go | 403 +++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 613 insertions(+), 704 deletions(-) delete mode 100644 ui/logdialog.go create mode 100644 ui/logpage.go delete mode 100644 ui/logview.go create mode 100644 ui/tunnelspage.go (limited to 'ui') diff --git a/ui/logdialog.go b/ui/logdialog.go deleted file mode 100644 index a2f23903..00000000 --- a/ui/logdialog.go +++ /dev/null @@ -1,194 +0,0 @@ -/* SPDX-License-Identifier: MIT - * - * Copyright (C) 2019 WireGuard LLC. All Rights Reserved. - */ - -package ui - -import ( - "fmt" - "os" - "strings" - "time" - - "github.com/lxn/win" - - "github.com/lxn/walk" - "golang.zx2c4.com/wireguard/windows/ringlogger" -) - -func runLogDialog(owner walk.Form, logger *ringlogger.Ringlogger) { - dlg := &LogDialog{logger: logger} - dlg.model = newLogModel(dlg, logger) - defer func() { - dlg.model.quit <- true - }() - - var disposables walk.Disposables - defer disposables.Treat() - - showError := func(err error) bool { - if err == nil { - return false - } - - walk.MsgBox(owner, "Viewing log dialog failed", err.Error(), walk.MsgBoxIconError) - - return true - } - - var err error - - if dlg.Dialog, err = walk.NewDialog(owner); showError(err) { - return - } - disposables.Add(dlg) - - dlg.SetTitle("WireGuard Log") - dlg.SetLayout(walk.NewVBoxLayout()) - dlg.Layout().SetMargins(walk.Margins{18, 18, 18, 18}) - dlg.SetMinMaxSize(walk.Size{600, 400}, walk.Size{}) - - if dlg.logView, err = walk.NewTableView(dlg); showError(err) { - return - } - dlg.logView.SetAlternatingRowBGColor(walk.Color(win.GetSysColor(win.COLOR_BTNFACE))) - dlg.logView.SetLastColumnStretched(true) - - stampCol := walk.NewTableViewColumn() - stampCol.SetName("Stamp") - stampCol.SetTitle("Time") - stampCol.SetFormat("2006-01-02 15:04:05.000") - stampCol.SetWidth(150) - dlg.logView.Columns().Add(stampCol) - - msgCol := walk.NewTableViewColumn() - msgCol.SetName("Line") - msgCol.SetTitle("Log message") - dlg.logView.Columns().Add(msgCol) - - dlg.logView.SetModel(dlg.model) - dlg.scrollToBottom() - - buttonsContainer, err := walk.NewComposite(dlg) - buttonsContainer.SetLayout(walk.NewHBoxLayout()) - buttonsContainer.Layout().SetMargins(walk.Margins{0, 12, 0, 0}) - - saveButton, err := walk.NewPushButton(buttonsContainer) - saveButton.SetText("Save") - saveButton.Clicked().Attach(dlg.onSaveButtonClicked) - - walk.NewHSpacer(buttonsContainer) - - closeButton, err := walk.NewPushButton(buttonsContainer) - closeButton.SetText("Close") - closeButton.Clicked().Attach(dlg.Accept) - - dlg.SetDefaultButton(closeButton) - dlg.SetCancelButton(closeButton) - - disposables.Spare() - - dlg.Run() -} - -type LogDialog struct { - *walk.Dialog - logView *walk.TableView - logger *ringlogger.Ringlogger - model *logModel -} - -func (dlg *LogDialog) isAtBottom() bool { - return dlg.logView.ItemVisible(len(dlg.model.items) - 1) -} - -func (dlg *LogDialog) scrollToBottom() { - dlg.logView.EnsureItemVisible(len(dlg.model.items) - 1) -} - -func (dlg *LogDialog) onSaveButtonClicked() { - fd := walk.FileDialog{ - Filter: "Text Files (*.txt)|*.txt|All Files (*.*)|*.*", - FilePath: fmt.Sprintf("wireguard-log-%s.txt", time.Now().Format("2006-01-02T150405")), - Title: "Export log to file", - } - - if ok, _ := fd.ShowSave(dlg); !ok { - return - } - - extensions := []string{".log", ".txt"} - if fd.FilterIndex < 3 && !strings.HasSuffix(fd.FilePath, extensions[fd.FilterIndex-1]) { - fd.FilePath = fd.FilePath + extensions[fd.FilterIndex-1] - } - - writeFileWithOverwriteHandling(dlg, fd.FilePath, func(file *os.File) error { - if _, err := dlg.logger.WriteTo(file); err != nil { - return fmt.Errorf("exportLog: Ringlogger.WriteTo failed: %v", err) - } - - return nil - }) -} - -type logModel struct { - walk.ReflectTableModelBase - dlg *LogDialog - quit chan bool - logger *ringlogger.Ringlogger - items []ringlogger.FollowLine -} - -func newLogModel(dlg *LogDialog, logger *ringlogger.Ringlogger) *logModel { - mdl := &logModel{dlg: dlg, quit: make(chan bool), logger: logger} - var lastCursor uint32 - mdl.items, lastCursor = logger.FollowFromCursor(ringlogger.CursorAll) - - var lastStamp time.Time - if len(mdl.items) > 0 { - lastStamp = mdl.items[len(mdl.items)-1].Stamp - } - - go func() { - ticker := time.NewTicker(time.Second) - - for { - select { - case <-ticker.C: - items, cursor := mdl.logger.FollowFromCursor(ringlogger.CursorAll) - - var stamp time.Time - if len(items) > 0 { - stamp = items[len(items)-1].Stamp - } - - if cursor != lastCursor || stamp.After(lastStamp) { - lastCursor = cursor - lastStamp = stamp - - mdl.dlg.Synchronize(func() { - isAtBottom := mdl.dlg.isAtBottom() - - mdl.items = items - mdl.PublishRowsReset() - - if isAtBottom { - mdl.dlg.scrollToBottom() - } - }) - } - - case <-mdl.quit: - ticker.Stop() - break - } - } - }() - - return mdl -} - -func (mdl *logModel) Items() interface{} { - return mdl.items -} diff --git a/ui/logpage.go b/ui/logpage.go new file mode 100644 index 00000000..4f39ae87 --- /dev/null +++ b/ui/logpage.go @@ -0,0 +1,184 @@ +/* SPDX-License-Identifier: MIT + * + * Copyright (C) 2019 WireGuard LLC. All Rights Reserved. + */ + +package ui + +import ( + "fmt" + "os" + "strings" + "time" + + "github.com/lxn/win" + + "github.com/lxn/walk" + "golang.zx2c4.com/wireguard/windows/ringlogger" +) + +func NewLogPage(logger *ringlogger.Ringlogger) (*LogPage, error) { + lp := &LogPage{logger: logger} + + var disposables walk.Disposables + defer disposables.Treat() + + var err error + + if lp.TabPage, err = walk.NewTabPage(); err != nil { + return nil, err + } + disposables.Add(lp) + + lp.Disposing().Attach(func() { + lp.model.quit <- true + }) + + lp.SetTitle("Log") + lp.SetLayout(walk.NewVBoxLayout()) + lp.Layout().SetMargins(walk.Margins{18, 18, 18, 18}) + + if lp.logView, err = walk.NewTableView(lp); err != nil { + return nil, err + } + lp.logView.SetAlternatingRowBGColor(walk.Color(win.GetSysColor(win.COLOR_BTNFACE))) + lp.logView.SetLastColumnStretched(true) + + stampCol := walk.NewTableViewColumn() + stampCol.SetName("Stamp") + stampCol.SetTitle("Time") + stampCol.SetFormat("2006-01-02 15:04:05.000") + stampCol.SetWidth(150) + lp.logView.Columns().Add(stampCol) + + msgCol := walk.NewTableViewColumn() + msgCol.SetName("Line") + msgCol.SetTitle("Log message") + lp.logView.Columns().Add(msgCol) + + lp.model = newLogModel(lp, logger) + lp.logView.SetModel(lp.model) + + buttonsContainer, err := walk.NewComposite(lp) + if err != nil { + return nil, err + } + buttonsContainer.SetLayout(walk.NewHBoxLayout()) + buttonsContainer.Layout().SetMargins(walk.Margins{0, 12, 0, 0}) + + walk.NewHSpacer(buttonsContainer) + + saveButton, err := walk.NewPushButton(buttonsContainer) + if err != nil { + return nil, err + } + saveButton.SetText("Save") + saveButton.Clicked().Attach(lp.onSaveButtonClicked) + + disposables.Spare() + + return lp, nil +} + +type LogPage struct { + *walk.TabPage + logView *walk.TableView + logger *ringlogger.Ringlogger + model *logModel +} + +func (lp *LogPage) isAtBottom() bool { + return lp.logView.ItemVisible(len(lp.model.items) - 1) +} + +func (lp *LogPage) scrollToBottom() { + lp.logView.EnsureItemVisible(len(lp.model.items) - 1) +} + +func (lp *LogPage) onSaveButtonClicked() { + fd := walk.FileDialog{ + Filter: "Text Files (*.txt)|*.txt|All Files (*.*)|*.*", + FilePath: fmt.Sprintf("wireguard-log-%s.txt", time.Now().Format("2006-01-02T150405")), + Title: "Export log to file", + } + + form := lp.Form() + + if ok, _ := fd.ShowSave(form); !ok { + return + } + + extensions := []string{".log", ".txt"} + if fd.FilterIndex < 3 && !strings.HasSuffix(fd.FilePath, extensions[fd.FilterIndex-1]) { + fd.FilePath = fd.FilePath + extensions[fd.FilterIndex-1] + } + + writeFileWithOverwriteHandling(form, fd.FilePath, func(file *os.File) error { + if _, err := lp.logger.WriteTo(file); err != nil { + return fmt.Errorf("exportLog: Ringlogger.WriteTo failed: %v", err) + } + + return nil + }) +} + +type logModel struct { + walk.ReflectTableModelBase + lp *LogPage + quit chan bool + logger *ringlogger.Ringlogger + items []ringlogger.FollowLine +} + +func newLogModel(lp *LogPage, logger *ringlogger.Ringlogger) *logModel { + mdl := &logModel{lp: lp, quit: make(chan bool), logger: logger} + var lastCursor uint32 + mdl.items, lastCursor = logger.FollowFromCursor(ringlogger.CursorAll) + + var lastStamp time.Time + if len(mdl.items) > 0 { + lastStamp = mdl.items[len(mdl.items)-1].Stamp + } + + go func() { + ticker := time.NewTicker(time.Second) + + for { + select { + case <-ticker.C: + items, cursor := mdl.logger.FollowFromCursor(ringlogger.CursorAll) + + var stamp time.Time + if len(items) > 0 { + stamp = items[len(items)-1].Stamp + } + + if cursor != lastCursor || stamp.After(lastStamp) { + lastCursor = cursor + lastStamp = stamp + + mdl.lp.Synchronize(func() { + isAtBottom := mdl.lp.isAtBottom() + + mdl.items = items + mdl.PublishRowsReset() + + if isAtBottom { + mdl.lp.scrollToBottom() + } + }) + } + + case <-mdl.quit: + ticker.Stop() + break + } + } + }() + + return mdl +} + +func (mdl *logModel) Items() interface{} { + return mdl.items +} diff --git a/ui/logview.go b/ui/logview.go deleted file mode 100644 index e7a8a237..00000000 --- a/ui/logview.go +++ /dev/null @@ -1,128 +0,0 @@ -/* SPDX-License-Identifier: MIT - * - * Copyright (C) 2019 WireGuard LLC. All Rights Reserved. - */ - -package ui - -import ( - "errors" - "fmt" - "github.com/lxn/walk" - "github.com/lxn/win" - "golang.zx2c4.com/wireguard/windows/ringlogger" - "strings" - "syscall" - "time" - "unsafe" -) - -type LogView struct { - walk.WidgetBase - logChan chan string -} - -const ( - TEM_APPENDTEXT = win.WM_USER + 6 -) - -func NewLogView(parent walk.Container, rl *ringlogger.Ringlogger) (*LogView, error) { - lc := make(chan string, 1024) - lv := &LogView{logChan: lc} - - if err := walk.InitWidget( - lv, - parent, - "EDIT", - win.WS_TABSTOP|win.WS_VISIBLE|win.WS_VSCROLL|win.ES_MULTILINE|win.ES_WANTRETURN, - win.WS_EX_CLIENTEDGE); err != nil { - return nil, err - } - lv.setReadOnly(true) - lv.SendMessage(win.EM_SETLIMITTEXT, 4294967295, 0) - - go func() { - var lines []ringlogger.FollowLine - cursor := ringlogger.CursorAll - for { - if lv.IsDisposed() { - return - } - lines, cursor = rl.FollowFromCursor(cursor) - sb := strings.Builder{} - for _, line := range lines { - sb.WriteString(fmt.Sprintf("%s: %s\r\n", line.Stamp.Format("2006-01-02 15:04:05.000000"), strings.ReplaceAll(line.Line, "\n", "\r\n"))) - } - newLines := sb.String() - if len(newLines) > 0 { - lv.AppendText(newLines) - } - time.Sleep(300 * time.Millisecond) - } - }() - return lv, nil -} - -func (*LogView) LayoutFlags() walk.LayoutFlags { - return walk.ShrinkableHorz | walk.ShrinkableVert | walk.GrowableHorz | walk.GrowableVert | walk.GreedyHorz | walk.GreedyVert -} - -func (*LogView) MinSizeHint() walk.Size { - return walk.Size{20, 12} -} - -func (*LogView) SizeHint() walk.Size { - return walk.Size{100, 100} -} - -func (lv *LogView) setTextSelection(start, end int) { - lv.SendMessage(win.EM_SETSEL, uintptr(start), uintptr(end)) -} - -func (lv *LogView) textLength() int { - return int(lv.SendMessage(0x000E, uintptr(0), uintptr(0))) -} - -func (lv *LogView) AppendText(value string) { - textLength := lv.textLength() - lv.setTextSelection(textLength, textLength) - lv.SendMessage(win.EM_REPLACESEL, 0, uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(value)))) -} - -func (lv *LogView) setReadOnly(readOnly bool) error { - if 0 == lv.SendMessage(win.EM_SETREADONLY, uintptr(win.BoolToBOOL(readOnly)), 0) { - return errors.New("fail to call EM_SETREADONLY") - } - - return nil -} - -func (lv *LogView) PostAppendText(value string) { - lv.logChan <- value - win.PostMessage(lv.Handle(), TEM_APPENDTEXT, 0, 0) -} - -func (lv *LogView) Write(p []byte) (int, error) { - lv.PostAppendText(string(p) + "\r\n") - return len(p), nil -} - -func (lv *LogView) WndProc(hwnd win.HWND, msg uint32, wParam, lParam uintptr) uintptr { - switch msg { - case win.WM_GETDLGCODE: - if wParam == win.VK_RETURN { - return win.DLGC_WANTALLKEYS - } - - return win.DLGC_HASSETSEL | win.DLGC_WANTARROWS | win.DLGC_WANTCHARS - case TEM_APPENDTEXT: - select { - case value := <-lv.logChan: - lv.AppendText(value) - default: - return 0 - } - } - - return lv.WidgetBase.WndProc(hwnd, msg, wParam, lParam) -} diff --git a/ui/manage_tunnels.go b/ui/manage_tunnels.go index 35c067a6..69d90a0f 100644 --- a/ui/manage_tunnels.go +++ b/ui/manage_tunnels.go @@ -6,17 +6,8 @@ package ui import ( - "archive/zip" - "fmt" - "io/ioutil" - "os" - "path/filepath" - "strings" - "time" - "github.com/lxn/walk" "github.com/lxn/win" - "golang.zx2c4.com/wireguard/windows/conf" "golang.zx2c4.com/wireguard/windows/ringlogger" "golang.zx2c4.com/wireguard/windows/service" ) @@ -24,13 +15,10 @@ import ( type ManageTunnelsWindow struct { *walk.MainWindow - icon *walk.Icon - logger *ringlogger.Ringlogger - tunnelTracker *TunnelTracker - tunnelsView *TunnelsView - confView *ConfView - tunnelAddedPublisher walk.StringEventPublisher - tunnelDeletedPublisher walk.StringEventPublisher + icon *walk.Icon + logger *ringlogger.Ringlogger + tunnelsPage *TunnelsPage + logPage *LogPage } func NewManageTunnelsWindow(icon *walk.Icon, logger *ringlogger.Ringlogger) (*ManageTunnelsWindow, error) { @@ -63,103 +51,23 @@ func NewManageTunnelsWindow(icon *walk.Icon, logger *ringlogger.Ringlogger) (*Ma // "Close to tray" instead of exiting application onQuit() }) - mtw.Starting().Attach(func() { - mtw.updateConfView() - win.SetForegroundWindow(mtw.Handle()) - win.BringWindowToTop(mtw.Handle()) - }) - - splitter, _ := walk.NewHSplitter(mtw) - splitter.SetSuspended(true) - defer func() { - splitter.SetSuspended(false) - }() - - tunnelsContainer, _ := walk.NewComposite(splitter) - tunnelsContainer.SetLayout(walk.NewVBoxLayout()) - - splitter.SetFixed(tunnelsContainer, true) - - mtw.tunnelsView, _ = NewTunnelsView(tunnelsContainer) - mtw.tunnelsView.ItemActivated().Attach(mtw.onTunnelsViewItemActivated) - mtw.tunnelsView.CurrentIndexChanged().Attach(mtw.updateConfView) - - // ToolBar actions - { - // HACK: Because of https://github.com/lxn/walk/issues/481 - // we need to put the ToolBar into its own Composite. - toolBarContainer, _ := walk.NewComposite(tunnelsContainer) - toolBarContainer.SetLayout(walk.NewHBoxLayout()) - - tunnelsToolBar, _ := walk.NewToolBarWithOrientationAndButtonStyle(toolBarContainer, walk.Horizontal, walk.ToolBarButtonTextOnly) - - importAction := walk.NewAction() - importAction.SetText("Import tunnels from file...") - importAction.Triggered().Attach(mtw.onImport) - - addAction := walk.NewAction() - addAction.SetText("Add empty tunnel") - addAction.Triggered().Attach(mtw.onAddTunnel) - - viewLogAction := walk.NewAction() - viewLogAction.SetText("View Log") - viewLogAction.Triggered().Attach(mtw.onViewLog) - - exportTunnelsAction := walk.NewAction() - exportTunnelsAction.SetText("Export tunnels to zip...") - exportTunnelsAction.Triggered().Attach(mtw.onExportTunnels) - - addMenu, _ := walk.NewMenu() - mtw.AddDisposable(addMenu) - addMenu.Actions().Add(addAction) - addMenu.Actions().Add(importAction) - addMenuAction, _ := tunnelsToolBar.Actions().AddMenu(addMenu) - addMenuAction.SetText("➕") - - deleteAction := walk.NewAction() - tunnelsToolBar.Actions().Add(deleteAction) - deleteAction.SetText("➖") - deleteAction.Triggered().Attach(mtw.onDelete) - - settingsMenu, _ := walk.NewMenu() - mtw.AddDisposable(settingsMenu) - settingsMenu.Actions().Add(viewLogAction) - settingsMenu.Actions().Add(exportTunnelsAction) - settingsMenuAction, _ := tunnelsToolBar.Actions().AddMenu(settingsMenu) - settingsMenuAction.SetText("⚙") - } + mtw.VisibleChanged().Attach(func() { + if mtw.Visible() { + mtw.tunnelsPage.updateConfView() + win.SetForegroundWindow(mtw.Handle()) + win.BringWindowToTop(mtw.Handle()) - currentTunnelContainer, _ := walk.NewComposite(splitter) - currentTunnelContainer.SetLayout(walk.NewVBoxLayout()) - splitter.Layout().(interface{ SetStretchFactor(walk.Widget, int) error }).SetStretchFactor(currentTunnelContainer, 3) - - mtw.confView, _ = NewConfView(currentTunnelContainer) - - updateConfViewTicker := time.NewTicker(time.Second) - mtw.Disposing().Attach(updateConfViewTicker.Stop) - go func() { - for range updateConfViewTicker.C { - mtw.Synchronize(func() { - mtw.updateConfView() - }) + mtw.logPage.scrollToBottom() } - }() - - controlsContainer, _ := walk.NewComposite(currentTunnelContainer) - controlsContainer.SetLayout(walk.NewHBoxLayout()) - controlsContainer.Layout().SetMargins(walk.Margins{}) + }) - walk.NewHSpacer(controlsContainer) + tabWidget, _ := walk.NewTabWidget(mtw) - editTunnel, _ := walk.NewPushButton(controlsContainer) - editTunnel.SetEnabled(false) - mtw.tunnelsView.CurrentIndexChanged().Attach(func() { - editTunnel.SetEnabled(mtw.tunnelsView.CurrentIndex() > -1) - }) - editTunnel.SetText("Edit") - editTunnel.Clicked().Attach(mtw.onEditTunnel) + mtw.tunnelsPage, _ = NewTunnelsPage() + tabWidget.Pages().Add(mtw.tunnelsPage.TabPage) - mtw.tunnelsView.SetCurrentIndex(0) + mtw.logPage, _ = NewLogPage(logger) + tabWidget.Pages().Add(mtw.logPage.TabPage) disposables.Spare() @@ -167,286 +75,22 @@ func NewManageTunnelsWindow(icon *walk.Icon, logger *ringlogger.Ringlogger) (*Ma } func (mtw *ManageTunnelsWindow) TunnelTracker() *TunnelTracker { - return mtw.tunnelTracker + return mtw.tunnelsPage.tunnelTracker } func (mtw *ManageTunnelsWindow) SetTunnelTracker(tunnelTracker *TunnelTracker) { - mtw.tunnelTracker = tunnelTracker + mtw.tunnelsPage.tunnelTracker = tunnelTracker - mtw.confView.SetTunnelTracker(tunnelTracker) + mtw.tunnelsPage.confView.SetTunnelTracker(tunnelTracker) } func (mtw *ManageTunnelsWindow) SetTunnelState(tunnel *service.Tunnel, state service.TunnelState) { - mtw.tunnelsView.SetTunnelState(tunnel, state) + mtw.tunnelsPage.SetTunnelState(tunnel, state) - icon, err := mtw.tunnelsView.imageProvider.IconWithOverlayForState(mtw.icon, state) + icon, err := mtw.tunnelsPage.tunnelsView.imageProvider.IconWithOverlayForState(mtw.icon, state) if err != nil { return } mtw.SetIcon(icon) } - -func (mtw *ManageTunnelsWindow) updateConfView() { - if !mtw.Visible() { - return - } - - mtw.confView.SetTunnel(mtw.tunnelsView.CurrentTunnel()) -} - -// importFiles tries to import a list of configurations. -func (mtw *ManageTunnelsWindow) importFiles(paths []string) { - type unparsedConfig struct { - Name string - Config string - } - - var ( - unparsedConfigs []unparsedConfig - lastErr error - ) - - // Note: other versions of WireGuard start with all .zip files, then all .conf files. - // To reproduce that if needed, inverse-sort the array. - for _, path := range paths { - switch filepath.Ext(path) { - case ".conf": - textConfig, err := ioutil.ReadFile(path) - if err != nil { - lastErr = err - continue - } - unparsedConfigs = append(unparsedConfigs, unparsedConfig{Name: strings.TrimSuffix(filepath.Base(path), ".conf"), 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 filepath.Ext(f.Name) != ".conf" { - continue - } - - rc, err := f.Open() - if err != nil { - lastErr = err - continue - } - textConfig, err := ioutil.ReadAll(rc) - rc.Close() - if err != nil { - lastErr = err - continue - } - unparsedConfigs = append(unparsedConfigs, unparsedConfig{Name: strings.TrimSuffix(filepath.Base(f.Name), ".conf"), Config: string(textConfig)}) - } - - r.Close() - } - } - - if lastErr != nil || unparsedConfigs == nil { - walk.MsgBox(mtw, "Error", fmt.Sprintf("Could not parse some files: %v", lastErr), walk.MsgBoxIconWarning) - return - } - - var configs []*conf.Config - - for _, unparsedConfig := range unparsedConfigs { - config, err := conf.FromWgQuick(unparsedConfig.Config, unparsedConfig.Name) - if err != nil { - lastErr = err - continue - } - service.IPCClientNewTunnel(config) - configs = append(configs, config) - } - - m, n := len(configs), len(unparsedConfigs) - switch { - case n == 1 && m != n: - walk.MsgBox(mtw, "Error", fmt.Sprintf("Could not parse some files: %v", lastErr), walk.MsgBoxIconWarning) - case n == 1 && m == n: - // TODO: Select tunnel in the list - case m == n: - walk.MsgBox(mtw, "Imported tunnels", fmt.Sprintf("Imported %d tunnels", m), walk.MsgBoxOK) - case m != n: - walk.MsgBox(mtw, "Imported tunnels", fmt.Sprintf("Imported %d of %d tunnels", m, n), walk.MsgBoxIconWarning) - default: - panic("unreachable case") - } -} - -func (mtw *ManageTunnelsWindow) exportTunnels(filePath string) { - writeFileWithOverwriteHandling(mtw, filePath, func(file *os.File) error { - writer := zip.NewWriter(file) - - for _, tunnel := range mtw.tunnelsView.model.tunnels { - cfg, err := tunnel.StoredConfig() - if err != nil { - return fmt.Errorf("onExportTunnels: tunnel.StoredConfig failed: %v", err) - } - - w, err := writer.Create(tunnel.Name + ".conf") - if err != nil { - return fmt.Errorf("onExportTunnels: writer.Create failed: %v", err) - } - - if _, err := w.Write(([]byte)(cfg.ToWgQuick())); err != nil { - return fmt.Errorf("onExportTunnels: cfg.ToWgQuick failed: %v", err) - } - } - - return writer.Close() - }) -} - -func (mtw *ManageTunnelsWindow) addTunnel(config *conf.Config) { - tunnel, err := service.IPCClientNewTunnel(config) - if err != nil { - walk.MsgBox(mtw, "Unable to create tunnel", err.Error(), walk.MsgBoxIconError) - return - } - - model := mtw.tunnelsView.model - model.tunnels = append(model.tunnels, tunnel) - model.PublishRowsReset() - model.Sort(model.SortedColumn(), model.SortOrder()) - - for i, t := range model.tunnels { - if t.Name == tunnel.Name { - mtw.tunnelsView.SetCurrentIndex(i) - break - } - } - - mtw.confView.SetTunnel(&tunnel) - - mtw.tunnelAddedPublisher.Publish(tunnel.Name) -} - -func (mtw *ManageTunnelsWindow) deleteTunnel(tunnel *service.Tunnel) { - tunnel.Delete() - - model := mtw.tunnelsView.model - - for i, t := range model.tunnels { - if t.Name == tunnel.Name { - model.tunnels = append(model.tunnels[:i], model.tunnels[i+1:]...) - model.PublishRowsRemoved(i, i) - break - } - } - - mtw.tunnelDeletedPublisher.Publish(tunnel.Name) -} - -func (mtw *ManageTunnelsWindow) TunnelAdded() *walk.StringEvent { - return mtw.tunnelAddedPublisher.Event() -} - -func (mtw *ManageTunnelsWindow) TunnelDeleted() *walk.StringEvent { - return mtw.tunnelDeletedPublisher.Event() -} - -// Handlers - -func (mtw *ManageTunnelsWindow) onTunnelsViewItemActivated() { - if mtw.tunnelTracker.InTransition() { - return - } - - var err error - var title string - tunnel := mtw.tunnelsView.CurrentTunnel() - activeTunnel := mtw.tunnelTracker.ActiveTunnel() - if tunnel != nil && activeTunnel != nil && tunnel.Name == activeTunnel.Name { - err, title = mtw.tunnelTracker.DeactivateTunnel(), "Deactivating tunnel failed" - } else { - err, title = mtw.tunnelTracker.ActivateTunnel(tunnel), "Activating tunnel failed" - } - if err != nil { - walk.MsgBox(mtw, title, fmt.Sprintf("Error: %s", err.Error()), walk.MsgBoxIconError) - } -} - -func (mtw *ManageTunnelsWindow) onEditTunnel() { - tunnel := mtw.tunnelsView.CurrentTunnel() - if tunnel == nil { - // Misfired event? - return - } - - if config := runTunnelConfigDialog(mtw, tunnel); config != nil { - // Delete old one - mtw.deleteTunnel(tunnel) - - // Save new one - mtw.addTunnel(config) - } -} - -func (mtw *ManageTunnelsWindow) onAddTunnel() { - if config := runTunnelConfigDialog(mtw, nil); config != nil { - // Save new - mtw.addTunnel(config) - } -} - -func (mtw *ManageTunnelsWindow) onDelete() { - currentTunnel := mtw.tunnelsView.CurrentTunnel() - if currentTunnel == nil { - // Misfired event? - return - } - - if walk.DlgCmdNo == walk.MsgBox( - mtw, - fmt.Sprintf(`Delete "%s"`, currentTunnel.Name), - fmt.Sprintf(`Are you sure you want to delete "%s"?`, currentTunnel.Name), - walk.MsgBoxYesNo|walk.MsgBoxIconWarning) { - return - } - - mtw.deleteTunnel(currentTunnel) - - mtw.tunnelDeletedPublisher.Publish(currentTunnel.Name) -} - -func (mtw *ManageTunnelsWindow) onImport() { - dlg := walk.FileDialog{ - Filter: "Configuration Files (*.zip, *.conf)|*.zip;*.conf|All Files (*.*)|*.*", - Title: "Import tunnel(s) from file...", - } - - if ok, _ := dlg.ShowOpenMultiple(mtw); !ok { - return - } - - mtw.importFiles(dlg.FilePaths) -} - -func (mtw *ManageTunnelsWindow) onExportTunnels() { - dlg := walk.FileDialog{ - Filter: "Configuration ZIP Files (*.zip)|*.zip", - Title: "Export tunnels to zip...", - } - - if ok, _ := dlg.ShowSave(mtw); !ok { - return - } - - if !strings.HasSuffix(dlg.FilePath, ".zip") { - dlg.FilePath += ".zip" - } - - mtw.exportTunnels(dlg.FilePath) -} - -func (mtw *ManageTunnelsWindow) onViewLog() { - runLogDialog(mtw, mtw.logger) -} diff --git a/ui/tray.go b/ui/tray.go index e15d2549..2650530d 100644 --- a/ui/tray.go +++ b/ui/tray.go @@ -68,7 +68,7 @@ func (tray *Tray) setup() error { {separator: true}, {separator: true}, {label: "&Manage tunnels...", handler: tray.mtw.Show, enabled: true}, - {label: "&Import tunnel(s) from file...", handler: tray.mtw.onImport, enabled: true}, + {label: "&Import tunnel(s) from file...", handler: tray.mtw.tunnelsPage.onImport, enabled: true}, {separator: true}, {label: "&About WireGuard", handler: func() { onAbout(tray.mtw) }, enabled: true}, {label: "&Quit", handler: onQuit, enabled: true}, @@ -98,8 +98,8 @@ func (tray *Tray) setup() error { tray.addTunnelAction(tunnel.Name) } - tray.mtw.TunnelAdded().Attach(tray.addTunnelAction) - tray.mtw.TunnelDeleted().Attach(tray.removeTunnelAction) + tray.mtw.tunnelsPage.TunnelAdded().Attach(tray.addTunnelAction) + tray.mtw.tunnelsPage.TunnelDeleted().Attach(tray.removeTunnelAction) return nil } @@ -110,7 +110,7 @@ func (tray *Tray) addTunnelAction(tunnelName string) { tunnelAction.SetEnabled(true) tunnelAction.SetCheckable(true) tunnelAction.Triggered().Attach(func() { - if activeTunnel := tray.mtw.tunnelTracker.activeTunnel; activeTunnel != nil && activeTunnel.Name == tunnelName { + if activeTunnel := tray.mtw.tunnelsPage.tunnelTracker.activeTunnel; activeTunnel != nil && activeTunnel.Name == tunnelName { tray.onDeactivateTunnel() } else { tray.onActivateTunnel(tunnelName) @@ -148,7 +148,7 @@ func (tray *Tray) SetTunnelState(tunnel *service.Tunnel, state service.TunnelSta } func (tray *Tray) SetTunnelStateWithNotification(tunnel *service.Tunnel, state service.TunnelState, showNotifications bool) { - if icon, err := tray.mtw.tunnelsView.imageProvider.IconWithOverlayForState(tray.icon, state); err == nil { + if icon, err := tray.mtw.tunnelsPage.tunnelsView.imageProvider.IconWithOverlayForState(tray.icon, state); err == nil { tray.SetIcon(icon) } diff --git a/ui/tunnelspage.go b/ui/tunnelspage.go new file mode 100644 index 00000000..ac778b3a --- /dev/null +++ b/ui/tunnelspage.go @@ -0,0 +1,403 @@ +/* SPDX-License-Identifier: MIT + * + * Copyright (C) 2019 WireGuard LLC. All Rights Reserved. + */ + +package ui + +import ( + "archive/zip" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" + "time" + + "github.com/lxn/walk" + "golang.zx2c4.com/wireguard/windows/conf" + "golang.zx2c4.com/wireguard/windows/service" +) + +type TunnelsPage struct { + *walk.TabPage + + tunnelTracker *TunnelTracker + tunnelsView *TunnelsView + confView *ConfView + tunnelAddedPublisher walk.StringEventPublisher + tunnelDeletedPublisher walk.StringEventPublisher +} + +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("Tunnels") + tp.SetLayout(walk.NewHBoxLayout()) + + tunnelsContainer, _ := walk.NewComposite(tp) + tunnelsContainer.SetLayout(walk.NewVBoxLayout()) + + tp.tunnelsView, _ = NewTunnelsView(tunnelsContainer) + tp.tunnelsView.ItemActivated().Attach(tp.onTunnelsViewItemActivated) + tp.tunnelsView.CurrentIndexChanged().Attach(tp.updateConfView) + + // ToolBar actions + { + // HACK: Because of https://github.com/lxn/walk/issues/481 + // we need to put the ToolBar into its own Composite. + toolBarContainer, _ := walk.NewComposite(tunnelsContainer) + toolBarContainer.SetLayout(walk.NewHBoxLayout()) + + tunnelsToolBar, _ := walk.NewToolBarWithOrientationAndButtonStyle(toolBarContainer, walk.Horizontal, walk.ToolBarButtonTextOnly) + + importAction := walk.NewAction() + importAction.SetText("Import tunnels from file...") + importAction.Triggered().Attach(tp.onImport) + + addAction := walk.NewAction() + addAction.SetText("Add empty tunnel") + addAction.Triggered().Attach(tp.onAddTunnel) + + exportTunnelsAction := walk.NewAction() + exportTunnelsAction.SetText("Export tunnels to zip...") + exportTunnelsAction.Triggered().Attach(tp.onExportTunnels) + + addMenu, _ := walk.NewMenu() + tp.AddDisposable(addMenu) + addMenu.Actions().Add(addAction) + addMenu.Actions().Add(importAction) + addMenuAction, _ := tunnelsToolBar.Actions().AddMenu(addMenu) + addMenuAction.SetText("➕") + + deleteAction := walk.NewAction() + tunnelsToolBar.Actions().Add(deleteAction) + deleteAction.SetText("➖") + deleteAction.Triggered().Attach(tp.onDelete) + + settingsMenu, _ := walk.NewMenu() + tp.AddDisposable(settingsMenu) + settingsMenu.Actions().Add(exportTunnelsAction) + settingsMenuAction, _ := tunnelsToolBar.Actions().AddMenu(settingsMenu) + settingsMenuAction.SetText("⚙") + } + + currentTunnelContainer, _ := walk.NewComposite(tp) + currentTunnelContainer.SetLayout(walk.NewVBoxLayout()) + tp.Layout().(interface{ SetStretchFactor(walk.Widget, int) error }).SetStretchFactor(currentTunnelContainer, 10) + + tp.confView, _ = NewConfView(currentTunnelContainer) + + updateConfViewTicker := time.NewTicker(time.Second) + tp.Disposing().Attach(updateConfViewTicker.Stop) + go func() { + for range updateConfViewTicker.C { + tp.Synchronize(func() { + tp.updateConfView() + }) + } + }() + + controlsContainer, _ := walk.NewComposite(currentTunnelContainer) + controlsContainer.SetLayout(walk.NewHBoxLayout()) + controlsContainer.Layout().SetMargins(walk.Margins{}) + + walk.NewHSpacer(controlsContainer) + + editTunnel, _ := walk.NewPushButton(controlsContainer) + editTunnel.SetEnabled(false) + tp.tunnelsView.CurrentIndexChanged().Attach(func() { + editTunnel.SetEnabled(tp.tunnelsView.CurrentIndex() > -1) + }) + editTunnel.SetText("Edit") + editTunnel.Clicked().Attach(tp.onEditTunnel) + + tp.tunnelsView.SetCurrentIndex(0) + + disposables.Spare() + + return tp, nil +} + +func (tp *TunnelsPage) TunnelTracker() *TunnelTracker { + return tp.tunnelTracker +} + +func (tp *TunnelsPage) SetTunnelTracker(tunnelTracker *TunnelTracker) { + tp.tunnelTracker = tunnelTracker + + tp.confView.SetTunnelTracker(tunnelTracker) +} + +func (tp *TunnelsPage) SetTunnelState(tunnel *service.Tunnel, state service.TunnelState) { + tp.tunnelsView.SetTunnelState(tunnel, state) +} + +func (tp *TunnelsPage) updateConfView() { + if !tp.Visible() { + return + } + + tp.confView.SetTunnel(tp.tunnelsView.CurrentTunnel()) +} + +// importFiles tries to import a list of configurations. +func (tp *TunnelsPage) importFiles(paths []string) { + type unparsedConfig struct { + Name string + Config string + } + + var ( + unparsedConfigs []unparsedConfig + lastErr error + ) + + // Note: other versions of WireGuard start with all .zip files, then all .conf files. + // To reproduce that if needed, inverse-sort the array. + for _, path := range paths { + switch filepath.Ext(path) { + case ".conf": + textConfig, err := ioutil.ReadFile(path) + if err != nil { + lastErr = err + continue + } + unparsedConfigs = append(unparsedConfigs, unparsedConfig{Name: strings.TrimSuffix(filepath.Base(path), ".conf"), 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 filepath.Ext(f.Name) != ".conf" { + continue + } + + rc, err := f.Open() + if err != nil { + lastErr = err + continue + } + textConfig, err := ioutil.ReadAll(rc) + rc.Close() + if err != nil { + lastErr = err + continue + } + unparsedConfigs = append(unparsedConfigs, unparsedConfig{Name: strings.TrimSuffix(filepath.Base(f.Name), ".conf"), Config: string(textConfig)}) + } + + r.Close() + } + } + + if lastErr != nil || unparsedConfigs == nil { + walk.MsgBox(tp.Form(), "Error", fmt.Sprintf("Could not parse some files: %v", lastErr), walk.MsgBoxIconWarning) + return + } + + var configs []*conf.Config + + for _, unparsedConfig := range unparsedConfigs { + config, err := conf.FromWgQuick(unparsedConfig.Config, unparsedConfig.Name) + if err != nil { + lastErr = err + continue + } + service.IPCClientNewTunnel(config) + configs = append(configs, config) + } + + m, n := len(configs), len(unparsedConfigs) + switch { + case n == 1 && m != n: + walk.MsgBox(tp.Form(), "Error", fmt.Sprintf("Could not parse some files: %v", lastErr), walk.MsgBoxIconWarning) + case n == 1 && m == n: + // TODO: Select tunnel in the list + case m == n: + walk.MsgBox(tp.Form(), "Imported tunnels", fmt.Sprintf("Imported %d tunnels", m), walk.MsgBoxOK) + case m != n: + walk.MsgBox(tp.Form(), "Imported tunnels", fmt.Sprintf("Imported %d of %d tunnels", m, n), walk.MsgBoxIconWarning) + default: + panic("unreachable case") + } +} + +func (tp *TunnelsPage) exportTunnels(filePath string) { + writeFileWithOverwriteHandling(tp.Form(), filePath, func(file *os.File) error { + writer := zip.NewWriter(file) + + for _, tunnel := range tp.tunnelsView.model.tunnels { + cfg, err := tunnel.StoredConfig() + if err != nil { + return fmt.Errorf("onExportTunnels: tunnel.StoredConfig failed: %v", err) + } + + w, err := writer.Create(tunnel.Name + ".conf") + if err != nil { + return fmt.Errorf("onExportTunnels: writer.Create failed: %v", err) + } + + if _, err := w.Write(([]byte)(cfg.ToWgQuick())); err != nil { + return fmt.Errorf("onExportTunnels: cfg.ToWgQuick failed: %v", err) + } + } + + return writer.Close() + }) +} + +func (tp *TunnelsPage) addTunnel(config *conf.Config) { + tunnel, err := service.IPCClientNewTunnel(config) + if err != nil { + walk.MsgBox(tp.Form(), "Unable to create tunnel", err.Error(), walk.MsgBoxIconError) + return + } + + model := tp.tunnelsView.model + model.tunnels = append(model.tunnels, tunnel) + model.PublishRowsReset() + model.Sort(model.SortedColumn(), model.SortOrder()) + + for i, t := range model.tunnels { + if t.Name == tunnel.Name { + tp.tunnelsView.SetCurrentIndex(i) + break + } + } + + tp.confView.SetTunnel(&tunnel) + + tp.tunnelAddedPublisher.Publish(tunnel.Name) +} + +func (tp *TunnelsPage) deleteTunnel(tunnel *service.Tunnel) { + tunnel.Delete() + + model := tp.tunnelsView.model + + for i, t := range model.tunnels { + if t.Name == tunnel.Name { + model.tunnels = append(model.tunnels[:i], model.tunnels[i+1:]...) + model.PublishRowsRemoved(i, i) + break + } + } + + tp.tunnelDeletedPublisher.Publish(tunnel.Name) +} + +func (tp *TunnelsPage) TunnelAdded() *walk.StringEvent { + return tp.tunnelAddedPublisher.Event() +} + +func (tp *TunnelsPage) TunnelDeleted() *walk.StringEvent { + return tp.tunnelDeletedPublisher.Event() +} + +// Handlers + +func (tp *TunnelsPage) onTunnelsViewItemActivated() { + if tp.tunnelTracker.InTransition() { + return + } + + var err error + var title string + tunnel := tp.tunnelsView.CurrentTunnel() + activeTunnel := tp.tunnelTracker.ActiveTunnel() + if tunnel != nil && activeTunnel != nil && tunnel.Name == activeTunnel.Name { + err, title = tp.tunnelTracker.DeactivateTunnel(), "Deactivating tunnel failed" + } else { + err, title = tp.tunnelTracker.ActivateTunnel(tunnel), "Activating tunnel failed" + } + if err != nil { + walk.MsgBox(tp.Form(), title, fmt.Sprintf("Error: %s", err.Error()), walk.MsgBoxIconError) + } +} + +func (tp *TunnelsPage) onEditTunnel() { + tunnel := tp.tunnelsView.CurrentTunnel() + if tunnel == nil { + // Misfired event? + return + } + + if config := runTunnelConfigDialog(tp.Form(), tunnel); config != nil { + // Delete old one + tp.deleteTunnel(tunnel) + + // Save new one + tp.addTunnel(config) + } +} + +func (tp *TunnelsPage) onAddTunnel() { + if config := runTunnelConfigDialog(tp.Form(), nil); config != nil { + // Save new + tp.addTunnel(config) + } +} + +func (tp *TunnelsPage) onDelete() { + currentTunnel := tp.tunnelsView.CurrentTunnel() + if currentTunnel == nil { + // Misfired event? + return + } + + if walk.DlgCmdNo == walk.MsgBox( + tp.Form(), + fmt.Sprintf(`Delete "%s"`, currentTunnel.Name), + fmt.Sprintf(`Are you sure you want to delete "%s"?`, currentTunnel.Name), + walk.MsgBoxYesNo|walk.MsgBoxIconWarning) { + return + } + + tp.deleteTunnel(currentTunnel) + + tp.tunnelDeletedPublisher.Publish(currentTunnel.Name) +} + +func (tp *TunnelsPage) onImport() { + dlg := walk.FileDialog{ + Filter: "Configuration Files (*.zip, *.conf)|*.zip;*.conf|All Files (*.*)|*.*", + Title: "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: "Configuration ZIP Files (*.zip)|*.zip", + Title: "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) +} -- cgit v1.2.3-59-g8ed1b