Move all source files to Sources/ and rename WireGuardKit targets

Signed-off-by: Andrej Mihajlov <and@mullvad.net>
This commit is contained in:
Andrej Mihajlov
2020-12-02 12:27:39 +01:00
parent 9c38a1b897
commit ec57408570
209 changed files with 54 additions and 58 deletions
@@ -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?()
}
}