aboutsummaryrefslogtreecommitdiffstats
path: root/Sources/WireGuardApp/UI/macOS/ViewController
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2020-12-02 12:27:39 +0100
committerAndrej Mihajlov <and@mullvad.net>2020-12-03 13:32:24 +0100
commitec574085703ea1c8b2d4538596961beb910c4382 (patch)
tree73cf8bbdb74fe5575606664bccd0232ffa911803 /Sources/WireGuardApp/UI/macOS/ViewController
parentWireGuardKit: Assert that resolutionResults must not contain failures (diff)
downloadwireguard-apple-ec574085703ea1c8b2d4538596961beb910c4382.tar.xz
wireguard-apple-ec574085703ea1c8b2d4538596961beb910c4382.zip
Move all source files to `Sources/` and rename WireGuardKit targets
Signed-off-by: Andrej Mihajlov <and@mullvad.net>
Diffstat (limited to 'Sources/WireGuardApp/UI/macOS/ViewController')
-rw-r--r--Sources/WireGuardApp/UI/macOS/ViewController/ButtonedDetailViewController.swift54
-rw-r--r--Sources/WireGuardApp/UI/macOS/ViewController/LogViewController.swift274
-rw-r--r--Sources/WireGuardApp/UI/macOS/ViewController/ManageTunnelsRootViewController.swift135
-rw-r--r--Sources/WireGuardApp/UI/macOS/ViewController/TunnelDetailTableViewController.swift516
-rw-r--r--Sources/WireGuardApp/UI/macOS/ViewController/TunnelEditViewController.swift297
-rw-r--r--Sources/WireGuardApp/UI/macOS/ViewController/TunnelsListTableViewController.swift354
-rw-r--r--Sources/WireGuardApp/UI/macOS/ViewController/UnusableTunnelDetailViewController.swift71
7 files changed, 1701 insertions, 0 deletions
diff --git a/Sources/WireGuardApp/UI/macOS/ViewController/ButtonedDetailViewController.swift b/Sources/WireGuardApp/UI/macOS/ViewController/ButtonedDetailViewController.swift
new file mode 100644
index 0000000..09c2414
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ViewController/ButtonedDetailViewController.swift
@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class ButtonedDetailViewController: NSViewController {
+
+ var onButtonClicked: (() -> Void)?
+
+ let button: NSButton = {
+ let button = NSButton()
+ button.title = ""
+ button.setButtonType(.momentaryPushIn)
+ button.bezelStyle = .rounded
+ return button
+ }()
+
+ init() {
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func loadView() {
+ let view = NSView()
+
+ button.target = self
+ button.action = #selector(buttonClicked)
+
+ view.addSubview(button)
+ button.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
+ ])
+
+ NSLayoutConstraint.activate([
+ view.widthAnchor.constraint(greaterThanOrEqualToConstant: 320),
+ view.heightAnchor.constraint(greaterThanOrEqualToConstant: 120)
+ ])
+
+ self.view = view
+ }
+
+ func setButtonTitle(_ title: String) {
+ button.title = title
+ }
+
+ @objc func buttonClicked() {
+ onButtonClicked?()
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/ViewController/LogViewController.swift b/Sources/WireGuardApp/UI/macOS/ViewController/LogViewController.swift
new file mode 100644
index 0000000..39ce663
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ViewController/LogViewController.swift
@@ -0,0 +1,274 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class LogViewController: NSViewController {
+
+ enum LogColumn: String {
+ case time = "Time"
+ case logMessage = "LogMessage"
+
+ func createColumn() -> NSTableColumn {
+ return NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue))
+ }
+
+ func isRepresenting(tableColumn: NSTableColumn?) -> Bool {
+ return tableColumn?.identifier.rawValue == rawValue
+ }
+ }
+
+ let scrollView: NSScrollView = {
+ let scrollView = NSScrollView()
+ scrollView.hasVerticalScroller = true
+ scrollView.autohidesScrollers = false
+ scrollView.borderType = .bezelBorder
+ return scrollView
+ }()
+
+ let tableView: NSTableView = {
+ let tableView = NSTableView()
+ let timeColumn = LogColumn.time.createColumn()
+ timeColumn.title = tr("macLogColumnTitleTime")
+ timeColumn.width = 160
+ timeColumn.resizingMask = []
+ tableView.addTableColumn(timeColumn)
+ let messageColumn = LogColumn.logMessage.createColumn()
+ messageColumn.title = tr("macLogColumnTitleLogMessage")
+ messageColumn.minWidth = 360
+ messageColumn.resizingMask = .autoresizingMask
+ tableView.addTableColumn(messageColumn)
+ tableView.rowSizeStyle = .custom
+ tableView.rowHeight = 16
+ tableView.usesAlternatingRowBackgroundColors = true
+ tableView.usesAutomaticRowHeights = true
+ tableView.allowsColumnReordering = false
+ tableView.allowsColumnResizing = true
+ tableView.allowsMultipleSelection = true
+ return tableView
+ }()
+
+ let progressIndicator: NSProgressIndicator = {
+ let progressIndicator = NSProgressIndicator()
+ progressIndicator.controlSize = .small
+ progressIndicator.isIndeterminate = true
+ progressIndicator.style = .spinning
+ progressIndicator.isDisplayedWhenStopped = false
+ return progressIndicator
+ }()
+
+ let closeButton: NSButton = {
+ let button = NSButton()
+ button.title = tr("macLogButtonTitleClose")
+ button.setButtonType(.momentaryPushIn)
+ button.bezelStyle = .rounded
+ return button
+ }()
+
+ let saveButton: NSButton = {
+ let button = NSButton()
+ button.title = tr("macLogButtonTitleSave")
+ button.setButtonType(.momentaryPushIn)
+ button.bezelStyle = .rounded
+ return button
+ }()
+
+ let logViewHelper: LogViewHelper?
+ var logEntries = [LogViewHelper.LogEntry]()
+ var isFetchingLogEntries = false
+ var isInScrolledToEndMode = true
+
+ private var updateLogEntriesTimer: Timer?
+
+ init() {
+ logViewHelper = LogViewHelper(logFilePath: FileManager.logFileURL?.path)
+ 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
+
+ closeButton.target = self
+ closeButton.action = #selector(closeClicked)
+
+ saveButton.target = self
+ saveButton.action = #selector(saveClicked)
+ saveButton.isEnabled = false
+
+ let clipView = NSClipView()
+ clipView.documentView = tableView
+ scrollView.contentView = clipView
+
+ _ = NotificationCenter.default.addObserver(forName: NSView.boundsDidChangeNotification, object: clipView, queue: OperationQueue.main) { [weak self] _ in
+ guard let self = self else { return }
+ let lastVisibleRowIndex = self.tableView.row(at: NSPoint(x: 0, y: self.scrollView.contentView.documentVisibleRect.maxY - 1))
+ self.isInScrolledToEndMode = lastVisibleRowIndex < 0 || lastVisibleRowIndex == self.logEntries.count - 1
+ }
+
+ _ = NotificationCenter.default.addObserver(forName: NSView.frameDidChangeNotification, object: tableView, queue: OperationQueue.main) { [weak self] _ in
+ guard let self = self else { return }
+ if self.isInScrolledToEndMode {
+ DispatchQueue.main.async {
+ self.tableView.scroll(NSPoint(x: 0, y: self.tableView.frame.maxY - clipView.documentVisibleRect.height))
+ }
+ }
+ }
+
+ let margin: CGFloat = 20
+ let internalSpacing: CGFloat = 10
+
+ let buttonRowStackView = NSStackView()
+ buttonRowStackView.addView(closeButton, in: .leading)
+ buttonRowStackView.addView(saveButton, in: .trailing)
+ buttonRowStackView.orientation = .horizontal
+ buttonRowStackView.spacing = internalSpacing
+
+ let containerView = NSView()
+ [scrollView, progressIndicator, buttonRowStackView].forEach { view in
+ containerView.addSubview(view)
+ view.translatesAutoresizingMaskIntoConstraints = false
+ }
+ NSLayoutConstraint.activate([
+ scrollView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: margin),
+ scrollView.leftAnchor.constraint(equalTo: containerView.leftAnchor, constant: margin),
+ containerView.rightAnchor.constraint(equalTo: scrollView.rightAnchor, constant: margin),
+ buttonRowStackView.topAnchor.constraint(equalTo: scrollView.bottomAnchor, constant: internalSpacing),
+ buttonRowStackView.leftAnchor.constraint(equalTo: containerView.leftAnchor, constant: margin),
+ containerView.rightAnchor.constraint(equalTo: buttonRowStackView.rightAnchor, constant: margin),
+ containerView.bottomAnchor.constraint(equalTo: buttonRowStackView.bottomAnchor, constant: margin),
+ progressIndicator.centerXAnchor.constraint(equalTo: scrollView.centerXAnchor),
+ progressIndicator.centerYAnchor.constraint(equalTo: scrollView.centerYAnchor)
+ ])
+
+ NSLayoutConstraint.activate([
+ containerView.widthAnchor.constraint(greaterThanOrEqualToConstant: 640),
+ containerView.widthAnchor.constraint(lessThanOrEqualToConstant: 1200),
+ containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 240)
+ ])
+
+ containerView.frame = NSRect(x: 0, y: 0, width: 640, height: 480)
+
+ view = containerView
+
+ progressIndicator.startAnimation(self)
+ startUpdatingLogEntries()
+ }
+
+ func updateLogEntries() {
+ guard !isFetchingLogEntries else { return }
+ isFetchingLogEntries = true
+ logViewHelper?.fetchLogEntriesSinceLastFetch { [weak self] fetchedLogEntries in
+ guard let self = self else { return }
+ defer {
+ self.isFetchingLogEntries = false
+ }
+ if !self.progressIndicator.isHidden {
+ self.progressIndicator.stopAnimation(self)
+ self.saveButton.isEnabled = true
+ }
+ guard !fetchedLogEntries.isEmpty else { return }
+ let oldCount = self.logEntries.count
+ self.logEntries.append(contentsOf: fetchedLogEntries)
+ self.tableView.insertRows(at: IndexSet(integersIn: oldCount ..< oldCount + fetchedLogEntries.count), withAnimation: .slideDown)
+ }
+ }
+
+ func startUpdatingLogEntries() {
+ updateLogEntries()
+ updateLogEntriesTimer?.invalidate()
+ let timer = Timer(timeInterval: 1 /* second */, repeats: true) { [weak self] _ in
+ self?.updateLogEntries()
+ }
+ updateLogEntriesTimer = timer
+ RunLoop.main.add(timer, forMode: .common)
+ }
+
+ func stopUpdatingLogEntries() {
+ updateLogEntriesTimer?.invalidate()
+ updateLogEntriesTimer = nil
+ }
+
+ override func viewWillAppear() {
+ view.window?.setFrameAutosaveName(NSWindow.FrameAutosaveName("LogWindow"))
+ }
+
+ override func viewWillDisappear() {
+ super.viewWillDisappear()
+ stopUpdatingLogEntries()
+ }
+
+ @objc func saveClicked() {
+ let savePanel = NSSavePanel()
+ savePanel.prompt = tr("macSheetButtonExportLog")
+ savePanel.nameFieldLabel = tr("macNameFieldExportLog")
+
+ let dateFormatter = ISO8601DateFormatter()
+ dateFormatter.formatOptions = [.withFullDate, .withTime, .withTimeZone] // Avoid ':' in the filename
+ let timeStampString = dateFormatter.string(from: Date())
+ savePanel.nameFieldStringValue = "wireguard-log-\(timeStampString).txt"
+
+ savePanel.beginSheetModal(for: self.view.window!) { [weak self] response in
+ guard response == .OK else { return }
+ guard let destinationURL = savePanel.url else { return }
+
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ let isWritten = Logger.global?.writeLog(to: destinationURL.path) ?? false
+ guard isWritten else {
+ DispatchQueue.main.async { [weak self] in
+ ErrorPresenter.showErrorAlert(title: tr("alertUnableToWriteLogTitle"), message: tr("alertUnableToWriteLogMessage"), from: self)
+ }
+ return
+ }
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ self.presentingViewController?.dismiss(self)
+ }
+ }
+
+ }
+ }
+
+ @objc func closeClicked() {
+ presentingViewController?.dismiss(self)
+ }
+
+ @objc func copy(_ sender: Any?) {
+ let text = tableView.selectedRowIndexes.sorted().reduce("") { $0 + self.logEntries[$1].text() + "\n" }
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+ pasteboard.writeObjects([text as NSString])
+ }
+}
+
+extension LogViewController: NSTableViewDataSource {
+ func numberOfRows(in tableView: NSTableView) -> Int {
+ return logEntries.count
+ }
+}
+
+extension LogViewController: NSTableViewDelegate {
+ func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
+ if LogColumn.time.isRepresenting(tableColumn: tableColumn) {
+ let cell: LogViewTimestampCell = tableView.dequeueReusableCell()
+ cell.text = logEntries[row].timestamp
+ return cell
+ } else if LogColumn.logMessage.isRepresenting(tableColumn: tableColumn) {
+ let cell: LogViewMessageCell = tableView.dequeueReusableCell()
+ cell.text = logEntries[row].message
+ return cell
+ } else {
+ fatalError()
+ }
+ }
+}
+
+extension LogViewController {
+ override func cancelOperation(_ sender: Any?) {
+ closeClicked()
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/ViewController/ManageTunnelsRootViewController.swift b/Sources/WireGuardApp/UI/macOS/ViewController/ManageTunnelsRootViewController.swift
new file mode 100644
index 0000000..0ad0805
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ViewController/ManageTunnelsRootViewController.swift
@@ -0,0 +1,135 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class ManageTunnelsRootViewController: NSViewController {
+
+ let tunnelsManager: TunnelsManager
+ var tunnelsListVC: TunnelsListTableViewController?
+ var tunnelDetailVC: TunnelDetailTableViewController?
+ let tunnelDetailContainerView = NSView()
+ var tunnelDetailContentVC: NSViewController?
+
+ 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() {
+ view = NSView()
+
+ let horizontalSpacing: CGFloat = 20
+ let verticalSpacing: CGFloat = 20
+ let centralSpacing: CGFloat = 10
+
+ let container = NSLayoutGuide()
+ view.addLayoutGuide(container)
+ NSLayoutConstraint.activate([
+ container.topAnchor.constraint(equalTo: view.topAnchor, constant: verticalSpacing),
+ view.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: verticalSpacing),
+ container.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: horizontalSpacing),
+ view.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: horizontalSpacing)
+ ])
+
+ tunnelsListVC = TunnelsListTableViewController(tunnelsManager: tunnelsManager)
+ tunnelsListVC!.delegate = self
+ let tunnelsListView = tunnelsListVC!.view
+
+ addChild(tunnelsListVC!)
+ view.addSubview(tunnelsListView)
+ view.addSubview(tunnelDetailContainerView)
+
+ tunnelsListView.translatesAutoresizingMaskIntoConstraints = false
+ tunnelDetailContainerView.translatesAutoresizingMaskIntoConstraints = false
+
+ NSLayoutConstraint.activate([
+ tunnelsListView.topAnchor.constraint(equalTo: container.topAnchor),
+ tunnelsListView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+ tunnelsListView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+ tunnelDetailContainerView.topAnchor.constraint(equalTo: container.topAnchor),
+ tunnelDetailContainerView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+ tunnelDetailContainerView.leadingAnchor.constraint(equalTo: tunnelsListView.trailingAnchor, constant: centralSpacing),
+ tunnelDetailContainerView.trailingAnchor.constraint(equalTo: container.trailingAnchor)
+ ])
+ }
+
+ private func setTunnelDetailContentVC(_ contentVC: NSViewController) {
+ if let currentContentVC = tunnelDetailContentVC {
+ currentContentVC.view.removeFromSuperview()
+ currentContentVC.removeFromParent()
+ }
+ addChild(contentVC)
+ tunnelDetailContainerView.addSubview(contentVC.view)
+ contentVC.view.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ tunnelDetailContainerView.topAnchor.constraint(equalTo: contentVC.view.topAnchor),
+ tunnelDetailContainerView.bottomAnchor.constraint(equalTo: contentVC.view.bottomAnchor),
+ tunnelDetailContainerView.leadingAnchor.constraint(equalTo: contentVC.view.leadingAnchor),
+ tunnelDetailContainerView.trailingAnchor.constraint(equalTo: contentVC.view.trailingAnchor)
+ ])
+ tunnelDetailContentVC = contentVC
+ }
+}
+
+extension ManageTunnelsRootViewController: TunnelsListTableViewControllerDelegate {
+ func tunnelsSelected(tunnelIndices: [Int]) {
+ assert(!tunnelIndices.isEmpty)
+ if tunnelIndices.count == 1 {
+ let tunnel = tunnelsManager.tunnel(at: tunnelIndices.first!)
+ if tunnel.isTunnelAvailableToUser {
+ let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager, tunnel: tunnel)
+ setTunnelDetailContentVC(tunnelDetailVC)
+ self.tunnelDetailVC = tunnelDetailVC
+ } else {
+ let unusableTunnelDetailVC = tunnelDetailContentVC as? UnusableTunnelDetailViewController ?? UnusableTunnelDetailViewController()
+ unusableTunnelDetailVC.onButtonClicked = { [weak tunnelsListVC] in
+ tunnelsListVC?.handleRemoveTunnelAction()
+ }
+ setTunnelDetailContentVC(unusableTunnelDetailVC)
+ self.tunnelDetailVC = nil
+ }
+ } else if tunnelIndices.count > 1 {
+ let multiSelectionVC = tunnelDetailContentVC as? ButtonedDetailViewController ?? ButtonedDetailViewController()
+ multiSelectionVC.setButtonTitle(tr(format: "macButtonDeleteTunnels (%d)", tunnelIndices.count))
+ multiSelectionVC.onButtonClicked = { [weak tunnelsListVC] in
+ tunnelsListVC?.handleRemoveTunnelAction()
+ }
+ setTunnelDetailContentVC(multiSelectionVC)
+ self.tunnelDetailVC = nil
+ }
+ }
+
+ func tunnelsListEmpty() {
+ let noTunnelsVC = ButtonedDetailViewController()
+ noTunnelsVC.setButtonTitle(tr("macButtonImportTunnels"))
+ noTunnelsVC.onButtonClicked = { [weak self] in
+ guard let self = self else { return }
+ ImportPanelPresenter.presentImportPanel(tunnelsManager: self.tunnelsManager, sourceVC: self)
+ }
+ setTunnelDetailContentVC(noTunnelsVC)
+ self.tunnelDetailVC = nil
+ }
+}
+
+extension ManageTunnelsRootViewController {
+ override func supplementalTarget(forAction action: Selector, sender: Any?) -> Any? {
+ switch action {
+ case #selector(TunnelsListTableViewController.handleViewLogAction),
+ #selector(TunnelsListTableViewController.handleAddEmptyTunnelAction),
+ #selector(TunnelsListTableViewController.handleImportTunnelAction),
+ #selector(TunnelsListTableViewController.handleExportTunnelsAction),
+ #selector(TunnelsListTableViewController.handleRemoveTunnelAction):
+ return tunnelsListVC
+ case #selector(TunnelDetailTableViewController.handleToggleActiveStatusAction),
+ #selector(TunnelDetailTableViewController.handleEditTunnelAction):
+ return tunnelDetailVC
+ default:
+ return super.supplementalTarget(forAction: action, sender: sender)
+ }
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/ViewController/TunnelDetailTableViewController.swift b/Sources/WireGuardApp/UI/macOS/ViewController/TunnelDetailTableViewController.swift
new file mode 100644
index 0000000..80b759e
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ViewController/TunnelDetailTableViewController.swift
@@ -0,0 +1,516 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+import WireGuardKit
+
+class TunnelDetailTableViewController: NSViewController {
+
+ private enum TableViewModelRow {
+ case interfaceFieldRow(TunnelViewModel.InterfaceField)
+ case peerFieldRow(peer: TunnelViewModel.PeerData, field: TunnelViewModel.PeerField)
+ case onDemandRow
+ case onDemandSSIDRow
+ case spacerRow
+
+ func localizedSectionKeyString() -> String {
+ switch self {
+ case .interfaceFieldRow: return tr("tunnelSectionTitleInterface")
+ case .peerFieldRow: return tr("tunnelSectionTitlePeer")
+ case .onDemandRow: return tr("macFieldOnDemand")
+ case .onDemandSSIDRow: return ""
+ case .spacerRow: return ""
+ }
+ }
+
+ func isTitleRow() -> Bool {
+ switch self {
+ case .interfaceFieldRow(let field): return field == .name
+ case .peerFieldRow(_, let field): return field == .publicKey
+ case .onDemandRow: return true
+ case .onDemandSSIDRow: return false
+ case .spacerRow: return false
+ }
+ }
+ }
+
+ static let interfaceFields: [TunnelViewModel.InterfaceField] = [
+ .name, .status, .publicKey, .addresses,
+ .listenPort, .mtu, .dns, .toggleStatus
+ ]
+
+ static let peerFields: [TunnelViewModel.PeerField] = [
+ .publicKey, .preSharedKey, .endpoint,
+ .allowedIPs, .persistentKeepAlive,
+ .rxBytes, .txBytes, .lastHandshakeTime
+ ]
+
+ static let onDemandFields: [ActivateOnDemandViewModel.OnDemandField] = [
+ .onDemand, .ssid
+ ]
+
+ let tableView: NSTableView = {
+ let tableView = NSTableView()
+ tableView.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("TunnelDetail")))
+ tableView.headerView = nil
+ tableView.rowSizeStyle = .medium
+ tableView.backgroundColor = .clear
+ tableView.selectionHighlightStyle = .none
+ return tableView
+ }()
+
+ let editButton: NSButton = {
+ let button = NSButton()
+ button.title = tr("macButtonEdit")
+ button.setButtonType(.momentaryPushIn)
+ button.bezelStyle = .rounded
+ button.toolTip = tr("macToolTipEditTunnel")
+ return button
+ }()
+
+ let box: NSBox = {
+ let box = NSBox()
+ box.titlePosition = .noTitle
+ box.fillColor = .unemphasizedSelectedContentBackgroundColor
+ return box
+ }()
+
+ let tunnelsManager: TunnelsManager
+ let tunnel: TunnelContainer
+
+ var tunnelViewModel: TunnelViewModel {
+ didSet {
+ updateTableViewModelRowsBySection()
+ updateTableViewModelRows()
+ }
+ }
+
+ var onDemandViewModel: ActivateOnDemandViewModel
+
+ private var tableViewModelRowsBySection = [[(isVisible: Bool, modelRow: TableViewModelRow)]]()
+ private var tableViewModelRows = [TableViewModelRow]()
+
+ private var statusObservationToken: AnyObject?
+ private var tunnelEditVC: TunnelEditViewController?
+ private var reloadRuntimeConfigurationTimer: Timer?
+
+ init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) {
+ self.tunnelsManager = tunnelsManager
+ self.tunnel = tunnel
+ tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration)
+ onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel)
+ super.init(nibName: nil, bundle: nil)
+ updateTableViewModelRowsBySection()
+ updateTableViewModelRows()
+ statusObservationToken = tunnel.observe(\TunnelContainer.status) { [weak self] _, _ in
+ guard let self = self else { return }
+ if tunnel.status == .active {
+ self.startUpdatingRuntimeConfiguration()
+ } else if tunnel.status == .inactive {
+ self.reloadRuntimeConfiguration()
+ self.stopUpdatingRuntimeConfiguration()
+ }
+ }
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func loadView() {
+ tableView.dataSource = self
+ tableView.delegate = self
+
+ editButton.target = self
+ editButton.action = #selector(handleEditTunnelAction)
+
+ let clipView = NSClipView()
+ clipView.documentView = tableView
+
+ let scrollView = NSScrollView()
+ scrollView.contentView = clipView // Set contentView before setting drawsBackground
+ scrollView.drawsBackground = false
+ scrollView.hasVerticalScroller = true
+ scrollView.autohidesScrollers = true
+
+ let containerView = NSView()
+ let bottomControlsContainer = NSLayoutGuide()
+ containerView.addLayoutGuide(bottomControlsContainer)
+ containerView.addSubview(box)
+ containerView.addSubview(scrollView)
+ containerView.addSubview(editButton)
+ box.translatesAutoresizingMaskIntoConstraints = false
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
+ editButton.translatesAutoresizingMaskIntoConstraints = false
+
+ NSLayoutConstraint.activate([
+ containerView.topAnchor.constraint(equalTo: scrollView.topAnchor),
+ containerView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor),
+ containerView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor),
+ containerView.leadingAnchor.constraint(equalTo: bottomControlsContainer.leadingAnchor),
+ containerView.trailingAnchor.constraint(equalTo: bottomControlsContainer.trailingAnchor),
+ bottomControlsContainer.heightAnchor.constraint(equalToConstant: 32),
+ scrollView.bottomAnchor.constraint(equalTo: bottomControlsContainer.topAnchor),
+ bottomControlsContainer.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
+ editButton.trailingAnchor.constraint(equalTo: bottomControlsContainer.trailingAnchor),
+ bottomControlsContainer.bottomAnchor.constraint(equalTo: editButton.bottomAnchor, constant: 0)
+ ])
+
+ NSLayoutConstraint.activate([
+ scrollView.topAnchor.constraint(equalTo: box.topAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: box.bottomAnchor),
+ scrollView.leadingAnchor.constraint(equalTo: box.leadingAnchor),
+ scrollView.trailingAnchor.constraint(equalTo: box.trailingAnchor)
+ ])
+
+ NSLayoutConstraint.activate([
+ containerView.widthAnchor.constraint(greaterThanOrEqualToConstant: 320),
+ containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 120)
+ ])
+
+ view = containerView
+ }
+
+ func updateTableViewModelRowsBySection() {
+ var modelRowsBySection = [[(isVisible: Bool, modelRow: TableViewModelRow)]]()
+
+ var interfaceSection = [(isVisible: Bool, modelRow: TableViewModelRow)]()
+ for field in TunnelDetailTableViewController.interfaceFields {
+ let isStatus = field == .status || field == .toggleStatus
+ let isEmpty = tunnelViewModel.interfaceData[field].isEmpty
+ interfaceSection.append((isVisible: isStatus || !isEmpty, modelRow: .interfaceFieldRow(field)))
+ }
+ interfaceSection.append((isVisible: true, modelRow: .spacerRow))
+ modelRowsBySection.append(interfaceSection)
+
+ for peerData in tunnelViewModel.peersData {
+ var peerSection = [(isVisible: Bool, modelRow: TableViewModelRow)]()
+ for field in TunnelDetailTableViewController.peerFields {
+ peerSection.append((isVisible: !peerData[field].isEmpty, modelRow: .peerFieldRow(peer: peerData, field: field)))
+ }
+ peerSection.append((isVisible: true, modelRow: .spacerRow))
+ modelRowsBySection.append(peerSection)
+ }
+
+ var onDemandSection = [(isVisible: Bool, modelRow: TableViewModelRow)]()
+ onDemandSection.append((isVisible: true, modelRow: .onDemandRow))
+ if onDemandViewModel.isWiFiInterfaceEnabled {
+ onDemandSection.append((isVisible: true, modelRow: .onDemandSSIDRow))
+ }
+ modelRowsBySection.append(onDemandSection)
+
+ tableViewModelRowsBySection = modelRowsBySection
+ }
+
+ func updateTableViewModelRows() {
+ tableViewModelRows = tableViewModelRowsBySection.flatMap { $0.filter { $0.isVisible }.map { $0.modelRow } }
+ }
+
+ @objc func handleEditTunnelAction() {
+ PrivateDataConfirmation.confirmAccess(to: tr("macViewPrivateData")) { [weak self] in
+ guard let self = self else { return }
+ let tunnelEditVC = TunnelEditViewController(tunnelsManager: self.tunnelsManager, tunnel: self.tunnel)
+ tunnelEditVC.delegate = self
+ self.presentAsSheet(tunnelEditVC)
+ self.tunnelEditVC = tunnelEditVC
+ }
+ }
+
+ @objc func handleToggleActiveStatusAction() {
+ if tunnel.status == .inactive {
+ tunnelsManager.startActivation(of: tunnel)
+ } else if tunnel.status == .active {
+ tunnelsManager.startDeactivation(of: tunnel)
+ }
+ }
+
+ override func viewWillAppear() {
+ if tunnel.status == .active {
+ startUpdatingRuntimeConfiguration()
+ }
+ }
+
+ override func viewWillDisappear() {
+ super.viewWillDisappear()
+ if let tunnelEditVC = tunnelEditVC {
+ dismiss(tunnelEditVC)
+ }
+ stopUpdatingRuntimeConfiguration()
+ }
+
+ func applyTunnelConfiguration(tunnelConfiguration: TunnelConfiguration) {
+ // Incorporates changes from tunnelConfiguation. Ignores any changes in peer ordering.
+
+ let tableView = self.tableView
+
+ func handleSectionFieldsModified<T>(fields: [T], modelRowsInSection: [(isVisible: Bool, modelRow: TableViewModelRow)], rowOffset: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) {
+ var modifiedRowIndices = IndexSet()
+ for (index, field) in fields.enumerated() {
+ guard let change = changes[field] else { continue }
+ if case .modified(_) = change {
+ let row = modelRowsInSection[0 ..< index].filter { $0.isVisible }.count
+ modifiedRowIndices.insert(rowOffset + row)
+ }
+ }
+ if !modifiedRowIndices.isEmpty {
+ tableView.reloadData(forRowIndexes: modifiedRowIndices, columnIndexes: IndexSet(integer: 0))
+ }
+ }
+
+ func handleSectionFieldsAddedOrRemoved<T>(fields: [T], modelRowsInSection: inout [(isVisible: Bool, modelRow: TableViewModelRow)], rowOffset: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) {
+ for (index, field) in fields.enumerated() {
+ guard let change = changes[field] else { continue }
+ let row = modelRowsInSection[0 ..< index].filter { $0.isVisible }.count
+ switch change {
+ case .added:
+ tableView.insertRows(at: IndexSet(integer: rowOffset + row), withAnimation: .effectFade)
+ modelRowsInSection[index].isVisible = true
+ case .removed:
+ tableView.removeRows(at: IndexSet(integer: rowOffset + row), withAnimation: .effectFade)
+ modelRowsInSection[index].isVisible = false
+ case .modified:
+ break
+ }
+ }
+ }
+
+ let changes = self.tunnelViewModel.applyConfiguration(other: tunnelConfiguration)
+
+ if !changes.interfaceChanges.isEmpty {
+ handleSectionFieldsModified(fields: TunnelDetailTableViewController.interfaceFields,
+ modelRowsInSection: self.tableViewModelRowsBySection[0],
+ rowOffset: 0, changes: changes.interfaceChanges)
+ }
+ for (peerIndex, peerChanges) in changes.peerChanges {
+ let sectionIndex = 1 + peerIndex
+ let rowOffset = self.tableViewModelRowsBySection[0 ..< sectionIndex].flatMap { $0.filter { $0.isVisible } }.count
+ handleSectionFieldsModified(fields: TunnelDetailTableViewController.peerFields,
+ modelRowsInSection: self.tableViewModelRowsBySection[sectionIndex],
+ rowOffset: rowOffset, changes: peerChanges)
+ }
+
+ let isAnyInterfaceFieldAddedOrRemoved = changes.interfaceChanges.contains { $0.value == .added || $0.value == .removed }
+ let isAnyPeerFieldAddedOrRemoved = changes.peerChanges.contains { $0.changes.contains { $0.value == .added || $0.value == .removed } }
+
+ if isAnyInterfaceFieldAddedOrRemoved || isAnyPeerFieldAddedOrRemoved || !changes.peersRemovedIndices.isEmpty || !changes.peersInsertedIndices.isEmpty {
+ tableView.beginUpdates()
+ if isAnyInterfaceFieldAddedOrRemoved {
+ handleSectionFieldsAddedOrRemoved(fields: TunnelDetailTableViewController.interfaceFields,
+ modelRowsInSection: &self.tableViewModelRowsBySection[0],
+ rowOffset: 0, changes: changes.interfaceChanges)
+ }
+ if isAnyPeerFieldAddedOrRemoved {
+ for (peerIndex, peerChanges) in changes.peerChanges {
+ let sectionIndex = 1 + peerIndex
+ let rowOffset = self.tableViewModelRowsBySection[0 ..< sectionIndex].flatMap { $0.filter { $0.isVisible } }.count
+ handleSectionFieldsAddedOrRemoved(fields: TunnelDetailTableViewController.peerFields, modelRowsInSection: &self.tableViewModelRowsBySection[sectionIndex], rowOffset: rowOffset, changes: peerChanges)
+ }
+ }
+ if !changes.peersRemovedIndices.isEmpty {
+ for peerIndex in changes.peersRemovedIndices {
+ let sectionIndex = 1 + peerIndex
+ let rowOffset = self.tableViewModelRowsBySection[0 ..< sectionIndex].flatMap { $0.filter { $0.isVisible } }.count
+ let count = self.tableViewModelRowsBySection[sectionIndex].filter { $0.isVisible }.count
+ self.tableView.removeRows(at: IndexSet(integersIn: rowOffset ..< rowOffset + count), withAnimation: .effectFade)
+ self.tableViewModelRowsBySection.remove(at: sectionIndex)
+ }
+ }
+ if !changes.peersInsertedIndices.isEmpty {
+ for peerIndex in changes.peersInsertedIndices {
+ let peerData = self.tunnelViewModel.peersData[peerIndex]
+ let sectionIndex = 1 + peerIndex
+ let rowOffset = self.tableViewModelRowsBySection[0 ..< sectionIndex].flatMap { $0.filter { $0.isVisible } }.count
+ var modelRowsInSection: [(isVisible: Bool, modelRow: TableViewModelRow)] = TunnelDetailTableViewController.peerFields.map {
+ (isVisible: !peerData[$0].isEmpty, modelRow: .peerFieldRow(peer: peerData, field: $0))
+ }
+ modelRowsInSection.append((isVisible: true, modelRow: .spacerRow))
+ let count = modelRowsInSection.filter { $0.isVisible }.count
+ self.tableView.insertRows(at: IndexSet(integersIn: rowOffset ..< rowOffset + count), withAnimation: .effectFade)
+ self.tableViewModelRowsBySection.insert(modelRowsInSection, at: sectionIndex)
+ }
+ }
+ updateTableViewModelRows()
+ tableView.endUpdates()
+ }
+ }
+
+ private func reloadRuntimeConfiguration() {
+ tunnel.getRuntimeTunnelConfiguration { [weak self] tunnelConfiguration in
+ guard let tunnelConfiguration = tunnelConfiguration else { return }
+ self?.applyTunnelConfiguration(tunnelConfiguration: tunnelConfiguration)
+ }
+ }
+
+ func startUpdatingRuntimeConfiguration() {
+ reloadRuntimeConfiguration()
+ reloadRuntimeConfigurationTimer?.invalidate()
+ let reloadTimer = Timer(timeInterval: 1 /* second */, repeats: true) { [weak self] _ in
+ self?.reloadRuntimeConfiguration()
+ }
+ reloadRuntimeConfigurationTimer = reloadTimer
+ RunLoop.main.add(reloadTimer, forMode: .common)
+ }
+
+ func stopUpdatingRuntimeConfiguration() {
+ reloadRuntimeConfiguration()
+ reloadRuntimeConfigurationTimer?.invalidate()
+ reloadRuntimeConfigurationTimer = nil
+ }
+
+}
+
+extension TunnelDetailTableViewController: NSTableViewDataSource {
+ func numberOfRows(in tableView: NSTableView) -> Int {
+ return tableViewModelRows.count
+ }
+}
+
+extension TunnelDetailTableViewController: NSTableViewDelegate {
+ func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
+ let modelRow = tableViewModelRows[row]
+ switch modelRow {
+ case .interfaceFieldRow(let field):
+ if field == .status {
+ return statusCell()
+ } else if field == .toggleStatus {
+ return toggleStatusCell()
+ } else {
+ let cell: KeyValueRow = tableView.dequeueReusableCell()
+ let localizedKeyString = modelRow.isTitleRow() ? modelRow.localizedSectionKeyString() : field.localizedUIString
+ cell.key = tr(format: "macFieldKey (%@)", localizedKeyString)
+ cell.value = tunnelViewModel.interfaceData[field]
+ cell.isKeyInBold = modelRow.isTitleRow()
+ return cell
+ }
+ case .peerFieldRow(let peerData, let field):
+ let cell: KeyValueRow = tableView.dequeueReusableCell()
+ let localizedKeyString = modelRow.isTitleRow() ? modelRow.localizedSectionKeyString() : field.localizedUIString
+ cell.key = tr(format: "macFieldKey (%@)", localizedKeyString)
+ if field == .persistentKeepAlive {
+ cell.value = tr(format: "tunnelPeerPersistentKeepaliveValue (%@)", peerData[field])
+ } else if field == .preSharedKey {
+ cell.value = tr("tunnelPeerPresharedKeyEnabled")
+ } else {
+ cell.value = peerData[field]
+ }
+ cell.isKeyInBold = modelRow.isTitleRow()
+ return cell
+ case .spacerRow:
+ return NSView()
+ case .onDemandRow:
+ let cell: KeyValueRow = tableView.dequeueReusableCell()
+ cell.key = modelRow.localizedSectionKeyString()
+ cell.value = onDemandViewModel.localizedInterfaceDescription
+ cell.isKeyInBold = true
+ return cell
+ case .onDemandSSIDRow:
+ let cell: KeyValueRow = tableView.dequeueReusableCell()
+ cell.key = tr("macFieldOnDemandSSIDs")
+ let value: String
+ if onDemandViewModel.ssidOption == .anySSID {
+ value = onDemandViewModel.ssidOption.localizedUIString
+ } else {
+ value = tr(format: "tunnelOnDemandSSIDOptionDescriptionMac (%1$@: %2$@)",
+ onDemandViewModel.ssidOption.localizedUIString,
+ onDemandViewModel.selectedSSIDs.joined(separator: ", "))
+ }
+ cell.value = value
+ cell.isKeyInBold = false
+ return cell
+ }
+ }
+
+ func statusCell() -> NSView {
+ let cell: KeyValueImageRow = tableView.dequeueReusableCell()
+ cell.key = tr(format: "macFieldKey (%@)", tr("tunnelInterfaceStatus"))
+ cell.value = TunnelDetailTableViewController.localizedStatusDescription(forStatus: tunnel.status)
+ cell.valueImage = TunnelDetailTableViewController.image(forStatus: tunnel.status)
+ cell.observationToken = tunnel.observe(\.status) { [weak cell] tunnel, _ in
+ guard let cell = cell else { return }
+ cell.value = TunnelDetailTableViewController.localizedStatusDescription(forStatus: tunnel.status)
+ cell.valueImage = TunnelDetailTableViewController.image(forStatus: tunnel.status)
+ }
+ return cell
+ }
+
+ func toggleStatusCell() -> NSView {
+ let cell: ButtonRow = tableView.dequeueReusableCell()
+ cell.buttonTitle = TunnelDetailTableViewController.localizedToggleStatusActionText(forStatus: tunnel.status)
+ cell.isButtonEnabled = (tunnel.status == .active || tunnel.status == .inactive)
+ cell.buttonToolTip = tr("macToolTipToggleStatus")
+ cell.onButtonClicked = { [weak self] in
+ self?.handleToggleActiveStatusAction()
+ }
+ cell.observationToken = tunnel.observe(\.status) { [weak cell] tunnel, _ in
+ guard let cell = cell else { return }
+ cell.buttonTitle = TunnelDetailTableViewController.localizedToggleStatusActionText(forStatus: tunnel.status)
+ cell.isButtonEnabled = (tunnel.status == .active || tunnel.status == .inactive)
+ }
+ return cell
+ }
+
+ private static func localizedStatusDescription(forStatus status: TunnelStatus) -> String {
+ switch status {
+ case .inactive:
+ return tr("tunnelStatusInactive")
+ case .activating:
+ return tr("tunnelStatusActivating")
+ case .active:
+ return tr("tunnelStatusActive")
+ case .deactivating:
+ return tr("tunnelStatusDeactivating")
+ case .reasserting:
+ return tr("tunnelStatusReasserting")
+ case .restarting:
+ return tr("tunnelStatusRestarting")
+ case .waiting:
+ return tr("tunnelStatusWaiting")
+ }
+ }
+
+ private static func image(forStatus status: TunnelStatus?) -> NSImage? {
+ guard let status = status else { return nil }
+ switch status {
+ case .active, .restarting, .reasserting:
+ return NSImage(named: NSImage.statusAvailableName)
+ case .activating, .waiting, .deactivating:
+ return NSImage(named: NSImage.statusPartiallyAvailableName)
+ case .inactive:
+ return NSImage(named: NSImage.statusNoneName)
+ }
+ }
+
+ private static func localizedToggleStatusActionText(forStatus status: TunnelStatus) -> String {
+ switch status {
+ case .waiting:
+ return tr("macToggleStatusButtonWaiting")
+ case .inactive:
+ return tr("macToggleStatusButtonActivate")
+ case .activating:
+ return tr("macToggleStatusButtonActivating")
+ case .active:
+ return tr("macToggleStatusButtonDeactivate")
+ case .deactivating:
+ return tr("macToggleStatusButtonDeactivating")
+ case .reasserting:
+ return tr("macToggleStatusButtonReasserting")
+ case .restarting:
+ return tr("macToggleStatusButtonRestarting")
+ }
+ }
+}
+
+extension TunnelDetailTableViewController: TunnelEditViewControllerDelegate {
+ func tunnelSaved(tunnel: TunnelContainer) {
+ tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration)
+ onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel)
+ updateTableViewModelRowsBySection()
+ updateTableViewModelRows()
+ tableView.reloadData()
+ self.tunnelEditVC = nil
+ }
+
+ func tunnelEditingCancelled() {
+ self.tunnelEditVC = nil
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/ViewController/TunnelEditViewController.swift b/Sources/WireGuardApp/UI/macOS/ViewController/TunnelEditViewController.swift
new file mode 100644
index 0000000..97eaf8f
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ViewController/TunnelEditViewController.swift
@@ -0,0 +1,297 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+import WireGuardKit
+
+protocol TunnelEditViewControllerDelegate: class {
+ func tunnelSaved(tunnel: TunnelContainer)
+ func tunnelEditingCancelled()
+}
+
+class TunnelEditViewController: NSViewController {
+
+ let nameRow: EditableKeyValueRow = {
+ let nameRow = EditableKeyValueRow()
+ nameRow.key = tr(format: "macFieldKey (%@)", TunnelViewModel.InterfaceField.name.localizedUIString)
+ return nameRow
+ }()
+
+ let publicKeyRow: KeyValueRow = {
+ let publicKeyRow = KeyValueRow()
+ publicKeyRow.key = tr(format: "macFieldKey (%@)", TunnelViewModel.InterfaceField.publicKey.localizedUIString)
+ return publicKeyRow
+ }()
+
+ let textView: ConfTextView = {
+ let textView = ConfTextView()
+ let minWidth: CGFloat = 120
+ let minHeight: CGFloat = 0
+ textView.minSize = NSSize(width: 0, height: minHeight)
+ textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
+ textView.autoresizingMask = [.width] // Width should be based on superview width
+ textView.isHorizontallyResizable = false // Width shouldn't be based on content
+ textView.isVerticallyResizable = true // Height should be based on content
+ if let textContainer = textView.textContainer {
+ textContainer.size = NSSize(width: minWidth, height: CGFloat.greatestFiniteMagnitude)
+ textContainer.widthTracksTextView = true
+ }
+ NSLayoutConstraint.activate([
+ textView.widthAnchor.constraint(greaterThanOrEqualToConstant: minWidth),
+ textView.heightAnchor.constraint(greaterThanOrEqualToConstant: minHeight)
+ ])
+ return textView
+ }()
+
+ let onDemandControlsRow = OnDemandControlsRow()
+
+ let scrollView: NSScrollView = {
+ let scrollView = NSScrollView()
+ scrollView.hasVerticalScroller = true
+ scrollView.autohidesScrollers = true
+ scrollView.borderType = .bezelBorder
+ return scrollView
+ }()
+
+ let excludePrivateIPsCheckbox: NSButton = {
+ let checkbox = NSButton()
+ checkbox.title = tr("tunnelPeerExcludePrivateIPs")
+ checkbox.setButtonType(.switch)
+ checkbox.state = .off
+ return checkbox
+ }()
+
+ let discardButton: NSButton = {
+ let button = NSButton()
+ button.title = tr("macEditDiscard")
+ button.setButtonType(.momentaryPushIn)
+ button.bezelStyle = .rounded
+ return button
+ }()
+
+ let saveButton: NSButton = {
+ let button = NSButton()
+ button.title = tr("macEditSave")
+ button.setButtonType(.momentaryPushIn)
+ button.bezelStyle = .rounded
+ button.keyEquivalent = "s"
+ button.keyEquivalentModifierMask = [.command]
+ return button
+ }()
+
+ let tunnelsManager: TunnelsManager
+ let tunnel: TunnelContainer?
+ var onDemandViewModel: ActivateOnDemandViewModel
+
+ weak var delegate: TunnelEditViewControllerDelegate?
+
+ var privateKeyObservationToken: AnyObject?
+ var hasErrorObservationToken: AnyObject?
+ var singlePeerAllowedIPsObservationToken: AnyObject?
+
+ var dnsServersAddedToAllowedIPs: String?
+
+ init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer?) {
+ self.tunnelsManager = tunnelsManager
+ self.tunnel = tunnel
+ self.onDemandViewModel = tunnel != nil ? ActivateOnDemandViewModel(tunnel: tunnel!) : ActivateOnDemandViewModel()
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func populateFields() {
+ if let tunnel = tunnel {
+ // Editing an existing tunnel
+ let tunnelConfiguration = tunnel.tunnelConfiguration!
+ nameRow.value = tunnel.name
+ textView.string = tunnelConfiguration.asWgQuickConfig()
+ publicKeyRow.value = tunnelConfiguration.interface.privateKey.publicKey.base64Key
+ textView.privateKeyString = tunnelConfiguration.interface.privateKey.base64Key
+ let singlePeer = tunnelConfiguration.peers.count == 1 ? tunnelConfiguration.peers.first : nil
+ updateExcludePrivateIPsVisibility(singlePeerAllowedIPs: singlePeer?.allowedIPs.map { $0.stringRepresentation })
+ dnsServersAddedToAllowedIPs = excludePrivateIPsCheckbox.state == .on ? tunnelConfiguration.interface.dns.map { $0.stringRepresentation }.joined(separator: ", ") : nil
+ } else {
+ // Creating a new tunnel
+ let privateKey = PrivateKey()
+ let bootstrappingText = "[Interface]\nPrivateKey = \(privateKey.base64Key)\n"
+ publicKeyRow.value = privateKey.publicKey.base64Key
+ textView.string = bootstrappingText
+ updateExcludePrivateIPsVisibility(singlePeerAllowedIPs: nil)
+ dnsServersAddedToAllowedIPs = nil
+ }
+ privateKeyObservationToken = textView.observe(\.privateKeyString) { [weak publicKeyRow] textView, _ in
+ if let privateKeyString = textView.privateKeyString,
+ let privateKey = PrivateKey(base64Key: privateKeyString) {
+ publicKeyRow?.value = privateKey.publicKey.base64Key
+ } else {
+ publicKeyRow?.value = ""
+ }
+ }
+ hasErrorObservationToken = textView.observe(\.hasError) { [weak saveButton] textView, _ in
+ saveButton?.isEnabled = !textView.hasError
+ }
+ singlePeerAllowedIPsObservationToken = textView.observe(\.singlePeerAllowedIPs) { [weak self] textView, _ in
+ self?.updateExcludePrivateIPsVisibility(singlePeerAllowedIPs: textView.singlePeerAllowedIPs)
+ }
+ }
+
+ override func loadView() {
+ populateFields()
+
+ scrollView.documentView = textView
+
+ saveButton.target = self
+ saveButton.action = #selector(handleSaveAction)
+
+ discardButton.target = self
+ discardButton.action = #selector(handleDiscardAction)
+
+ excludePrivateIPsCheckbox.target = self
+ excludePrivateIPsCheckbox.action = #selector(excludePrivateIPsCheckboxToggled(sender:))
+
+ onDemandControlsRow.onDemandViewModel = onDemandViewModel
+
+ let margin: CGFloat = 20
+ let internalSpacing: CGFloat = 10
+
+ let editorStackView = NSStackView(views: [nameRow, publicKeyRow, onDemandControlsRow, scrollView])
+ editorStackView.orientation = .vertical
+ editorStackView.setHuggingPriority(.defaultHigh, for: .horizontal)
+ editorStackView.spacing = internalSpacing
+
+ let buttonRowStackView = NSStackView()
+ buttonRowStackView.setViews([discardButton, saveButton], in: .trailing)
+ buttonRowStackView.addView(excludePrivateIPsCheckbox, in: .leading)
+ buttonRowStackView.orientation = .horizontal
+ buttonRowStackView.spacing = internalSpacing
+
+ let containerView = NSStackView(views: [editorStackView, buttonRowStackView])
+ containerView.orientation = .vertical
+ containerView.edgeInsets = NSEdgeInsets(top: margin, left: margin, bottom: margin, right: margin)
+ containerView.setHuggingPriority(.defaultHigh, for: .horizontal)
+ containerView.spacing = internalSpacing
+
+ NSLayoutConstraint.activate([
+ containerView.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
+ containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 240)
+ ])
+ containerView.frame = NSRect(x: 0, y: 0, width: 600, height: 480)
+
+ self.view = containerView
+ }
+
+ func setUserInteractionEnabled(_ enabled: Bool) {
+ view.window?.ignoresMouseEvents = !enabled
+ nameRow.valueLabel.isEditable = enabled
+ textView.isEditable = enabled
+ onDemandControlsRow.onDemandSSIDsField.isEnabled = enabled
+ }
+
+ @objc func handleSaveAction() {
+ let name = nameRow.value
+ guard !name.isEmpty else {
+ ErrorPresenter.showErrorAlert(title: tr("macAlertNameIsEmpty"), message: "", from: self)
+ return
+ }
+
+ onDemandControlsRow.saveToViewModel()
+ let onDemandOption = onDemandViewModel.toOnDemandOption()
+
+ let isTunnelModifiedWithoutChangingName = (tunnel != nil && tunnel!.name == name)
+ guard isTunnelModifiedWithoutChangingName || tunnelsManager.tunnel(named: name) == nil else {
+ ErrorPresenter.showErrorAlert(title: tr(format: "macAlertDuplicateName (%@)", name), message: "", from: self)
+ return
+ }
+
+ var tunnelConfiguration: TunnelConfiguration
+ do {
+ tunnelConfiguration = try TunnelConfiguration(fromWgQuickConfig: textView.string, called: nameRow.value)
+ } catch let error as WireGuardAppError {
+ ErrorPresenter.showErrorAlert(error: error, from: self)
+ return
+ } catch {
+ fatalError()
+ }
+
+ if excludePrivateIPsCheckbox.state == .on, tunnelConfiguration.peers.count == 1, let dnsServersAddedToAllowedIPs = dnsServersAddedToAllowedIPs {
+ // Update the DNS servers in the AllowedIPs
+ let tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnelConfiguration)
+ let originalAllowedIPs = tunnelViewModel.peersData[0][.allowedIPs].splitToArray(trimmingCharacters: .whitespacesAndNewlines)
+ let dnsServersInAllowedIPs = TunnelViewModel.PeerData.normalizedIPAddressRangeStrings(dnsServersAddedToAllowedIPs.splitToArray(trimmingCharacters: .whitespacesAndNewlines))
+ let dnsServersCurrent = TunnelViewModel.PeerData.normalizedIPAddressRangeStrings(tunnelViewModel.interfaceData[.dns].splitToArray(trimmingCharacters: .whitespacesAndNewlines))
+ let modifiedAllowedIPs = originalAllowedIPs.filter { !dnsServersInAllowedIPs.contains($0) } + dnsServersCurrent
+ tunnelViewModel.peersData[0][.allowedIPs] = modifiedAllowedIPs.joined(separator: ", ")
+ let saveResult = tunnelViewModel.save()
+ if case .saved(let modifiedTunnelConfiguration) = saveResult {
+ tunnelConfiguration = modifiedTunnelConfiguration
+ }
+ }
+
+ setUserInteractionEnabled(false)
+
+ if let tunnel = tunnel {
+ // We're modifying an existing tunnel
+ tunnelsManager.modify(tunnel: tunnel, tunnelConfiguration: tunnelConfiguration, onDemandOption: onDemandOption) { [weak self] error in
+ guard let self = self else { return }
+ self.setUserInteractionEnabled(true)
+ if let error = error {
+ ErrorPresenter.showErrorAlert(error: error, from: self)
+ return
+ }
+ self.delegate?.tunnelSaved(tunnel: tunnel)
+ self.presentingViewController?.dismiss(self)
+ }
+ } else {
+ // We're creating a new tunnel
+ self.tunnelsManager.add(tunnelConfiguration: tunnelConfiguration, onDemandOption: onDemandOption) { [weak self] result in
+ guard let self = self else { return }
+ self.setUserInteractionEnabled(true)
+ switch result {
+ case .failure(let error):
+ ErrorPresenter.showErrorAlert(error: error, from: self)
+ case .success(let tunnel):
+ self.delegate?.tunnelSaved(tunnel: tunnel)
+ self.presentingViewController?.dismiss(self)
+ }
+ }
+ }
+ }
+
+ @objc func handleDiscardAction() {
+ delegate?.tunnelEditingCancelled()
+ presentingViewController?.dismiss(self)
+ }
+
+ func updateExcludePrivateIPsVisibility(singlePeerAllowedIPs: [String]?) {
+ let shouldAllowExcludePrivateIPsControl: Bool
+ let excludePrivateIPsValue: Bool
+ if let singlePeerAllowedIPs = singlePeerAllowedIPs {
+ (shouldAllowExcludePrivateIPsControl, excludePrivateIPsValue) = TunnelViewModel.PeerData.excludePrivateIPsFieldStates(isSinglePeer: true, allowedIPs: Set<String>(singlePeerAllowedIPs))
+ } else {
+ (shouldAllowExcludePrivateIPsControl, excludePrivateIPsValue) = TunnelViewModel.PeerData.excludePrivateIPsFieldStates(isSinglePeer: false, allowedIPs: Set<String>())
+ }
+ excludePrivateIPsCheckbox.isHidden = !shouldAllowExcludePrivateIPsControl
+ excludePrivateIPsCheckbox.state = excludePrivateIPsValue ? .on : .off
+ }
+
+ @objc func excludePrivateIPsCheckboxToggled(sender: AnyObject?) {
+ guard let excludePrivateIPsCheckbox = sender as? NSButton else { return }
+ guard let tunnelConfiguration = try? TunnelConfiguration(fromWgQuickConfig: textView.string, called: nameRow.value) else { return }
+ let isOn = excludePrivateIPsCheckbox.state == .on
+ let tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnelConfiguration)
+ tunnelViewModel.peersData.first?.excludePrivateIPsValueChanged(isOn: isOn, dnsServers: tunnelViewModel.interfaceData[.dns], oldDNSServers: dnsServersAddedToAllowedIPs)
+ if let modifiedConfig = tunnelViewModel.asWgQuickConfig() {
+ textView.setConfText(modifiedConfig)
+ dnsServersAddedToAllowedIPs = isOn ? tunnelViewModel.interfaceData[.dns] : nil
+ }
+ }
+}
+
+extension TunnelEditViewController {
+ override func cancelOperation(_ sender: Any?) {
+ handleDiscardAction()
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/ViewController/TunnelsListTableViewController.swift b/Sources/WireGuardApp/UI/macOS/ViewController/TunnelsListTableViewController.swift
new file mode 100644
index 0000000..0771582
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ViewController/TunnelsListTableViewController.swift
@@ -0,0 +1,354 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+protocol TunnelsListTableViewControllerDelegate: class {
+ 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.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
+ }
+}
diff --git a/Sources/WireGuardApp/UI/macOS/ViewController/UnusableTunnelDetailViewController.swift b/Sources/WireGuardApp/UI/macOS/ViewController/UnusableTunnelDetailViewController.swift
new file mode 100644
index 0000000..612e8c1
--- /dev/null
+++ b/Sources/WireGuardApp/UI/macOS/ViewController/UnusableTunnelDetailViewController.swift
@@ -0,0 +1,71 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
+
+import Cocoa
+
+class UnusableTunnelDetailViewController: NSViewController {
+
+ var onButtonClicked: (() -> Void)?
+
+ let messageLabel: NSTextField = {
+ let text = tr("macUnusableTunnelMessage")
+ let boldFont = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
+ let boldText = NSAttributedString(string: text, attributes: [.font: boldFont])
+ let label = NSTextField(labelWithAttributedString: boldText)
+ return label
+ }()
+
+ let infoLabel: NSTextField = {
+ let label = NSTextField(wrappingLabelWithString: tr("macUnusableTunnelInfo"))
+ return label
+ }()
+
+ let button: NSButton = {
+ let button = NSButton()
+ button.title = tr("macUnusableTunnelButtonTitleDeleteTunnel")
+ button.setButtonType(.momentaryPushIn)
+ button.bezelStyle = .rounded
+ return button
+ }()
+
+ init() {
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func loadView() {
+
+ button.target = self
+ button.action = #selector(buttonClicked)
+
+ let margin: CGFloat = 20
+ let internalSpacing: CGFloat = 20
+ let buttonSpacing: CGFloat = 30
+ let stackView = NSStackView(views: [messageLabel, infoLabel, button])
+ stackView.orientation = .vertical
+ stackView.edgeInsets = NSEdgeInsets(top: margin, left: margin, bottom: margin, right: margin)
+ stackView.spacing = internalSpacing
+ stackView.setCustomSpacing(buttonSpacing, after: infoLabel)
+
+ let view = NSView()
+ view.addSubview(stackView)
+ stackView.translatesAutoresizingMaskIntoConstraints = false
+
+ NSLayoutConstraint.activate([
+ stackView.widthAnchor.constraint(equalToConstant: 360),
+ stackView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
+ view.widthAnchor.constraint(greaterThanOrEqualToConstant: 420),
+ view.heightAnchor.constraint(greaterThanOrEqualToConstant: 240)
+ ])
+
+ self.view = view
+ }
+
+ @objc func buttonClicked() {
+ onButtonClicked?()
+ }
+}