aboutsummaryrefslogtreecommitdiffstats
path: root/Sources/WireGuardApp/UI/macOS/ViewController/TunnelsListTableViewController.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Sources/WireGuardApp/UI/macOS/ViewController/TunnelsListTableViewController.swift')
-rw-r--r--Sources/WireGuardApp/UI/macOS/ViewController/TunnelsListTableViewController.swift363
1 files changed, 363 insertions, 0 deletions
diff --git a/Sources/WireGuardApp/UI/macOS/ViewController/TunnelsListTableViewController.swift b/Sources/WireGuardApp/UI/macOS/ViewController/TunnelsListTableViewController.swift
new file mode 100644
index 0000000..a6cc5c5
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ViewController/TunnelsListTableViewController.swift
@@ -0,0 +1,363 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+protocol TunnelsListTableViewControllerDelegate: AnyObject {
+ func tunnelsSelected(tunnelIndices: [Int])
+ func tunnelsListEmpty()
+}
+
+class TunnelsListTableViewController: NSViewController {
+
+ let tunnelsManager: TunnelsManager
+ weak var delegate: TunnelsListTableViewControllerDelegate?
+ var isRemovingTunnelsFromWithinTheApp = false
+
+ let tableView: NSTableView = {
+ let tableView = NSTableView()
+ tableView.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("TunnelsList")))
+ tableView.headerView = nil
+ tableView.rowSizeStyle = .medium
+ tableView.allowsMultipleSelection = true
+ return tableView
+ }()
+
+ let addButton: NSPopUpButton = {
+ let imageItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
+ imageItem.image = NSImage(named: NSImage.addTemplateName)!
+
+ let menu = NSMenu()
+ menu.addItem(imageItem)
+ menu.addItem(withTitle: tr("macMenuAddEmptyTunnel"), action: #selector(handleAddEmptyTunnelAction), keyEquivalent: "n")
+ menu.addItem(withTitle: tr("macMenuImportTunnels"), action: #selector(handleImportTunnelAction), keyEquivalent: "o")
+ menu.autoenablesItems = false
+
+ let button = NSPopUpButton(frame: NSRect.zero, pullsDown: true)
+ button.menu = menu
+ button.bezelStyle = .smallSquare
+ (button.cell as? NSPopUpButtonCell)?.arrowPosition = .arrowAtBottom
+ return button
+ }()
+
+ let removeButton: NSButton = {
+ let image = NSImage(named: NSImage.removeTemplateName)!
+ let button = NSButton(image: image, target: self, action: #selector(handleRemoveTunnelAction))
+ button.bezelStyle = .smallSquare
+ button.imagePosition = .imageOnly
+ return button
+ }()
+
+ let actionButton: NSPopUpButton = {
+ let imageItem = NSMenuItem(title: "", action: nil, keyEquivalent: "")
+ imageItem.image = NSImage(named: NSImage.actionTemplateName)!
+
+ let menu = NSMenu()
+ menu.addItem(imageItem)
+ menu.addItem(withTitle: tr("macMenuViewLog"), action: #selector(handleViewLogAction), keyEquivalent: "")
+ menu.addItem(withTitle: tr("macMenuExportTunnels"), action: #selector(handleExportTunnelsAction), keyEquivalent: "")
+ menu.autoenablesItems = false
+
+ let button = NSPopUpButton(frame: NSRect.zero, pullsDown: true)
+ button.menu = menu
+ button.bezelStyle = .smallSquare
+ (button.cell as? NSPopUpButtonCell)?.arrowPosition = .arrowAtBottom
+ return button
+ }()
+
+ init(tunnelsManager: TunnelsManager) {
+ self.tunnelsManager = tunnelsManager
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func loadView() {
+ tableView.dataSource = self
+ tableView.delegate = self
+
+ tableView.doubleAction = #selector(listDoubleClicked(sender:))
+
+ let isSelected = selectTunnelInOperation() || selectTunnel(at: 0)
+ if !isSelected {
+ delegate?.tunnelsListEmpty()
+ }
+ tableView.allowsEmptySelection = false
+
+ let scrollView = NSScrollView()
+ scrollView.hasVerticalScroller = true
+ scrollView.autohidesScrollers = true
+ scrollView.borderType = .bezelBorder
+
+ let clipView = NSClipView()
+ clipView.documentView = tableView
+ scrollView.contentView = clipView
+
+ let buttonBar = NSStackView(views: [addButton, removeButton, actionButton])
+ buttonBar.orientation = .horizontal
+ buttonBar.spacing = -1
+
+ NSLayoutConstraint.activate([
+ removeButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 26),
+ removeButton.topAnchor.constraint(equalTo: buttonBar.topAnchor),
+ removeButton.bottomAnchor.constraint(equalTo: buttonBar.bottomAnchor)
+ ])
+
+ let fillerButton = FillerButton()
+
+ let containerView = NSView()
+ containerView.addSubview(scrollView)
+ containerView.addSubview(buttonBar)
+ containerView.addSubview(fillerButton)
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
+ buttonBar.translatesAutoresizingMaskIntoConstraints = false
+ fillerButton.translatesAutoresizingMaskIntoConstraints = false
+
+ NSLayoutConstraint.activate([
+ containerView.topAnchor.constraint(equalTo: scrollView.topAnchor),
+ containerView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
+ containerView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: buttonBar.topAnchor, constant: 1),
+ containerView.leadingAnchor.constraint(equalTo: buttonBar.leadingAnchor),
+ containerView.bottomAnchor.constraint(equalTo: buttonBar.bottomAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: fillerButton.topAnchor, constant: 1),
+ containerView.bottomAnchor.constraint(equalTo: fillerButton.bottomAnchor),
+ buttonBar.trailingAnchor.constraint(equalTo: fillerButton.leadingAnchor, constant: 1),
+ fillerButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor)
+ ])
+
+ NSLayoutConstraint.activate([
+ containerView.widthAnchor.constraint(equalToConstant: 180),
+ containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 120)
+ ])
+
+ addButton.menu?.items.forEach { $0.target = self }
+ actionButton.menu?.items.forEach { $0.target = self }
+
+ view = containerView
+ }
+
+ override func viewWillAppear() {
+ selectTunnelInOperation()
+ }
+
+ @discardableResult
+ func selectTunnelInOperation() -> Bool {
+ if let currentTunnel = tunnelsManager.tunnelInOperation(), let indexToSelect = tunnelsManager.index(of: currentTunnel) {
+ return selectTunnel(at: indexToSelect)
+ }
+ return false
+ }
+
+ @objc func handleAddEmptyTunnelAction() {
+ let tunnelEditVC = TunnelEditViewController(tunnelsManager: tunnelsManager, tunnel: nil)
+ tunnelEditVC.delegate = self
+ presentAsSheet(tunnelEditVC)
+ }
+
+ @objc func handleImportTunnelAction() {
+ ImportPanelPresenter.presentImportPanel(tunnelsManager: tunnelsManager, sourceVC: self)
+ }
+
+ @objc func handleRemoveTunnelAction() {
+ guard let window = view.window else { return }
+
+ let selectedTunnelIndices = tableView.selectedRowIndexes.sorted().filter { $0 >= 0 && $0 < tunnelsManager.numberOfTunnels() }
+ guard !selectedTunnelIndices.isEmpty else { return }
+ var nextSelection = selectedTunnelIndices.last! + 1
+ if nextSelection >= tunnelsManager.numberOfTunnels() {
+ nextSelection = max(selectedTunnelIndices.first! - 1, 0)
+ }
+
+ let alert = DeleteTunnelsConfirmationAlert()
+ if selectedTunnelIndices.count == 1 {
+ let firstSelectedTunnel = tunnelsManager.tunnel(at: selectedTunnelIndices.first!)
+ alert.messageText = tr(format: "macDeleteTunnelConfirmationAlertMessage (%@)", firstSelectedTunnel.name)
+ } else {
+ alert.messageText = tr(format: "macDeleteMultipleTunnelsConfirmationAlertMessage (%d)", selectedTunnelIndices.count)
+ }
+ alert.informativeText = tr("macDeleteTunnelConfirmationAlertInfo")
+ alert.onDeleteClicked = { [weak self] completion in
+ guard let self = self else { return }
+ self.selectTunnel(at: nextSelection)
+ let selectedTunnels = selectedTunnelIndices.map { self.tunnelsManager.tunnel(at: $0) }
+ self.isRemovingTunnelsFromWithinTheApp = true
+ self.tunnelsManager.removeMultiple(tunnels: selectedTunnels) { [weak self] error in
+ guard let self = self else { return }
+ self.isRemovingTunnelsFromWithinTheApp = false
+ defer { completion() }
+ if let error = error {
+ ErrorPresenter.showErrorAlert(error: error, from: self)
+ return
+ }
+ }
+ }
+ alert.beginSheetModal(for: window)
+ }
+
+ @objc func handleViewLogAction() {
+ let logVC = LogViewController()
+ self.presentAsSheet(logVC)
+ }
+
+ @objc func handleExportTunnelsAction() {
+ PrivateDataConfirmation.confirmAccess(to: tr("macExportPrivateData")) { [weak self] in
+ guard let self = self else { return }
+ guard let window = self.view.window else { return }
+ let savePanel = NSSavePanel()
+ savePanel.allowedFileTypes = ["zip"]
+ savePanel.prompt = tr("macSheetButtonExportZip")
+ savePanel.nameFieldLabel = tr("macNameFieldExportZip")
+ savePanel.nameFieldStringValue = "wireguard-export.zip"
+ let tunnelsManager = self.tunnelsManager
+ savePanel.beginSheetModal(for: window) { [weak tunnelsManager] response in
+ guard let tunnelsManager = tunnelsManager else { return }
+ guard response == .OK else { return }
+ guard let destinationURL = savePanel.url else { return }
+ let count = tunnelsManager.numberOfTunnels()
+ let tunnelConfigurations = (0 ..< count).compactMap { tunnelsManager.tunnel(at: $0).tunnelConfiguration }
+ ZipExporter.exportConfigFiles(tunnelConfigurations: tunnelConfigurations, to: destinationURL) { [weak self] error in
+ if let error = error {
+ ErrorPresenter.showErrorAlert(error: error, from: self)
+ return
+ }
+ }
+ }
+ }
+ }
+
+ @objc func listDoubleClicked(sender: AnyObject) {
+ let tunnelIndex = tableView.clickedRow
+ guard tunnelIndex >= 0 && tunnelIndex < tunnelsManager.numberOfTunnels() else { return }
+ let tunnel = tunnelsManager.tunnel(at: tunnelIndex)
+ if tunnel.hasOnDemandRules {
+ let turnOn = !tunnel.isActivateOnDemandEnabled
+ tunnelsManager.setOnDemandEnabled(turnOn, on: tunnel) { error in
+ if error == nil && !turnOn {
+ self.tunnelsManager.startDeactivation(of: tunnel)
+ }
+ }
+ } else {
+ if tunnel.status == .inactive {
+ tunnelsManager.startActivation(of: tunnel)
+ } else if tunnel.status == .active {
+ tunnelsManager.startDeactivation(of: tunnel)
+ }
+ }
+ }
+
+ @discardableResult
+ private func selectTunnel(at index: Int) -> Bool {
+ if index < tunnelsManager.numberOfTunnels() {
+ tableView.scrollRowToVisible(index)
+ tableView.selectRowIndexes(IndexSet(integer: index), byExtendingSelection: false)
+ return true
+ }
+ return false
+ }
+}
+
+extension TunnelsListTableViewController: TunnelEditViewControllerDelegate {
+ func tunnelSaved(tunnel: TunnelContainer) {
+ if let tunnelIndex = tunnelsManager.index(of: tunnel), tunnelIndex >= 0 {
+ self.selectTunnel(at: tunnelIndex)
+ }
+ }
+
+ func tunnelEditingCancelled() {
+ // Nothing to do
+ }
+}
+
+extension TunnelsListTableViewController {
+ func tunnelAdded(at index: Int) {
+ tableView.insertRows(at: IndexSet(integer: index), withAnimation: .slideLeft)
+ if tunnelsManager.numberOfTunnels() == 1 {
+ selectTunnel(at: 0)
+ }
+ if !NSApp.isActive {
+ // macOS's VPN prompt might have caused us to lose focus
+ NSApp.activate(ignoringOtherApps: true)
+ }
+ }
+
+ func tunnelModified(at index: Int) {
+ tableView.reloadData(forRowIndexes: IndexSet(integer: index), columnIndexes: IndexSet(integer: 0))
+ }
+
+ func tunnelMoved(from oldIndex: Int, to newIndex: Int) {
+ tableView.moveRow(at: oldIndex, to: newIndex)
+ }
+
+ func tunnelRemoved(at index: Int) {
+ let selectedIndices = tableView.selectedRowIndexes
+ let isSingleSelectedTunnelBeingRemoved = selectedIndices.contains(index) && selectedIndices.count == 1
+ tableView.removeRows(at: IndexSet(integer: index), withAnimation: .slideLeft)
+ if tunnelsManager.numberOfTunnels() == 0 {
+ delegate?.tunnelsListEmpty()
+ } else if !isRemovingTunnelsFromWithinTheApp && isSingleSelectedTunnelBeingRemoved {
+ let newSelection = min(index, tunnelsManager.numberOfTunnels() - 1)
+ tableView.selectRowIndexes(IndexSet(integer: newSelection), byExtendingSelection: false)
+ }
+ }
+}
+
+extension TunnelsListTableViewController: NSTableViewDataSource {
+ func numberOfRows(in tableView: NSTableView) -> Int {
+ return tunnelsManager.numberOfTunnels()
+ }
+}
+
+extension TunnelsListTableViewController: NSTableViewDelegate {
+ func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
+ let cell: TunnelListRow = tableView.dequeueReusableCell()
+ cell.tunnel = tunnelsManager.tunnel(at: row)
+ return cell
+ }
+
+ func tableViewSelectionDidChange(_ notification: Notification) {
+ let selectedTunnelIndices = tableView.selectedRowIndexes.sorted()
+ if !selectedTunnelIndices.isEmpty {
+ delegate?.tunnelsSelected(tunnelIndices: tableView.selectedRowIndexes.sorted())
+ }
+ }
+}
+
+extension TunnelsListTableViewController {
+ override func keyDown(with event: NSEvent) {
+ if event.specialKey == .delete {
+ handleRemoveTunnelAction()
+ }
+ }
+}
+
+extension TunnelsListTableViewController: NSMenuItemValidation {
+ func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
+ if menuItem.action == #selector(TunnelsListTableViewController.handleRemoveTunnelAction) {
+ return !tableView.selectedRowIndexes.isEmpty
+ }
+ return true
+ }
+}
+
+class FillerButton: NSButton {
+ override var intrinsicContentSize: NSSize {
+ return NSSize(width: NSView.noIntrinsicMetric, height: NSView.noIntrinsicMetric)
+ }
+
+ init() {
+ super.init(frame: CGRect.zero)
+ title = ""
+ bezelStyle = .smallSquare
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ // Eat mouseDown event, so that the button looks enabled but is unresponsive
+ }
+}