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 }