// SPDX-License-Identifier: MIT // Copyright © 2018-2021 WireGuard LLC. All Rights Reserved. import Cocoa 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(singlePeerAllowedIPs)) } else { (shouldAllowExcludePrivateIPsControl, excludePrivateIPsValue) = TunnelViewModel.PeerData.excludePrivateIPsFieldStates(isSinglePeer: false, allowedIPs: Set()) } 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() } }