364 lines
14 KiB
Swift
364 lines
14 KiB
Swift
// 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
|
|
}
|
|
}
|