Move all source files to Sources/ and rename WireGuardKit targets
Signed-off-by: Andrej Mihajlov <and@mullvad.net>
This commit is contained in:
@@ -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?()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user