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