diff options
author | Andrej Mihajlov <and@mullvad.net> | 2020-12-02 12:27:39 +0100 |
---|---|---|
committer | Andrej Mihajlov <and@mullvad.net> | 2020-12-03 13:32:24 +0100 |
commit | ec574085703ea1c8b2d4538596961beb910c4382 (patch) | |
tree | 73cf8bbdb74fe5575606664bccd0232ffa911803 /Sources/WireGuardApp/UI | |
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')
124 files changed, 9450 insertions, 0 deletions
diff --git a/Sources/WireGuardApp/UI/ActivateOnDemandViewModel.swift b/Sources/WireGuardApp/UI/ActivateOnDemandViewModel.swift new file mode 100644 index 0000000..55b9be2 --- /dev/null +++ b/Sources/WireGuardApp/UI/ActivateOnDemandViewModel.swift @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Foundation + +class ActivateOnDemandViewModel { + enum OnDemandField { + case onDemand + case nonWiFiInterface + case wiFiInterface + case ssid + + var localizedUIString: String { + switch self { + case .onDemand: + return tr("tunnelOnDemandKey") + case .nonWiFiInterface: + #if os(iOS) + return tr("tunnelOnDemandCellular") + #elseif os(macOS) + return tr("tunnelOnDemandEthernet") + #else + #error("Unimplemented") + #endif + case .wiFiInterface: return tr("tunnelOnDemandWiFi") + case .ssid: return tr("tunnelOnDemandSSIDsKey") + } + } + } + + enum OnDemandSSIDOption { + case anySSID + case onlySpecificSSIDs + case exceptSpecificSSIDs + + var localizedUIString: String { + switch self { + case .anySSID: return tr("tunnelOnDemandAnySSID") + case .onlySpecificSSIDs: return tr("tunnelOnDemandOnlyTheseSSIDs") + case .exceptSpecificSSIDs: return tr("tunnelOnDemandExceptTheseSSIDs") + } + } + } + + var isNonWiFiInterfaceEnabled = false + var isWiFiInterfaceEnabled = false + var selectedSSIDs = [String]() + var ssidOption: OnDemandSSIDOption = .anySSID +} + +extension ActivateOnDemandViewModel { + convenience init(tunnel: TunnelContainer) { + self.init() + if tunnel.isActivateOnDemandEnabled { + switch tunnel.onDemandOption { + case .off: + break + case .wiFiInterfaceOnly(let onDemandSSIDOption): + isWiFiInterfaceEnabled = true + (ssidOption, selectedSSIDs) = ssidViewModel(from: onDemandSSIDOption) + case .nonWiFiInterfaceOnly: + isNonWiFiInterfaceEnabled = true + case .anyInterface(let onDemandSSIDOption): + isWiFiInterfaceEnabled = true + isNonWiFiInterfaceEnabled = true + (ssidOption, selectedSSIDs) = ssidViewModel(from: onDemandSSIDOption) + } + } + } + + func toOnDemandOption() -> ActivateOnDemandOption { + switch (isWiFiInterfaceEnabled, isNonWiFiInterfaceEnabled) { + case (false, false): + return .off + case (false, true): + return .nonWiFiInterfaceOnly + case (true, false): + return .wiFiInterfaceOnly(toSSIDOption()) + case (true, true): + return .anyInterface(toSSIDOption()) + } + } +} + +extension ActivateOnDemandViewModel { + func isEnabled(field: OnDemandField) -> Bool { + switch field { + case .nonWiFiInterface: + return isNonWiFiInterfaceEnabled + case .wiFiInterface: + return isWiFiInterfaceEnabled + default: + return false + } + } + + func setEnabled(field: OnDemandField, isEnabled: Bool) { + switch field { + case .nonWiFiInterface: + isNonWiFiInterfaceEnabled = isEnabled + case .wiFiInterface: + isWiFiInterfaceEnabled = isEnabled + default: + break + } + } +} + +extension ActivateOnDemandViewModel { + var localizedInterfaceDescription: String { + switch (isWiFiInterfaceEnabled, isNonWiFiInterfaceEnabled) { + case (false, false): + return tr("tunnelOnDemandOptionOff") + case (true, false): + return tr("tunnelOnDemandOptionWiFiOnly") + case (false, true): + #if os(iOS) + return tr("tunnelOnDemandOptionCellularOnly") + #elseif os(macOS) + return tr("tunnelOnDemandOptionEthernetOnly") + #else + #error("Unimplemented") + #endif + case (true, true): + #if os(iOS) + return tr("tunnelOnDemandOptionWiFiOrCellular") + #elseif os(macOS) + return tr("tunnelOnDemandOptionWiFiOrEthernet") + #else + #error("Unimplemented") + #endif + } + } + + var localizedSSIDDescription: String { + guard isWiFiInterfaceEnabled else { return "" } + switch ssidOption { + case .anySSID: return tr("tunnelOnDemandAnySSID") + case .onlySpecificSSIDs: + if selectedSSIDs.count == 1 { + return tr(format: "tunnelOnDemandOnlySSID (%d)", selectedSSIDs.count) + } else { + return tr(format: "tunnelOnDemandOnlySSIDs (%d)", selectedSSIDs.count) + } + case .exceptSpecificSSIDs: + if selectedSSIDs.count == 1 { + return tr(format: "tunnelOnDemandExceptSSID (%d)", selectedSSIDs.count) + } else { + return tr(format: "tunnelOnDemandExceptSSIDs (%d)", selectedSSIDs.count) + } + } + } + + func fixSSIDOption() { + selectedSSIDs = uniquifiedNonEmptySelectedSSIDs() + if selectedSSIDs.isEmpty { + ssidOption = .anySSID + } + } +} + +private extension ActivateOnDemandViewModel { + func ssidViewModel(from ssidOption: ActivateOnDemandSSIDOption) -> (OnDemandSSIDOption, [String]) { + switch ssidOption { + case .anySSID: + return (.anySSID, []) + case .onlySpecificSSIDs(let ssids): + return (.onlySpecificSSIDs, ssids) + case .exceptSpecificSSIDs(let ssids): + return (.exceptSpecificSSIDs, ssids) + } + } + + func toSSIDOption() -> ActivateOnDemandSSIDOption { + switch ssidOption { + case .anySSID: + return .anySSID + case .onlySpecificSSIDs: + let ssids = uniquifiedNonEmptySelectedSSIDs() + return ssids.isEmpty ? .anySSID : .onlySpecificSSIDs(selectedSSIDs) + case .exceptSpecificSSIDs: + let ssids = uniquifiedNonEmptySelectedSSIDs() + return ssids.isEmpty ? .anySSID : .exceptSpecificSSIDs(selectedSSIDs) + } + } + + func uniquifiedNonEmptySelectedSSIDs() -> [String] { + let nonEmptySSIDs = selectedSSIDs.filter { !$0.isEmpty } + var seenSSIDs = Set<String>() + var uniquified = [String]() + for ssid in nonEmptySSIDs { + guard !seenSSIDs.contains(ssid) else { continue } + uniquified.append(ssid) + seenSSIDs.insert(ssid) + } + return uniquified + } +} diff --git a/Sources/WireGuardApp/UI/ErrorPresenterProtocol.swift b/Sources/WireGuardApp/UI/ErrorPresenterProtocol.swift new file mode 100644 index 0000000..ee4cf48 --- /dev/null +++ b/Sources/WireGuardApp/UI/ErrorPresenterProtocol.swift @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +protocol ErrorPresenterProtocol { + static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?, onPresented: (() -> Void)?, onDismissal: (() -> Void)?) +} + +extension ErrorPresenterProtocol { + static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?, onPresented: (() -> Void)?) { + showErrorAlert(title: title, message: message, from: sourceVC, onPresented: onPresented, onDismissal: nil) + } + + static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?, onDismissal: (() -> Void)?) { + showErrorAlert(title: title, message: message, from: sourceVC, onPresented: nil, onDismissal: onDismissal) + } + + static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?) { + showErrorAlert(title: title, message: message, from: sourceVC, onPresented: nil, onDismissal: nil) + } + + static func showErrorAlert(error: WireGuardAppError, from sourceVC: AnyObject?, onPresented: (() -> Void)? = nil, onDismissal: (() -> Void)? = nil) { + let (title, message) = error.alertText + showErrorAlert(title: title, message: message, from: sourceVC, onPresented: onPresented, onDismissal: onDismissal) + } +} diff --git a/Sources/WireGuardApp/UI/LogViewHelper.swift b/Sources/WireGuardApp/UI/LogViewHelper.swift new file mode 100644 index 0000000..1d3619b --- /dev/null +++ b/Sources/WireGuardApp/UI/LogViewHelper.swift @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Foundation + +public class LogViewHelper { + var log: OpaquePointer + var cursor: UInt32 = UINT32_MAX + static let formatOptions: ISO8601DateFormatter.Options = [ + .withYear, .withMonth, .withDay, .withTime, + .withDashSeparatorInDate, .withColonSeparatorInTime, .withSpaceBetweenDateAndTime, + .withFractionalSeconds + ] + + struct LogEntry { + let timestamp: String + let message: String + + func text() -> String { + return timestamp + " " + message + } + } + + class LogEntries { + var entries: [LogEntry] = [] + } + + init?(logFilePath: String?) { + guard let logFilePath = logFilePath else { return nil } + guard let log = open_log(logFilePath) else { return nil } + self.log = log + } + + deinit { + close_log(self.log) + } + + func fetchLogEntriesSinceLastFetch(completion: @escaping ([LogViewHelper.LogEntry]) -> Void) { + var logEntries = LogEntries() + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + let newCursor = view_lines_from_cursor(self.log, self.cursor, &logEntries) { cStr, timestamp, ctx in + let message = cStr != nil ? String(cString: cStr!) : "" + let date = Date(timeIntervalSince1970: Double(timestamp) / 1000000000) + let dateString = ISO8601DateFormatter.string(from: date, timeZone: TimeZone.current, formatOptions: LogViewHelper.formatOptions) + if let logEntries = ctx?.bindMemory(to: LogEntries.self, capacity: 1) { + logEntries.pointee.entries.append(LogEntry(timestamp: dateString, message: message)) + } + } + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + self.cursor = newCursor + completion(logEntries.entries) + } + } + } +} diff --git a/Sources/WireGuardApp/UI/PrivateDataConfirmation.swift b/Sources/WireGuardApp/UI/PrivateDataConfirmation.swift new file mode 100644 index 0000000..c03e64a --- /dev/null +++ b/Sources/WireGuardApp/UI/PrivateDataConfirmation.swift @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Foundation +import LocalAuthentication +#if os(macOS) +import AppKit +#endif + +class PrivateDataConfirmation { + static func confirmAccess(to reason: String, _ after: @escaping () -> Void) { + let context = LAContext() + + var error: NSError? + if !context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) { + guard let error = error as? LAError else { return } + if error.code == .passcodeNotSet { + // We give no protection to folks who just don't set a passcode. + after() + } + return + } + + context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in + DispatchQueue.main.async { + #if os(macOS) + if !NSApp.isActive { + NSApp.activate(ignoringOtherApps: true) + } + #endif + if success { + after() + } + } + } + } +} diff --git a/Sources/WireGuardApp/UI/TunnelImporter.swift b/Sources/WireGuardApp/UI/TunnelImporter.swift new file mode 100644 index 0000000..3846382 --- /dev/null +++ b/Sources/WireGuardApp/UI/TunnelImporter.swift @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Foundation +import WireGuardKit + +class TunnelImporter { + static func importFromFile(urls: [URL], into tunnelsManager: TunnelsManager, sourceVC: AnyObject?, errorPresenterType: ErrorPresenterProtocol.Type, completionHandler: (() -> Void)? = nil) { + guard !urls.isEmpty else { + completionHandler?() + return + } + let dispatchGroup = DispatchGroup() + var configs = [TunnelConfiguration?]() + var lastFileImportErrorText: (title: String, message: String)? + for url in urls { + if url.pathExtension.lowercased() == "zip" { + dispatchGroup.enter() + ZipImporter.importConfigFiles(from: url) { result in + switch result { + case .failure(let error): + lastFileImportErrorText = error.alertText + case .success(let configsInZip): + configs.append(contentsOf: configsInZip) + } + dispatchGroup.leave() + } + } else { /* if it is not a zip, we assume it is a conf */ + let fileName = url.lastPathComponent + let fileBaseName = url.deletingPathExtension().lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) + dispatchGroup.enter() + DispatchQueue.global(qos: .userInitiated).async { + let fileContents: String + do { + fileContents = try String(contentsOf: url) + } catch let error { + DispatchQueue.main.async { + if let cocoaError = error as? CocoaError, cocoaError.isFileError { + lastFileImportErrorText = (title: tr("alertCantOpenInputConfFileTitle"), message: error.localizedDescription) + } else { + lastFileImportErrorText = (title: tr("alertCantOpenInputConfFileTitle"), message: tr(format: "alertCantOpenInputConfFileMessage (%@)", fileName)) + } + configs.append(nil) + dispatchGroup.leave() + } + return + } + var parseError: Error? + var tunnelConfiguration: TunnelConfiguration? + do { + tunnelConfiguration = try TunnelConfiguration(fromWgQuickConfig: fileContents, called: fileBaseName) + } catch let error { + parseError = error + } + DispatchQueue.main.async { + if parseError != nil { + if let parseError = parseError as? WireGuardAppError { + lastFileImportErrorText = parseError.alertText + } else { + lastFileImportErrorText = (title: tr("alertBadConfigImportTitle"), message: tr(format: "alertBadConfigImportMessage (%@)", fileName)) + } + } + configs.append(tunnelConfiguration) + dispatchGroup.leave() + } + } + } + } + dispatchGroup.notify(queue: .main) { + tunnelsManager.addMultiple(tunnelConfigurations: configs.compactMap { $0 }) { numberSuccessful, lastAddError in + if !configs.isEmpty && numberSuccessful == configs.count { + completionHandler?() + return + } + let alertText: (title: String, message: String)? + if urls.count == 1 { + if urls.first!.pathExtension.lowercased() == "zip" && !configs.isEmpty { + alertText = (title: tr(format: "alertImportedFromZipTitle (%d)", numberSuccessful), + message: tr(format: "alertImportedFromZipMessage (%1$d of %2$d)", numberSuccessful, configs.count)) + } else { + alertText = lastFileImportErrorText ?? lastAddError?.alertText + } + } else { + alertText = (title: tr(format: "alertImportedFromMultipleFilesTitle (%d)", numberSuccessful), + message: tr(format: "alertImportedFromMultipleFilesMessage (%1$d of %2$d)", numberSuccessful, configs.count)) + } + if let alertText = alertText { + errorPresenterType.showErrorAlert(title: alertText.title, message: alertText.message, from: sourceVC, onPresented: completionHandler) + } else { + completionHandler?() + } + } + } + } +} diff --git a/Sources/WireGuardApp/UI/TunnelViewModel.swift b/Sources/WireGuardApp/UI/TunnelViewModel.swift new file mode 100644 index 0000000..3215b70 --- /dev/null +++ b/Sources/WireGuardApp/UI/TunnelViewModel.swift @@ -0,0 +1,696 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Foundation +import WireGuardKit + +class TunnelViewModel { + + enum InterfaceField: CaseIterable { + case name + case privateKey + case publicKey + case generateKeyPair + case addresses + case listenPort + case mtu + case dns + case status + case toggleStatus + + var localizedUIString: String { + switch self { + case .name: return tr("tunnelInterfaceName") + case .privateKey: return tr("tunnelInterfacePrivateKey") + case .publicKey: return tr("tunnelInterfacePublicKey") + case .generateKeyPair: return tr("tunnelInterfaceGenerateKeypair") + case .addresses: return tr("tunnelInterfaceAddresses") + case .listenPort: return tr("tunnelInterfaceListenPort") + case .mtu: return tr("tunnelInterfaceMTU") + case .dns: return tr("tunnelInterfaceDNS") + case .status: return tr("tunnelInterfaceStatus") + case .toggleStatus: return "" + } + } + } + + static let interfaceFieldsWithControl: Set<InterfaceField> = [ + .generateKeyPair + ] + + enum PeerField: CaseIterable { + case publicKey + case preSharedKey + case endpoint + case persistentKeepAlive + case allowedIPs + case rxBytes + case txBytes + case lastHandshakeTime + case excludePrivateIPs + case deletePeer + + var localizedUIString: String { + switch self { + case .publicKey: return tr("tunnelPeerPublicKey") + case .preSharedKey: return tr("tunnelPeerPreSharedKey") + case .endpoint: return tr("tunnelPeerEndpoint") + case .persistentKeepAlive: return tr("tunnelPeerPersistentKeepalive") + case .allowedIPs: return tr("tunnelPeerAllowedIPs") + case .rxBytes: return tr("tunnelPeerRxBytes") + case .txBytes: return tr("tunnelPeerTxBytes") + case .lastHandshakeTime: return tr("tunnelPeerLastHandshakeTime") + case .excludePrivateIPs: return tr("tunnelPeerExcludePrivateIPs") + case .deletePeer: return tr("deletePeerButtonTitle") + } + } + } + + static let peerFieldsWithControl: Set<PeerField> = [ + .excludePrivateIPs, .deletePeer + ] + + static let keyLengthInBase64 = 44 + + struct Changes { + enum FieldChange: Equatable { + case added + case removed + case modified(newValue: String) + } + + var interfaceChanges: [InterfaceField: FieldChange] + var peerChanges: [(peerIndex: Int, changes: [PeerField: FieldChange])] + var peersRemovedIndices: [Int] + var peersInsertedIndices: [Int] + } + + class InterfaceData { + var scratchpad = [InterfaceField: String]() + var fieldsWithError = Set<InterfaceField>() + var validatedConfiguration: InterfaceConfiguration? + var validatedName: String? + + subscript(field: InterfaceField) -> String { + get { + if scratchpad.isEmpty { + populateScratchpad() + } + return scratchpad[field] ?? "" + } + set(stringValue) { + if scratchpad.isEmpty { + populateScratchpad() + } + validatedConfiguration = nil + validatedName = nil + if stringValue.isEmpty { + scratchpad.removeValue(forKey: field) + } else { + scratchpad[field] = stringValue + } + if field == .privateKey { + if stringValue.count == TunnelViewModel.keyLengthInBase64, + let privateKey = PrivateKey(base64Key: stringValue) { + scratchpad[.publicKey] = privateKey.publicKey.base64Key + } else { + scratchpad.removeValue(forKey: .publicKey) + } + } + } + } + + func populateScratchpad() { + guard let config = validatedConfiguration else { return } + guard let name = validatedName else { return } + scratchpad = TunnelViewModel.InterfaceData.createScratchPad(from: config, name: name) + } + + private static func createScratchPad(from config: InterfaceConfiguration, name: String) -> [InterfaceField: String] { + var scratchpad = [InterfaceField: String]() + scratchpad[.name] = name + scratchpad[.privateKey] = config.privateKey.base64Key + scratchpad[.publicKey] = config.privateKey.publicKey.base64Key + if !config.addresses.isEmpty { + scratchpad[.addresses] = config.addresses.map { $0.stringRepresentation }.joined(separator: ", ") + } + if let listenPort = config.listenPort { + scratchpad[.listenPort] = String(listenPort) + } + if let mtu = config.mtu { + scratchpad[.mtu] = String(mtu) + } + if !config.dns.isEmpty { + scratchpad[.dns] = config.dns.map { $0.stringRepresentation }.joined(separator: ", ") + } + return scratchpad + } + + func save() -> SaveResult<(String, InterfaceConfiguration)> { + if let config = validatedConfiguration, let name = validatedName { + return .saved((name, config)) + } + fieldsWithError.removeAll() + guard let name = scratchpad[.name]?.trimmingCharacters(in: .whitespacesAndNewlines), (!name.isEmpty) else { + fieldsWithError.insert(.name) + return .error(tr("alertInvalidInterfaceMessageNameRequired")) + } + guard let privateKeyString = scratchpad[.privateKey] else { + fieldsWithError.insert(.privateKey) + return .error(tr("alertInvalidInterfaceMessagePrivateKeyRequired")) + } + guard let privateKey = PrivateKey(base64Key: privateKeyString) else { + fieldsWithError.insert(.privateKey) + return .error(tr("alertInvalidInterfaceMessagePrivateKeyInvalid")) + } + var config = InterfaceConfiguration(privateKey: privateKey) + var errorMessages = [String]() + if let addressesString = scratchpad[.addresses] { + var addresses = [IPAddressRange]() + for addressString in addressesString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) { + if let address = IPAddressRange(from: addressString) { + addresses.append(address) + } else { + fieldsWithError.insert(.addresses) + errorMessages.append(tr("alertInvalidInterfaceMessageAddressInvalid")) + } + } + config.addresses = addresses + } + if let listenPortString = scratchpad[.listenPort] { + if let listenPort = UInt16(listenPortString) { + config.listenPort = listenPort + } else { + fieldsWithError.insert(.listenPort) + errorMessages.append(tr("alertInvalidInterfaceMessageListenPortInvalid")) + } + } + if let mtuString = scratchpad[.mtu] { + if let mtu = UInt16(mtuString), mtu >= 576 { + config.mtu = mtu + } else { + fieldsWithError.insert(.mtu) + errorMessages.append(tr("alertInvalidInterfaceMessageMTUInvalid")) + } + } + if let dnsString = scratchpad[.dns] { + var dnsServers = [DNSServer]() + for dnsServerString in dnsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) { + if let dnsServer = DNSServer(from: dnsServerString) { + dnsServers.append(dnsServer) + } else { + fieldsWithError.insert(.dns) + errorMessages.append(tr("alertInvalidInterfaceMessageDNSInvalid")) + } + } + config.dns = dnsServers + } + + guard errorMessages.isEmpty else { return .error(errorMessages.first!) } + + validatedConfiguration = config + validatedName = name + return .saved((name, config)) + } + + func filterFieldsWithValueOrControl(interfaceFields: [InterfaceField]) -> [InterfaceField] { + return interfaceFields.filter { field in + if TunnelViewModel.interfaceFieldsWithControl.contains(field) { + return true + } + return !self[field].isEmpty + } + } + + func applyConfiguration(other: InterfaceConfiguration, otherName: String) -> [InterfaceField: Changes.FieldChange] { + if scratchpad.isEmpty { + populateScratchpad() + } + let otherScratchPad = InterfaceData.createScratchPad(from: other, name: otherName) + var changes = [InterfaceField: Changes.FieldChange]() + for field in InterfaceField.allCases { + switch (scratchpad[field] ?? "", otherScratchPad[field] ?? "") { + case ("", ""): + break + case ("", _): + changes[field] = .added + case (_, ""): + changes[field] = .removed + case (let this, let other): + if this != other { + changes[field] = .modified(newValue: other) + } + } + } + scratchpad = otherScratchPad + return changes + } + } + + class PeerData { + var index: Int + var scratchpad = [PeerField: String]() + var fieldsWithError = Set<PeerField>() + var validatedConfiguration: PeerConfiguration? + var publicKey: PublicKey? { + if let validatedConfiguration = validatedConfiguration { + return validatedConfiguration.publicKey + } + if let scratchPadPublicKey = scratchpad[.publicKey] { + return PublicKey(base64Key: scratchPadPublicKey) + } + return nil + } + + private(set) var shouldAllowExcludePrivateIPsControl = false + private(set) var shouldStronglyRecommendDNS = false + private(set) var excludePrivateIPsValue = false + fileprivate var numberOfPeers = 0 + + init(index: Int) { + self.index = index + } + + subscript(field: PeerField) -> String { + get { + if scratchpad.isEmpty { + populateScratchpad() + } + return scratchpad[field] ?? "" + } + set(stringValue) { + if scratchpad.isEmpty { + populateScratchpad() + } + validatedConfiguration = nil + if stringValue.isEmpty { + scratchpad.removeValue(forKey: field) + } else { + scratchpad[field] = stringValue + } + if field == .allowedIPs { + updateExcludePrivateIPsFieldState() + } + } + } + + func populateScratchpad() { + guard let config = validatedConfiguration else { return } + scratchpad = TunnelViewModel.PeerData.createScratchPad(from: config) + updateExcludePrivateIPsFieldState() + } + + private static func createScratchPad(from config: PeerConfiguration) -> [PeerField: String] { + var scratchpad = [PeerField: String]() + scratchpad[.publicKey] = config.publicKey.base64Key + if let preSharedKey = config.preSharedKey?.base64Key { + scratchpad[.preSharedKey] = preSharedKey + } + if !config.allowedIPs.isEmpty { + scratchpad[.allowedIPs] = config.allowedIPs.map { $0.stringRepresentation }.joined(separator: ", ") + } + if let endpoint = config.endpoint { + scratchpad[.endpoint] = endpoint.stringRepresentation + } + if let persistentKeepAlive = config.persistentKeepAlive { + scratchpad[.persistentKeepAlive] = String(persistentKeepAlive) + } + if let rxBytes = config.rxBytes { + scratchpad[.rxBytes] = prettyBytes(rxBytes) + } + if let txBytes = config.txBytes { + scratchpad[.txBytes] = prettyBytes(txBytes) + } + if let lastHandshakeTime = config.lastHandshakeTime { + scratchpad[.lastHandshakeTime] = prettyTimeAgo(timestamp: lastHandshakeTime) + } + return scratchpad + } + + func save() -> SaveResult<PeerConfiguration> { + if let validatedConfiguration = validatedConfiguration { + return .saved(validatedConfiguration) + } + fieldsWithError.removeAll() + guard let publicKeyString = scratchpad[.publicKey] else { + fieldsWithError.insert(.publicKey) + return .error(tr("alertInvalidPeerMessagePublicKeyRequired")) + } + guard let publicKey = PublicKey(base64Key: publicKeyString) else { + fieldsWithError.insert(.publicKey) + return .error(tr("alertInvalidPeerMessagePublicKeyInvalid")) + } + var config = PeerConfiguration(publicKey: publicKey) + var errorMessages = [String]() + if let preSharedKeyString = scratchpad[.preSharedKey] { + if let preSharedKey = PreSharedKey(base64Key: preSharedKeyString) { + config.preSharedKey = preSharedKey + } else { + fieldsWithError.insert(.preSharedKey) + errorMessages.append(tr("alertInvalidPeerMessagePreSharedKeyInvalid")) + } + } + if let allowedIPsString = scratchpad[.allowedIPs] { + var allowedIPs = [IPAddressRange]() + for allowedIPString in allowedIPsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) { + if let allowedIP = IPAddressRange(from: allowedIPString) { + allowedIPs.append(allowedIP) + } else { + fieldsWithError.insert(.allowedIPs) + errorMessages.append(tr("alertInvalidPeerMessageAllowedIPsInvalid")) + } + } + config.allowedIPs = allowedIPs + } + if let endpointString = scratchpad[.endpoint] { + if let endpoint = Endpoint(from: endpointString) { + config.endpoint = endpoint + } else { + fieldsWithError.insert(.endpoint) + errorMessages.append(tr("alertInvalidPeerMessageEndpointInvalid")) + } + } + if let persistentKeepAliveString = scratchpad[.persistentKeepAlive] { + if let persistentKeepAlive = UInt16(persistentKeepAliveString) { + config.persistentKeepAlive = persistentKeepAlive + } else { + fieldsWithError.insert(.persistentKeepAlive) + errorMessages.append(tr("alertInvalidPeerMessagePersistentKeepaliveInvalid")) + } + } + + guard errorMessages.isEmpty else { return .error(errorMessages.first!) } + + validatedConfiguration = config + return .saved(config) + } + + func filterFieldsWithValueOrControl(peerFields: [PeerField]) -> [PeerField] { + return peerFields.filter { field in + if TunnelViewModel.peerFieldsWithControl.contains(field) { + return true + } + return (!self[field].isEmpty) + } + } + + static let ipv4DefaultRouteString = "0.0.0.0/0" + static let ipv4DefaultRouteModRFC1918String = [ // Set of all non-private IPv4 IPs + "0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3", + "64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12", + "172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7", + "176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16", + "192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10", + "193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4" + ] + + static func excludePrivateIPsFieldStates(isSinglePeer: Bool, allowedIPs: Set<String>) -> (shouldAllowExcludePrivateIPsControl: Bool, excludePrivateIPsValue: Bool) { + guard isSinglePeer else { + return (shouldAllowExcludePrivateIPsControl: false, excludePrivateIPsValue: false) + } + let allowedIPStrings = Set<String>(allowedIPs) + if allowedIPStrings.contains(TunnelViewModel.PeerData.ipv4DefaultRouteString) { + return (shouldAllowExcludePrivateIPsControl: true, excludePrivateIPsValue: false) + } else if allowedIPStrings.isSuperset(of: TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String) { + return (shouldAllowExcludePrivateIPsControl: true, excludePrivateIPsValue: true) + } else { + return (shouldAllowExcludePrivateIPsControl: false, excludePrivateIPsValue: false) + } + } + + func updateExcludePrivateIPsFieldState() { + if scratchpad.isEmpty { + populateScratchpad() + } + let allowedIPStrings = Set<String>(scratchpad[.allowedIPs].splitToArray(trimmingCharacters: .whitespacesAndNewlines)) + (shouldAllowExcludePrivateIPsControl, excludePrivateIPsValue) = TunnelViewModel.PeerData.excludePrivateIPsFieldStates(isSinglePeer: numberOfPeers == 1, allowedIPs: allowedIPStrings) + shouldStronglyRecommendDNS = allowedIPStrings.contains(TunnelViewModel.PeerData.ipv4DefaultRouteString) || allowedIPStrings.isSuperset(of: TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String) + } + + static func normalizedIPAddressRangeStrings(_ list: [String]) -> [String] { + return list.compactMap { IPAddressRange(from: $0) }.map { $0.stringRepresentation } + } + + static func modifiedAllowedIPs(currentAllowedIPs: [String], excludePrivateIPs: Bool, dnsServers: [String], oldDNSServers: [String]?) -> [String] { + let normalizedDNSServers = normalizedIPAddressRangeStrings(dnsServers) + let normalizedOldDNSServers = oldDNSServers == nil ? normalizedDNSServers : normalizedIPAddressRangeStrings(oldDNSServers!) + let ipv6Addresses = normalizedIPAddressRangeStrings(currentAllowedIPs.filter { $0.contains(":") }) + if excludePrivateIPs { + return ipv6Addresses + TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String + normalizedDNSServers + } else { + return ipv6Addresses.filter { !normalizedOldDNSServers.contains($0) } + [TunnelViewModel.PeerData.ipv4DefaultRouteString] + } + } + + func excludePrivateIPsValueChanged(isOn: Bool, dnsServers: String, oldDNSServers: String? = nil) { + let allowedIPStrings = scratchpad[.allowedIPs].splitToArray(trimmingCharacters: .whitespacesAndNewlines) + let dnsServerStrings = dnsServers.splitToArray(trimmingCharacters: .whitespacesAndNewlines) + let oldDNSServerStrings = oldDNSServers?.splitToArray(trimmingCharacters: .whitespacesAndNewlines) + let modifiedAllowedIPStrings = TunnelViewModel.PeerData.modifiedAllowedIPs(currentAllowedIPs: allowedIPStrings, excludePrivateIPs: isOn, dnsServers: dnsServerStrings, oldDNSServers: oldDNSServerStrings) + scratchpad[.allowedIPs] = modifiedAllowedIPStrings.joined(separator: ", ") + validatedConfiguration = nil + excludePrivateIPsValue = isOn + } + + func applyConfiguration(other: PeerConfiguration) -> [PeerField: Changes.FieldChange] { + if scratchpad.isEmpty { + populateScratchpad() + } + let otherScratchPad = PeerData.createScratchPad(from: other) + var changes = [PeerField: Changes.FieldChange]() + for field in PeerField.allCases { + switch (scratchpad[field] ?? "", otherScratchPad[field] ?? "") { + case ("", ""): + break + case ("", _): + changes[field] = .added + case (_, ""): + changes[field] = .removed + case (let this, let other): + if this != other { + changes[field] = .modified(newValue: other) + } + } + } + scratchpad = otherScratchPad + return changes + } + } + + enum SaveResult<Configuration> { + case saved(Configuration) + case error(String) + } + + private(set) var interfaceData: InterfaceData + private(set) var peersData: [PeerData] + + init(tunnelConfiguration: TunnelConfiguration?) { + let interfaceData = InterfaceData() + var peersData = [PeerData]() + if let tunnelConfiguration = tunnelConfiguration { + interfaceData.validatedConfiguration = tunnelConfiguration.interface + interfaceData.validatedName = tunnelConfiguration.name + for (index, peerConfiguration) in tunnelConfiguration.peers.enumerated() { + let peerData = PeerData(index: index) + peerData.validatedConfiguration = peerConfiguration + peersData.append(peerData) + } + } + let numberOfPeers = peersData.count + for peerData in peersData { + peerData.numberOfPeers = numberOfPeers + peerData.updateExcludePrivateIPsFieldState() + } + self.interfaceData = interfaceData + self.peersData = peersData + } + + func appendEmptyPeer() { + let peer = PeerData(index: peersData.count) + peersData.append(peer) + for peer in peersData { + peer.numberOfPeers = peersData.count + peer.updateExcludePrivateIPsFieldState() + } + } + + func deletePeer(peer: PeerData) { + let removedPeer = peersData.remove(at: peer.index) + assert(removedPeer.index == peer.index) + for peer in peersData[peer.index ..< peersData.count] { + assert(peer.index > 0) + peer.index -= 1 + } + for peer in peersData { + peer.numberOfPeers = peersData.count + peer.updateExcludePrivateIPsFieldState() + } + } + + func updateDNSServersInAllowedIPsIfRequired(oldDNSServers: String, newDNSServers: String) -> Bool { + guard peersData.count == 1, let firstPeer = peersData.first else { return false } + guard firstPeer.shouldAllowExcludePrivateIPsControl && firstPeer.excludePrivateIPsValue else { return false } + let allowedIPStrings = firstPeer[.allowedIPs].splitToArray(trimmingCharacters: .whitespacesAndNewlines) + let oldDNSServerStrings = TunnelViewModel.PeerData.normalizedIPAddressRangeStrings(oldDNSServers.splitToArray(trimmingCharacters: .whitespacesAndNewlines)) + let newDNSServerStrings = TunnelViewModel.PeerData.normalizedIPAddressRangeStrings(newDNSServers.splitToArray(trimmingCharacters: .whitespacesAndNewlines)) + let updatedAllowedIPStrings = allowedIPStrings.filter { !oldDNSServerStrings.contains($0) } + newDNSServerStrings + firstPeer[.allowedIPs] = updatedAllowedIPStrings.joined(separator: ", ") + return true + } + + func save() -> SaveResult<TunnelConfiguration> { + let interfaceSaveResult = interfaceData.save() + let peerSaveResults = peersData.map { $0.save() } // Save all, to help mark erroring fields in red + switch interfaceSaveResult { + case .error(let errorMessage): + return .error(errorMessage) + case .saved(let interfaceConfiguration): + var peerConfigurations = [PeerConfiguration]() + peerConfigurations.reserveCapacity(peerSaveResults.count) + for peerSaveResult in peerSaveResults { + switch peerSaveResult { + case .error(let errorMessage): + return .error(errorMessage) + case .saved(let peerConfiguration): + peerConfigurations.append(peerConfiguration) + } + } + + let peerPublicKeysArray = peerConfigurations.map { $0.publicKey } + let peerPublicKeysSet = Set<PublicKey>(peerPublicKeysArray) + if peerPublicKeysArray.count != peerPublicKeysSet.count { + return .error(tr("alertInvalidPeerMessagePublicKeyDuplicated")) + } + + let tunnelConfiguration = TunnelConfiguration(name: interfaceConfiguration.0, interface: interfaceConfiguration.1, peers: peerConfigurations) + return .saved(tunnelConfiguration) + } + } + + func asWgQuickConfig() -> String? { + let saveResult = save() + if case .saved(let tunnelConfiguration) = saveResult { + return tunnelConfiguration.asWgQuickConfig() + } + return nil + } + + @discardableResult + func applyConfiguration(other: TunnelConfiguration) -> Changes { + // Replaces current data with data from other TunnelConfiguration, ignoring any changes in peer ordering. + + let interfaceChanges = interfaceData.applyConfiguration(other: other.interface, otherName: other.name ?? "") + + var peerChanges = [(peerIndex: Int, changes: [PeerField: Changes.FieldChange])]() + for otherPeer in other.peers { + if let peersDataIndex = peersData.firstIndex(where: { $0.publicKey == otherPeer.publicKey }) { + let peerData = peersData[peersDataIndex] + let changes = peerData.applyConfiguration(other: otherPeer) + if !changes.isEmpty { + peerChanges.append((peerIndex: peersDataIndex, changes: changes)) + } + } + } + + var removedPeerIndices = [Int]() + for (index, peerData) in peersData.enumerated().reversed() { + if let peerPublicKey = peerData.publicKey, !other.peers.contains(where: { $0.publicKey == peerPublicKey}) { + removedPeerIndices.append(index) + peersData.remove(at: index) + } + } + + var addedPeerIndices = [Int]() + for otherPeer in other.peers { + if !peersData.contains(where: { $0.publicKey == otherPeer.publicKey }) { + addedPeerIndices.append(peersData.count) + let peerData = PeerData(index: peersData.count) + peerData.validatedConfiguration = otherPeer + peersData.append(peerData) + } + } + + for (index, peer) in peersData.enumerated() { + peer.index = index + peer.numberOfPeers = peersData.count + peer.updateExcludePrivateIPsFieldState() + } + + return Changes(interfaceChanges: interfaceChanges, peerChanges: peerChanges, peersRemovedIndices: removedPeerIndices, peersInsertedIndices: addedPeerIndices) + } +} + +private func prettyBytes(_ bytes: UInt64) -> String { + switch bytes { + case 0..<1024: + return "\(bytes) B" + case 1024 ..< (1024 * 1024): + return String(format: "%.2f", Double(bytes) / 1024) + " KiB" + case 1024 ..< (1024 * 1024 * 1024): + return String(format: "%.2f", Double(bytes) / (1024 * 1024)) + " MiB" + case 1024 ..< (1024 * 1024 * 1024 * 1024): + return String(format: "%.2f", Double(bytes) / (1024 * 1024 * 1024)) + " GiB" + default: + return String(format: "%.2f", Double(bytes) / (1024 * 1024 * 1024 * 1024)) + " TiB" + } +} + +private func prettyTimeAgo(timestamp: Date) -> String { + let now = Date() + let timeInterval = Int64(now.timeIntervalSince(timestamp)) + switch timeInterval { + case ..<0: return tr("tunnelHandshakeTimestampSystemClockBackward") + case 0: return tr("tunnelHandshakeTimestampNow") + default: + return tr(format: "tunnelHandshakeTimestampAgo (%@)", prettyTime(secondsLeft: timeInterval)) + } +} + +private func prettyTime(secondsLeft: Int64) -> String { + var left = secondsLeft + var timeStrings = [String]() + let years = left / (365 * 24 * 60 * 60) + left = left % (365 * 24 * 60 * 60) + let days = left / (24 * 60 * 60) + left = left % (24 * 60 * 60) + let hours = left / (60 * 60) + left = left % (60 * 60) + let minutes = left / 60 + let seconds = left % 60 + + #if os(iOS) + if years > 0 { + return years == 1 ? tr(format: "tunnelHandshakeTimestampYear (%d)", years) : tr(format: "tunnelHandshakeTimestampYears (%d)", years) + } + if days > 0 { + return days == 1 ? tr(format: "tunnelHandshakeTimestampDay (%d)", days) : tr(format: "tunnelHandshakeTimestampDays (%d)", days) + } + if hours > 0 { + let hhmmss = String(format: "%02d:%02d:%02d", hours, minutes, seconds) + return tr(format: "tunnelHandshakeTimestampHours hh:mm:ss (%@)", hhmmss) + } + if minutes > 0 { + let mmss = String(format: "%02d:%02d", minutes, seconds) + return tr(format: "tunnelHandshakeTimestampMinutes mm:ss (%@)", mmss) + } + return seconds == 1 ? tr(format: "tunnelHandshakeTimestampSecond (%d)", seconds) : tr(format: "tunnelHandshakeTimestampSeconds (%d)", seconds) + #elseif os(macOS) + if years > 0 { + timeStrings.append(years == 1 ? tr(format: "tunnelHandshakeTimestampYear (%d)", years) : tr(format: "tunnelHandshakeTimestampYears (%d)", years)) + } + if days > 0 { + timeStrings.append(days == 1 ? tr(format: "tunnelHandshakeTimestampDay (%d)", days) : tr(format: "tunnelHandshakeTimestampDays (%d)", days)) + } + if hours > 0 { + timeStrings.append(hours == 1 ? tr(format: "tunnelHandshakeTimestampHour (%d)", hours) : tr(format: "tunnelHandshakeTimestampHours (%d)", hours)) + } + if minutes > 0 { + timeStrings.append(minutes == 1 ? tr(format: "tunnelHandshakeTimestampMinute (%d)", minutes) : tr(format: "tunnelHandshakeTimestampMinutes (%d)", minutes)) + } + if seconds > 0 { + timeStrings.append(seconds == 1 ? tr(format: "tunnelHandshakeTimestampSecond (%d)", seconds) : tr(format: "tunnelHandshakeTimestampSeconds (%d)", seconds)) + } + return timeStrings.joined(separator: ", ") + #endif +} diff --git a/Sources/WireGuardApp/UI/iOS/AppDelegate.swift b/Sources/WireGuardApp/UI/iOS/AppDelegate.swift new file mode 100644 index 0000000..418557e --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/AppDelegate.swift @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit +import os.log + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + + var window: UIWindow? + var mainVC: MainViewController? + var isLaunchedForSpecificAction = false + + func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + Logger.configureGlobal(tagged: "APP", withFilePath: FileManager.logFileURL?.path) + + if let launchOptions = launchOptions { + if launchOptions[.url] != nil || launchOptions[.shortcutItem] != nil { + isLaunchedForSpecificAction = true + } + } + + let window = UIWindow(frame: UIScreen.main.bounds) + if #available(iOS 13.0, *) { + window.backgroundColor = .systemBackground + } else { + window.backgroundColor = .white + } + self.window = window + + let mainVC = MainViewController() + window.rootViewController = mainVC + window.makeKeyAndVisible() + + self.mainVC = mainVC + + return true + } + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + mainVC?.importFromDisposableFile(url: url) + return true + } + + func applicationDidBecomeActive(_ application: UIApplication) { + mainVC?.refreshTunnelConnectionStatuses() + } + + func applicationWillResignActive(_ application: UIApplication) { + guard let allTunnelNames = mainVC?.allTunnelNames() else { return } + application.shortcutItems = QuickActionItem.createItems(allTunnelNames: allTunnelNames) + } + + func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { + guard shortcutItem.type == QuickActionItem.type else { + completionHandler(false) + return + } + let tunnelName = shortcutItem.localizedTitle + mainVC?.showTunnelDetailForTunnel(named: tunnelName, animated: false, shouldToggleStatus: true) + completionHandler(true) + } +} + +extension AppDelegate { + func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool { + return true + } + + func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool { + return !self.isLaunchedForSpecificAction + } + + func application(_ application: UIApplication, viewControllerWithRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? { + guard let vcIdentifier = identifierComponents.last else { return nil } + if vcIdentifier.hasPrefix("TunnelDetailVC:") { + let tunnelName = String(vcIdentifier.suffix(vcIdentifier.count - "TunnelDetailVC:".count)) + if let tunnelsManager = mainVC?.tunnelsManager { + if let tunnel = tunnelsManager.tunnel(named: tunnelName) { + return TunnelDetailTableViewController(tunnelsManager: tunnelsManager, tunnel: tunnel) + } + } else { + // Show it when tunnelsManager is available + mainVC?.showTunnelDetailForTunnel(named: tunnelName, animated: false, shouldToggleStatus: false) + } + } + return nil + } +} diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..9f4dceb --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "wireguard_logo_20pt@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "wireguard_logo_20pt@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "wireguard_logo_29pt@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "wireguard_logo_29pt@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "wireguard_logo_40pt@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "wireguard_logo_40pt@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "wireguard_logo_60pt@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "wireguard_logo_60pt@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "wireguard_logo_20pt@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "wireguard_logo_20pt@2x-1.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "wireguard_logo_29pt@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "wireguard_logo_29pt@2x-1.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "wireguard_logo_40pt@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "wireguard_logo_40pt@2x-1.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "wireguard_logo_76pt@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "wireguard_logo_76pt@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "wireguard_logo_83.5pt@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "wireguard_logo.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +}
\ No newline at end of file diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo.png Binary files differnew file mode 100644 index 0000000..ec74f4d --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@1x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@1x.png Binary files differnew file mode 100644 index 0000000..e0dc54d --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@1x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x-1.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x-1.png Binary files differnew file mode 100644 index 0000000..429297a --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x-1.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x.png Binary files differnew file mode 100644 index 0000000..429297a --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@2x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@3x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@3x.png Binary files differnew file mode 100644 index 0000000..eb79183 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_20pt@3x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@1x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@1x.png Binary files differnew file mode 100644 index 0000000..a8fd5c2 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@1x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x-1.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x-1.png Binary files differnew file mode 100644 index 0000000..3645f0e --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x-1.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x.png Binary files differnew file mode 100644 index 0000000..3645f0e --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@2x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@3x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@3x.png Binary files differnew file mode 100644 index 0000000..386bb88 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_29pt@3x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@1x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@1x.png Binary files differnew file mode 100644 index 0000000..429297a --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@1x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x-1.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x-1.png Binary files differnew file mode 100644 index 0000000..49bfff8 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x-1.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x.png Binary files differnew file mode 100644 index 0000000..49bfff8 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@2x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@3x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@3x.png Binary files differnew file mode 100644 index 0000000..8aa7b6c --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_40pt@3x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@2x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@2x.png Binary files differnew file mode 100644 index 0000000..8aa7b6c --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@2x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@3x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@3x.png Binary files differnew file mode 100644 index 0000000..f6c3318 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_60pt@3x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@1x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@1x.png Binary files differnew file mode 100644 index 0000000..b0e2c3c --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@1x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@2x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@2x.png Binary files differnew file mode 100644 index 0000000..0c708bc --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_76pt@2x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_83.5pt@2x.png b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_83.5pt@2x.png Binary files differnew file mode 100644 index 0000000..b7338c4 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/AppIcon.appiconset/wireguard_logo_83.5pt@2x.png diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/Contents.json b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +}
\ No newline at end of file diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/wireguard.imageset/Contents.json b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/wireguard.imageset/Contents.json new file mode 100644 index 0000000..6c935c1 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/wireguard.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "wireguard.pdf" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "preserves-vector-representation" : true + } +}
\ No newline at end of file diff --git a/Sources/WireGuardApp/UI/iOS/Assets.xcassets/wireguard.imageset/wireguard.pdf b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/wireguard.imageset/wireguard.pdf Binary files differnew file mode 100644 index 0000000..69469c5 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Assets.xcassets/wireguard.imageset/wireguard.pdf diff --git a/Sources/WireGuardApp/UI/iOS/Base.lproj/LaunchScreen.storyboard b/Sources/WireGuardApp/UI/iOS/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..a88aa5b --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,82 @@ +<?xml version="1.0" encoding="UTF-8"?> +<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="dyO-pm-zxZ"> + <device id="ipad9_7" orientation="landscape"> + <adaptation id="fullscreen"/> + </device> + <dependencies> + <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/> + <capability name="Safe area layout guides" minToolsVersion="9.0"/> + <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> + </dependencies> + <scenes> + <!--View Controller--> + <scene sceneID="wbj-wA-LRS"> + <objects> + <viewController id="xPs-rU-RaC" sceneMemberID="viewController"> + <view key="view" contentMode="scaleToFill" id="VmF-QJ-FIU"> + <rect key="frame" x="0.0" y="0.0" width="703.5" height="768"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <viewLayoutGuide key="safeArea" id="gU2-7a-hJw"/> + </view> + </viewController> + <placeholder placeholderIdentifier="IBFirstResponder" id="XJD-RE-ATd" userLabel="First Responder" sceneMemberID="firstResponder"/> + </objects> + </scene> + <!--Table View Controller--> + <scene sceneID="pXL-Um-fWR"> + <objects> + <tableViewController clearsSelectionOnViewWillAppear="NO" id="9s0-Gz-xEQ" sceneMemberID="viewController"> + <tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="none" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="PWV-jc-Hj5"> + <rect key="frame" x="0.0" y="0.0" width="320" height="768"/> + <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> + <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> + <prototypes> + <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="LaunchScreenCellReuseId" id="bv7-0X-YhD"> + <rect key="frame" x="0.0" y="28" width="320" height="44"/> + <autoresizingMask key="autoresizingMask"/> + <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="bv7-0X-YhD" id="Uag-Pa-rmu"> + <rect key="frame" x="0.0" y="0.0" width="320" height="44"/> + <autoresizingMask key="autoresizingMask"/> + </tableViewCellContentView> + </tableViewCell> + </prototypes> + <connections> + <outlet property="dataSource" destination="9s0-Gz-xEQ" id="djY-Us-7mp"/> + <outlet property="delegate" destination="9s0-Gz-xEQ" id="Frz-TZ-bD1"/> + </connections> + </tableView> + <navigationItem key="navigationItem" id="0ZE-eq-OPY"/> + </tableViewController> + <placeholder placeholderIdentifier="IBFirstResponder" id="YJ1-t3-sLe" userLabel="First Responder" sceneMemberID="firstResponder"/> + </objects> + </scene> + <!--Navigation Controller--> + <scene sceneID="bPm-bN-3Gh"> + <objects> + <navigationController id="Fi9-6j-pBd" sceneMemberID="viewController"> + <navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="h1o-PJ-Yrv"> + <rect key="frame" x="0.0" y="20" width="320" height="50"/> + <autoresizingMask key="autoresizingMask"/> + </navigationBar> + <connections> + <segue destination="9s0-Gz-xEQ" kind="relationship" relationship="rootViewController" id="Gdt-8A-Sqj"/> + </connections> + </navigationController> + <placeholder placeholderIdentifier="IBFirstResponder" id="fqW-5d-sYk" userLabel="First Responder" sceneMemberID="firstResponder"/> + </objects> + </scene> + <!--Split View Controller--> + <scene sceneID="FTm-2q-Sdy"> + <objects> + <splitViewController id="dyO-pm-zxZ" sceneMemberID="viewController"> + <connections> + <segue destination="Fi9-6j-pBd" kind="relationship" relationship="masterViewController" id="mFt-pZ-wHb"/> + <segue destination="xPs-rU-RaC" kind="relationship" relationship="detailViewController" id="q2k-Zf-dXJ"/> + </connections> + </splitViewController> + <placeholder placeholderIdentifier="IBFirstResponder" id="XEu-8J-cff" userLabel="First Responder" sceneMemberID="firstResponder"/> + </objects> + </scene> + </scenes> +</document> diff --git a/Sources/WireGuardApp/UI/iOS/ConfirmationAlertPresenter.swift b/Sources/WireGuardApp/UI/iOS/ConfirmationAlertPresenter.swift new file mode 100644 index 0000000..8064fff --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ConfirmationAlertPresenter.swift @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +class ConfirmationAlertPresenter { + static func showConfirmationAlert(message: String, buttonTitle: String, from sourceObject: AnyObject, presentingVC: UIViewController, onConfirmed: @escaping (() -> Void)) { + let destroyAction = UIAlertAction(title: buttonTitle, style: .destructive) { _ in + onConfirmed() + } + let cancelAction = UIAlertAction(title: tr("actionCancel"), style: .cancel) + let alert = UIAlertController(title: "", message: message, preferredStyle: .actionSheet) + alert.addAction(destroyAction) + alert.addAction(cancelAction) + + if let sourceView = sourceObject as? UIView { + alert.popoverPresentationController?.sourceView = sourceView + alert.popoverPresentationController?.sourceRect = sourceView.bounds + } else if let sourceBarButtonItem = sourceObject as? UIBarButtonItem { + alert.popoverPresentationController?.barButtonItem = sourceBarButtonItem + } + + presentingVC.present(alert, animated: true, completion: nil) + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ErrorPresenter.swift b/Sources/WireGuardApp/UI/iOS/ErrorPresenter.swift new file mode 100644 index 0000000..7d134de --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ErrorPresenter.swift @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit +import os.log + +class ErrorPresenter: ErrorPresenterProtocol { + static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?, onPresented: (() -> Void)?, onDismissal: (() -> Void)?) { + guard let sourceVC = sourceVC as? UIViewController else { return } + + let okAction = UIAlertAction(title: "OK", style: .default) { _ in + onDismissal?() + } + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(okAction) + + sourceVC.present(alert, animated: true, completion: onPresented) + } +} diff --git a/Sources/WireGuardApp/UI/iOS/Info.plist b/Sources/WireGuardApp/UI/iOS/Info.plist new file mode 100644 index 0000000..7d91077 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/Info.plist @@ -0,0 +1,131 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>ITSAppUsesNonExemptEncryption</key> + <false/> + <key>CFBundleDevelopmentRegion</key> + <string>$(DEVELOPMENT_LANGUAGE)</string> + <key>CFBundleDocumentTypes</key> + <array> + <dict> + <key>CFBundleTypeExtensions</key> + <array> + <string>conf</string> + </array> + <key>CFBundleTypeIconFiles</key> + <array> + <string>wireguard_doc_logo_22x29.png</string> + <string>wireguard_doc_logo_44x58.png</string> + <string>wireguard_doc_logo_64x64.png</string> + <string>wireguard_doc_logo_320x320.png</string> + </array> + <key>CFBundleTypeName</key> + <string>WireGuard wg-quick configuration file</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + <key>LSHandlerRank</key> + <string>Default</string> + <key>LSItemContentTypes</key> + <array> + <string>com.wireguard.config.quick</string> + </array> + </dict> + <dict> + <key>CFBundleTypeName</key> + <string>Zip file</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + <key>LSHandlerRank</key> + <string>Alternate</string> + <key>LSItemContentTypes</key> + <array> + <string>com.pkware.zip-archive</string> + </array> + </dict> + <dict> + <key>CFBundleTypeIconFiles</key> + <array/> + <key>CFBundleTypeName</key> + <string>Text file</string> + <key>CFBundleTypeRole</key> + <string>Viewer</string> + <key>LSHandlerRank</key> + <string>Alternate</string> + <key>LSItemContentTypes</key> + <array> + <string>public.text</string> + </array> + </dict> + </array> + <key>CFBundleExecutable</key> + <string>$(EXECUTABLE_NAME)</string> + <key>CFBundleIdentifier</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleDisplayName</key> + <string>$(PRODUCT_NAME)</string> + <key>CFBundleName</key> + <string>$(PRODUCT_NAME)</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleShortVersionString</key> + <string>$(VERSION_NAME)</string> + <key>CFBundleVersion</key> + <string>$(VERSION_ID)</string> + <key>LSRequiresIPhoneOS</key> + <true/> + <key>LSSupportsOpeningDocumentsInPlace</key> + <false/> + <key>NSCameraUsageDescription</key> + <string>Localized</string> + <key>UILaunchStoryboardName</key> + <string>LaunchScreen</string> + <key>UIRequiredDeviceCapabilities</key> + <array> + </array> + <key>UISupportedInterfaceOrientations</key> + <array> + <string>UIInterfaceOrientationPortrait</string> + <string>UIInterfaceOrientationLandscapeLeft</string> + <string>UIInterfaceOrientationLandscapeRight</string> + </array> + <key>UISupportedInterfaceOrientations~ipad</key> + <array> + <string>UIInterfaceOrientationPortrait</string> + <string>UIInterfaceOrientationPortraitUpsideDown</string> + <string>UIInterfaceOrientationLandscapeLeft</string> + <string>UIInterfaceOrientationLandscapeRight</string> + </array> + <key>UTExportedTypeDeclarations</key> + <array> + <dict> + <key>UTTypeConformsTo</key> + <array> + <string>public.text</string> + </array> + <key>UTTypeDescription</key> + <string>WireGuard wg-quick configuration file</string> + <key>UTTypeIconFiles</key> + <array> + <string>wireguard_doc_logo_22x29.png</string> + <string>wireguard_doc_logo_44x58.png</string> + <string>wireguard_doc_logo_64x64.png</string> + <string>wireguard_doc_logo_320x320.png</string> + </array> + <key>UTTypeIdentifier</key> + <string>com.wireguard.config.quick</string> + <key>UTTypeTagSpecification</key> + <dict> + <key>public.filename-extension</key> + <string>conf</string> + </dict> + </dict> + </array> + <key>NSFaceIDUsageDescription</key> + <string>Localized</string> + <key>com.wireguard.ios.app_group_id</key> + <string>group.$(APP_ID_IOS)</string> +</dict> +</plist> diff --git a/Sources/WireGuardApp/UI/iOS/QuickActionItem.swift b/Sources/WireGuardApp/UI/iOS/QuickActionItem.swift new file mode 100644 index 0000000..4367fa9 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/QuickActionItem.swift @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +class QuickActionItem: UIApplicationShortcutItem { + static let type = "WireGuardTunnelActivateAndShow" + + init(tunnelName: String) { + super.init(type: QuickActionItem.type, localizedTitle: tunnelName, localizedSubtitle: nil, icon: nil, userInfo: nil) + } + + static func createItems(allTunnelNames: [String]) -> [QuickActionItem] { + let numberOfItems = 10 + // Currently, only 4 items shown by iOS, but that can increase in the future. + // iOS will discard additional items we give it. + var tunnelNames = RecentTunnelsTracker.recentlyActivatedTunnelNames(limit: numberOfItems) + let numberOfSlotsRemaining = numberOfItems - tunnelNames.count + if numberOfSlotsRemaining > 0 { + let moreTunnels = allTunnelNames.filter { !tunnelNames.contains($0) }.prefix(numberOfSlotsRemaining) + tunnelNames.append(contentsOf: moreTunnels) + } + return tunnelNames.map { QuickActionItem(tunnelName: $0) } + } +} diff --git a/Sources/WireGuardApp/UI/iOS/RecentTunnelsTracker.swift b/Sources/WireGuardApp/UI/iOS/RecentTunnelsTracker.swift new file mode 100644 index 0000000..787a624 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/RecentTunnelsTracker.swift @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Foundation + +class RecentTunnelsTracker { + + private static let keyRecentlyActivatedTunnelNames = "recentlyActivatedTunnelNames" + private static let maxNumberOfTunnels = 10 + + private static var userDefaults: UserDefaults? { + guard let appGroupId = FileManager.appGroupId else { + wg_log(.error, staticMessage: "Cannot obtain app group ID from bundle for tracking recently used tunnels") + return nil + } + guard let userDefaults = UserDefaults(suiteName: appGroupId) else { + wg_log(.error, staticMessage: "Cannot obtain shared user defaults for tracking recently used tunnels") + return nil + } + return userDefaults + } + + static func handleTunnelActivated(tunnelName: String) { + guard let userDefaults = RecentTunnelsTracker.userDefaults else { return } + var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? [] + if let existingIndex = recentTunnels.firstIndex(of: tunnelName) { + recentTunnels.remove(at: existingIndex) + } + recentTunnels.insert(tunnelName, at: 0) + if recentTunnels.count > maxNumberOfTunnels { + recentTunnels.removeLast(recentTunnels.count - maxNumberOfTunnels) + } + userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames) + } + + static func handleTunnelRemoved(tunnelName: String) { + guard let userDefaults = RecentTunnelsTracker.userDefaults else { return } + var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? [] + if let existingIndex = recentTunnels.firstIndex(of: tunnelName) { + recentTunnels.remove(at: existingIndex) + userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames) + } + } + + static func handleTunnelRenamed(oldName: String, newName: String) { + guard let userDefaults = RecentTunnelsTracker.userDefaults else { return } + var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? [] + if let existingIndex = recentTunnels.firstIndex(of: oldName) { + recentTunnels[existingIndex] = newName + userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames) + } + } + + static func cleanupTunnels(except tunnelNamesToKeep: Set<String>) { + guard let userDefaults = RecentTunnelsTracker.userDefaults else { return } + var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? [] + let oldCount = recentTunnels.count + recentTunnels.removeAll { !tunnelNamesToKeep.contains($0) } + if oldCount != recentTunnels.count { + userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames) + } + } + + static func recentlyActivatedTunnelNames(limit: Int) -> [String] { + guard let userDefaults = RecentTunnelsTracker.userDefaults else { return [] } + var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? [] + if limit < recentTunnels.count { + recentTunnels.removeLast(recentTunnels.count - limit) + } + return recentTunnels + } +} diff --git a/Sources/WireGuardApp/UI/iOS/UITableViewCell+Reuse.swift b/Sources/WireGuardApp/UI/iOS/UITableViewCell+Reuse.swift new file mode 100644 index 0000000..bee1170 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/UITableViewCell+Reuse.swift @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +extension UITableViewCell { + static var reuseIdentifier: String { + return NSStringFromClass(self) + } +} + +extension UITableView { + func register<T: UITableViewCell>(_: T.Type) { + register(T.self, forCellReuseIdentifier: T.reuseIdentifier) + } + + func dequeueReusableCell<T: UITableViewCell>(for indexPath: IndexPath) -> T { + //swiftlint:disable:next force_cast + return dequeueReusableCell(withIdentifier: T.reuseIdentifier, for: indexPath) as! T + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/BorderedTextButton.swift b/Sources/WireGuardApp/UI/iOS/View/BorderedTextButton.swift new file mode 100644 index 0000000..82e7b2d --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/BorderedTextButton.swift @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +class BorderedTextButton: UIView { + let button: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) + button.titleLabel?.adjustsFontForContentSizeCategory = true + return button + }() + + override var intrinsicContentSize: CGSize { + let buttonSize = button.intrinsicContentSize + return CGSize(width: buttonSize.width + 32, height: buttonSize.height + 16) + } + + var title: String { + get { return button.title(for: .normal) ?? "" } + set(value) { button.setTitle(value, for: .normal) } + } + + var onTapped: (() -> Void)? + + init() { + super.init(frame: CGRect.zero) + + layer.borderWidth = 1 + layer.cornerRadius = 5 + layer.borderColor = button.tintColor.cgColor + + addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.centerXAnchor.constraint(equalTo: centerXAnchor), + button.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + + button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func buttonTapped() { + onTapped?() + } + +} diff --git a/Sources/WireGuardApp/UI/iOS/View/ButtonCell.swift b/Sources/WireGuardApp/UI/iOS/View/ButtonCell.swift new file mode 100644 index 0000000..90601c5 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/ButtonCell.swift @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +class ButtonCell: UITableViewCell { + var buttonText: String { + get { return button.title(for: .normal) ?? "" } + set(value) { button.setTitle(value, for: .normal) } + } + var hasDestructiveAction: Bool { + get { return button.tintColor == .systemRed } + set(value) { button.tintColor = value ? .systemRed : buttonStandardTintColor } + } + var onTapped: (() -> Void)? + + let button: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) + button.titleLabel?.adjustsFontForContentSizeCategory = true + return button + }() + + var buttonStandardTintColor: UIColor + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + buttonStandardTintColor = button.tintColor + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: button.bottomAnchor), + button.centerXAnchor.constraint(equalTo: contentView.centerXAnchor) + ]) + + button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + } + + @objc func buttonTapped() { + onTapped?() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + buttonText = "" + onTapped = nil + hasDestructiveAction = false + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/CheckmarkCell.swift b/Sources/WireGuardApp/UI/iOS/View/CheckmarkCell.swift new file mode 100644 index 0000000..3fdf1b9 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/CheckmarkCell.swift @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +class CheckmarkCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel!.text = value } + } + var isChecked: Bool { + didSet { + accessoryType = isChecked ? .checkmark : .none + } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + isChecked = false + super.init(style: .default, reuseIdentifier: reuseIdentifier) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + message = "" + isChecked = false + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/ChevronCell.swift b/Sources/WireGuardApp/UI/iOS/View/ChevronCell.swift new file mode 100644 index 0000000..a059b9c --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/ChevronCell.swift @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +class ChevronCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel?.text = value } + } + + var detailMessage: String { + get { return detailTextLabel?.text ?? "" } + set(value) { detailTextLabel?.text = value } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .value1, reuseIdentifier: reuseIdentifier) + accessoryType = .disclosureIndicator + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + message = "" + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/EditableTextCell.swift b/Sources/WireGuardApp/UI/iOS/View/EditableTextCell.swift new file mode 100644 index 0000000..178b200 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/EditableTextCell.swift @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +class EditableTextCell: UITableViewCell { + var message: String { + get { return valueTextField.text ?? "" } + set(value) { valueTextField.text = value } + } + + let valueTextField: UITextField = { + let valueTextField = UITextField() + valueTextField.textAlignment = .left + valueTextField.isEnabled = true + valueTextField.font = UIFont.preferredFont(forTextStyle: .body) + valueTextField.adjustsFontForContentSizeCategory = true + valueTextField.autocapitalizationType = .none + valueTextField.autocorrectionType = .no + valueTextField.spellCheckingType = .no + return valueTextField + }() + + var onValueBeingEdited: ((String) -> Void)? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + valueTextField.delegate = self + contentView.addSubview(valueTextField) + valueTextField.translatesAutoresizingMaskIntoConstraints = false + let bottomAnchorConstraint = contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: valueTextField.bottomAnchor, multiplier: 1) + bottomAnchorConstraint.priority = .defaultLow + NSLayoutConstraint.activate([ + valueTextField.leadingAnchor.constraint(equalToSystemSpacingAfter: contentView.layoutMarginsGuide.leadingAnchor, multiplier: 1), + contentView.layoutMarginsGuide.trailingAnchor.constraint(equalToSystemSpacingAfter: valueTextField.trailingAnchor, multiplier: 1), + valueTextField.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 1), + bottomAnchorConstraint + ]) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func beginEditing() { + valueTextField.becomeFirstResponder() + } + + override func prepareForReuse() { + super.prepareForReuse() + message = "" + } +} + +extension EditableTextCell: UITextFieldDelegate { + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if let onValueBeingEdited = onValueBeingEdited { + let modifiedText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string) + onValueBeingEdited(modifiedText) + } + return true + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/KeyValueCell.swift b/Sources/WireGuardApp/UI/iOS/View/KeyValueCell.swift new file mode 100644 index 0000000..9b1d13b --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/KeyValueCell.swift @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +class KeyValueCell: UITableViewCell { + + let keyLabel: UILabel = { + let keyLabel = UILabel() + keyLabel.font = UIFont.preferredFont(forTextStyle: .body) + keyLabel.adjustsFontForContentSizeCategory = true + if #available(iOS 13.0, *) { + keyLabel.textColor = .label + } else { + keyLabel.textColor = .black + } + keyLabel.textAlignment = .left + return keyLabel + }() + + let valueLabelScrollView: UIScrollView = { + let scrollView = UIScrollView(frame: .zero) + scrollView.isDirectionalLockEnabled = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + return scrollView + }() + + let valueTextField: UITextField = { + let valueTextField = UITextField() + valueTextField.textAlignment = .right + valueTextField.isEnabled = false + valueTextField.font = UIFont.preferredFont(forTextStyle: .body) + valueTextField.adjustsFontForContentSizeCategory = true + valueTextField.autocapitalizationType = .none + valueTextField.autocorrectionType = .no + valueTextField.spellCheckingType = .no + if #available(iOS 13.0, *) { + valueTextField.textColor = .secondaryLabel + } else { + valueTextField.textColor = .gray + } + return valueTextField + }() + + var copyableGesture = true + + var key: String { + get { return keyLabel.text ?? "" } + set(value) { keyLabel.text = value } + } + var value: String { + get { return valueTextField.text ?? "" } + set(value) { valueTextField.text = value } + } + var placeholderText: String { + get { return valueTextField.placeholder ?? "" } + set(value) { valueTextField.placeholder = value } + } + var keyboardType: UIKeyboardType { + get { return valueTextField.keyboardType } + set(value) { valueTextField.keyboardType = value } + } + + var isValueValid = true { + didSet { + if #available(iOS 13.0, *) { + if isValueValid { + keyLabel.textColor = .label + } else { + keyLabel.textColor = .systemRed + } + } else { + if isValueValid { + keyLabel.textColor = .black + } else { + keyLabel.textColor = .red + } + } + } + } + + var isStackedHorizontally = false + var isStackedVertically = false + var contentSizeBasedConstraints = [NSLayoutConstraint]() + + var onValueChanged: ((String, String) -> Void)? + var onValueBeingEdited: ((String) -> Void)? + + var observationToken: AnyObject? + + private var textFieldValueOnBeginEditing: String = "" + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(keyLabel) + keyLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + keyLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + keyLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) + ]) + + valueTextField.delegate = self + valueLabelScrollView.addSubview(valueTextField) + valueTextField.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + valueTextField.leadingAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.leadingAnchor), + valueTextField.topAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.topAnchor), + valueTextField.bottomAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.bottomAnchor), + valueTextField.trailingAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.trailingAnchor), + valueTextField.heightAnchor.constraint(equalTo: valueLabelScrollView.heightAnchor) + ]) + let expandToFitValueLabelConstraint = NSLayoutConstraint(item: valueTextField, attribute: .width, relatedBy: .equal, toItem: valueLabelScrollView, attribute: .width, multiplier: 1, constant: 0) + expandToFitValueLabelConstraint.priority = .defaultLow + 1 + expandToFitValueLabelConstraint.isActive = true + + contentView.addSubview(valueLabelScrollView) + + contentView.addSubview(valueLabelScrollView) + valueLabelScrollView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + valueLabelScrollView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: valueLabelScrollView.bottomAnchor, multiplier: 0.5) + ]) + + keyLabel.setContentCompressionResistancePriority(.defaultHigh + 1, for: .horizontal) + keyLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + valueLabelScrollView.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:))) + addGestureRecognizer(gestureRecognizer) + isUserInteractionEnabled = true + + configureForContentSize() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configureForContentSize() { + var constraints = [NSLayoutConstraint]() + if traitCollection.preferredContentSizeCategory.isAccessibilityCategory { + // Stack vertically + if !isStackedVertically { + constraints = [ + valueLabelScrollView.topAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), + valueLabelScrollView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), + keyLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor) + ] + isStackedVertically = true + isStackedHorizontally = false + } + } else { + // Stack horizontally + if !isStackedHorizontally { + constraints = [ + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), + valueLabelScrollView.leadingAnchor.constraint(equalToSystemSpacingAfter: keyLabel.trailingAnchor, multiplier: 1), + valueLabelScrollView.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) + ] + isStackedHorizontally = true + isStackedVertically = false + } + } + if !constraints.isEmpty { + NSLayoutConstraint.deactivate(contentSizeBasedConstraints) + NSLayoutConstraint.activate(constraints) + contentSizeBasedConstraints = constraints + } + } + + @objc func handleTapGesture(_ recognizer: UIGestureRecognizer) { + if !copyableGesture { + return + } + guard recognizer.state == .recognized else { return } + + if let recognizerView = recognizer.view, + let recognizerSuperView = recognizerView.superview, recognizerView.becomeFirstResponder() { + let menuController = UIMenuController.shared + menuController.setTargetRect(detailTextLabel?.frame ?? recognizerView.frame, in: detailTextLabel?.superview ?? recognizerSuperView) + menuController.setMenuVisible(true, animated: true) + } + } + + override var canBecomeFirstResponder: Bool { + return true + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + return (action == #selector(UIResponderStandardEditActions.copy(_:))) + } + + override func copy(_ sender: Any?) { + UIPasteboard.general.string = valueTextField.text + } + + override func prepareForReuse() { + super.prepareForReuse() + copyableGesture = true + placeholderText = "" + isValueValid = true + keyboardType = .default + onValueChanged = nil + onValueBeingEdited = nil + observationToken = nil + key = "" + value = "" + configureForContentSize() + } +} + +extension KeyValueCell: UITextFieldDelegate { + + func textFieldDidBeginEditing(_ textField: UITextField) { + textFieldValueOnBeginEditing = textField.text ?? "" + isValueValid = true + } + + func textFieldDidEndEditing(_ textField: UITextField) { + let isModified = textField.text ?? "" != textFieldValueOnBeginEditing + guard isModified else { return } + onValueChanged?(textFieldValueOnBeginEditing, textField.text ?? "") + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if let onValueBeingEdited = onValueBeingEdited { + let modifiedText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string) + onValueBeingEdited(modifiedText) + } + return true + } + +} diff --git a/Sources/WireGuardApp/UI/iOS/View/SwitchCell.swift b/Sources/WireGuardApp/UI/iOS/View/SwitchCell.swift new file mode 100644 index 0000000..0d6e4de --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/SwitchCell.swift @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +class SwitchCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel?.text = value } + } + var isOn: Bool { + get { return switchView.isOn } + set(value) { switchView.isOn = value } + } + var isEnabled: Bool { + get { return switchView.isEnabled } + set(value) { + switchView.isEnabled = value + if #available(iOS 13.0, *) { + textLabel?.textColor = value ? .label : .secondaryLabel + } else { + textLabel?.textColor = value ? .black : .gray + } + } + } + + var onSwitchToggled: ((Bool) -> Void)? + + var observationToken: AnyObject? + + let switchView = UISwitch() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + + accessoryView = switchView + switchView.addTarget(self, action: #selector(switchToggled), for: .valueChanged) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func switchToggled() { + onSwitchToggled?(switchView.isOn) + } + + override func prepareForReuse() { + super.prepareForReuse() + onSwitchToggled = nil + isEnabled = true + message = "" + isOn = false + observationToken = nil + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/TextCell.swift b/Sources/WireGuardApp/UI/iOS/View/TextCell.swift new file mode 100644 index 0000000..7df9444 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/TextCell.swift @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TextCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel!.text = value } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .default, reuseIdentifier: reuseIdentifier) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setTextColor(_ color: UIColor) { + textLabel?.textColor = color + } + + func setTextAlignment(_ alignment: NSTextAlignment) { + textLabel?.textAlignment = alignment + } + + override func prepareForReuse() { + super.prepareForReuse() + message = "" + if #available(iOS 13.0, *) { + setTextColor(.label) + } else { + setTextColor(.black) + } + setTextAlignment(.left) + } +} diff --git a/Sources/WireGuardApp/UI/iOS/View/TunnelEditKeyValueCell.swift b/Sources/WireGuardApp/UI/iOS/View/TunnelEditKeyValueCell.swift new file mode 100644 index 0000000..c139566 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/TunnelEditKeyValueCell.swift @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelEditKeyValueCell: KeyValueCell { + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + keyLabel.textAlignment = .right + valueTextField.textAlignment = .left + + let widthRatioConstraint = NSLayoutConstraint(item: keyLabel, attribute: .width, relatedBy: .equal, toItem: self, attribute: .width, multiplier: 0.4, constant: 0) + // In case the key doesn't fit into 0.4 * width, + // set a CR priority > the 0.4-constraint's priority. + widthRatioConstraint.priority = .defaultHigh + 1 + widthRatioConstraint.isActive = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} + +class TunnelEditEditableKeyValueCell: TunnelEditKeyValueCell { + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + copyableGesture = false + if #available(iOS 13.0, *) { + valueTextField.textColor = .label + } else { + valueTextField.textColor = .black + } + valueTextField.isEnabled = true + valueLabelScrollView.isScrollEnabled = false + valueTextField.widthAnchor.constraint(equalTo: valueLabelScrollView.widthAnchor).isActive = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + copyableGesture = false + } + +} diff --git a/Sources/WireGuardApp/UI/iOS/View/TunnelListCell.swift b/Sources/WireGuardApp/UI/iOS/View/TunnelListCell.swift new file mode 100644 index 0000000..b2e0ba9 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/View/TunnelListCell.swift @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelListCell: UITableViewCell { + var tunnel: TunnelContainer? { + didSet(value) { + // Bind to the tunnel's name + nameLabel.text = tunnel?.name ?? "" + nameObservationToken = tunnel?.observe(\.name) { [weak self] tunnel, _ in + self?.nameLabel.text = tunnel.name + } + // Bind to the tunnel's status + update(from: tunnel?.status) + statusObservationToken = tunnel?.observe(\.status) { [weak self] tunnel, _ in + self?.update(from: tunnel.status) + } + } + } + var onSwitchToggled: ((Bool) -> Void)? + + let nameLabel: UILabel = { + let nameLabel = UILabel() + nameLabel.font = UIFont.preferredFont(forTextStyle: .body) + nameLabel.adjustsFontForContentSizeCategory = true + nameLabel.numberOfLines = 0 + return nameLabel + }() + + let busyIndicator: UIActivityIndicatorView = { + if #available(iOS 13.0, *) { + let busyIndicator = UIActivityIndicatorView(style: .medium) + busyIndicator.hidesWhenStopped = true + return busyIndicator + } else { + let busyIndicator = UIActivityIndicatorView(style: .gray) + busyIndicator.hidesWhenStopped = true + return busyIndicator + } + }() + + let statusSwitch = UISwitch() + + private var statusObservationToken: AnyObject? + private var nameObservationToken: AnyObject? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(statusSwitch) + statusSwitch.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + statusSwitch.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + contentView.trailingAnchor.constraint(equalTo: statusSwitch.trailingAnchor) + ]) + + contentView.addSubview(busyIndicator) + busyIndicator.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + busyIndicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + statusSwitch.leadingAnchor.constraint(equalToSystemSpacingAfter: busyIndicator.trailingAnchor, multiplier: 1) + ]) + + contentView.addSubview(nameLabel) + nameLabel.translatesAutoresizingMaskIntoConstraints = false + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let bottomAnchorConstraint = contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: nameLabel.bottomAnchor, multiplier: 1) + bottomAnchorConstraint.priority = .defaultLow + NSLayoutConstraint.activate([ + nameLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 1), + nameLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: contentView.layoutMarginsGuide.leadingAnchor, multiplier: 1), + busyIndicator.leadingAnchor.constraint(equalToSystemSpacingAfter: nameLabel.trailingAnchor, multiplier: 1), + bottomAnchorConstraint + ]) + + accessoryType = .disclosureIndicator + + statusSwitch.addTarget(self, action: #selector(switchToggled), for: .valueChanged) + } + + @objc func switchToggled() { + onSwitchToggled?(statusSwitch.isOn) + } + + private func update(from status: TunnelStatus?) { + guard let status = status else { + reset() + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak statusSwitch, weak busyIndicator] in + guard let statusSwitch = statusSwitch, let busyIndicator = busyIndicator else { return } + statusSwitch.isOn = !(status == .deactivating || status == .inactive) + statusSwitch.isUserInteractionEnabled = (status == .inactive || status == .active) + if status == .inactive || status == .active { + busyIndicator.stopAnimating() + } else { + busyIndicator.startAnimating() + } + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func setEditing(_ editing: Bool, animated: Bool) { + super.setEditing(editing, animated: animated) + statusSwitch.isEnabled = !editing + } + + private func reset() { + statusSwitch.isOn = false + statusSwitch.isUserInteractionEnabled = false + busyIndicator.stopAnimating() + } + + override func prepareForReuse() { + super.prepareForReuse() + reset() + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/LogViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/LogViewController.swift new file mode 100644 index 0000000..f31af01 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/LogViewController.swift @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +class LogViewController: UIViewController { + + let textView: UITextView = { + let textView = UITextView() + textView.isEditable = false + textView.isSelectable = true + textView.font = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body) + textView.adjustsFontForContentSizeCategory = true + return textView + }() + + let busyIndicator: UIActivityIndicatorView = { + if #available(iOS 13.0, *) { + let busyIndicator = UIActivityIndicatorView(style: .medium) + busyIndicator.hidesWhenStopped = true + return busyIndicator + } else { + let busyIndicator = UIActivityIndicatorView(style: .gray) + busyIndicator.hidesWhenStopped = true + return busyIndicator + } + }() + + let paragraphStyle: NSParagraphStyle = { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.setParagraphStyle(NSParagraphStyle.default) + paragraphStyle.lineHeightMultiple = 1.2 + return paragraphStyle + }() + + var isNextLineHighlighted = false + + var logViewHelper: LogViewHelper? + var isFetchingLogEntries = false + private var updateLogEntriesTimer: Timer? + + override func loadView() { + view = UIView() + if #available(iOS 13.0, *) { + view.backgroundColor = .systemBackground + } else { + view.backgroundColor = .white + } + + view.addSubview(textView) + textView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + textView.topAnchor.constraint(equalTo: view.topAnchor), + textView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + view.addSubview(busyIndicator) + busyIndicator.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + busyIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + busyIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + + busyIndicator.startAnimating() + + logViewHelper = LogViewHelper(logFilePath: FileManager.logFileURL?.path) + startUpdatingLogEntries() + } + + override func viewDidLoad() { + title = tr("logViewTitle") + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(saveTapped(sender:))) + } + + 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.busyIndicator.isAnimating { + self.busyIndicator.stopAnimating() + } + guard !fetchedLogEntries.isEmpty else { return } + let isScrolledToEnd = self.textView.contentSize.height - self.textView.bounds.height - self.textView.contentOffset.y < 1 + + let richText = NSMutableAttributedString() + let bodyFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body) + let captionFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.caption1) + for logEntry in fetchedLogEntries { + var bgColor: UIColor + var fgColor: UIColor + if #available(iOS 13.0, *) { + bgColor = self.isNextLineHighlighted ? .systemGray3 : .systemBackground + fgColor = .label + } else { + bgColor = self.isNextLineHighlighted ? UIColor(white: 0.88, alpha: 1.0) : UIColor.white + fgColor = .black + } + let timestampText = NSAttributedString(string: logEntry.timestamp + "\n", attributes: [.font: captionFont, .backgroundColor: bgColor, .foregroundColor: fgColor, .paragraphStyle: self.paragraphStyle]) + let messageText = NSAttributedString(string: logEntry.message + "\n", attributes: [.font: bodyFont, .backgroundColor: bgColor, .foregroundColor: fgColor, .paragraphStyle: self.paragraphStyle]) + richText.append(timestampText) + richText.append(messageText) + self.isNextLineHighlighted.toggle() + } + self.textView.textStorage.append(richText) + if isScrolledToEnd { + let endOfCurrentText = NSRange(location: (self.textView.text as NSString).length, length: 0) + self.textView.scrollRangeToVisible(endOfCurrentText) + } + } + } + + 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) + } + + @objc func saveTapped(sender: AnyObject) { + guard let destinationDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withFullDate, .withTime, .withTimeZone] // Avoid ':' in the filename + let timeStampString = dateFormatter.string(from: Date()) + let destinationURL = destinationDir.appendingPathComponent("wireguard-log-\(timeStampString).txt") + + DispatchQueue.global(qos: .userInitiated).async { + + if FileManager.default.fileExists(atPath: destinationURL.path) { + let isDeleted = FileManager.deleteFile(at: destinationURL) + if !isDeleted { + ErrorPresenter.showErrorAlert(title: tr("alertUnableToRemovePreviousLogTitle"), message: tr("alertUnableToRemovePreviousLogMessage"), from: self) + return + } + } + + let isWritten = Logger.global?.writeLog(to: destinationURL.path) ?? false + + DispatchQueue.main.async { + guard isWritten else { + ErrorPresenter.showErrorAlert(title: tr("alertUnableToWriteLogTitle"), message: tr("alertUnableToWriteLogMessage"), from: self) + return + } + let activityVC = UIActivityViewController(activityItems: [destinationURL], applicationActivities: nil) + if let sender = sender as? UIBarButtonItem { + activityVC.popoverPresentationController?.barButtonItem = sender + } + activityVC.completionWithItemsHandler = { _, _, _, _ in + // Remove the exported log file after the activity has completed + _ = FileManager.deleteFile(at: destinationURL) + } + self.present(activityVC, animated: true) + } + } + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/MainViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/MainViewController.swift new file mode 100644 index 0000000..514e037 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/MainViewController.swift @@ -0,0 +1,146 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +class MainViewController: UISplitViewController { + + var tunnelsManager: TunnelsManager? + var onTunnelsManagerReady: ((TunnelsManager) -> Void)? + var tunnelsListVC: TunnelsListTableViewController? + + init() { + let detailVC = UIViewController() + if #available(iOS 13.0, *) { + detailVC.view.backgroundColor = .systemBackground + } else { + detailVC.view.backgroundColor = .white + } + let detailNC = UINavigationController(rootViewController: detailVC) + + let masterVC = TunnelsListTableViewController() + let masterNC = UINavigationController(rootViewController: masterVC) + + tunnelsListVC = masterVC + + super.init(nibName: nil, bundle: nil) + + viewControllers = [ masterNC, detailNC ] + + restorationIdentifier = "MainVC" + masterNC.restorationIdentifier = "MasterNC" + detailNC.restorationIdentifier = "DetailNC" + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + delegate = self + + // On iPad, always show both masterVC and detailVC, even in portrait mode, like the Settings app + preferredDisplayMode = .allVisible + + // Create the tunnels manager, and when it's ready, inform tunnelsListVC + TunnelsManager.create { [weak self] result in + guard let self = self else { return } + + switch result { + case .failure(let error): + ErrorPresenter.showErrorAlert(error: error, from: self) + case .success(let tunnelsManager): + self.tunnelsManager = tunnelsManager + self.tunnelsListVC?.setTunnelsManager(tunnelsManager: tunnelsManager) + + tunnelsManager.activationDelegate = self + + self.onTunnelsManagerReady?(tunnelsManager) + self.onTunnelsManagerReady = nil + } + } + } + + func allTunnelNames() -> [String]? { + guard let tunnelsManager = self.tunnelsManager else { return nil } + return tunnelsManager.mapTunnels { $0.name } + } +} + +extension MainViewController: TunnelsManagerActivationDelegate { + func tunnelActivationAttemptFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationAttemptError) { + ErrorPresenter.showErrorAlert(error: error, from: self) + } + + func tunnelActivationAttemptSucceeded(tunnel: TunnelContainer) { + // Nothing to do + } + + func tunnelActivationFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationError) { + ErrorPresenter.showErrorAlert(error: error, from: self) + } + + func tunnelActivationSucceeded(tunnel: TunnelContainer) { + // Nothing to do + } +} + +extension MainViewController { + func refreshTunnelConnectionStatuses() { + if let tunnelsManager = tunnelsManager { + tunnelsManager.refreshStatuses() + } + } + + func showTunnelDetailForTunnel(named tunnelName: String, animated: Bool, shouldToggleStatus: Bool) { + let showTunnelDetailBlock: (TunnelsManager) -> Void = { [weak self] tunnelsManager in + guard let self = self else { return } + guard let tunnelsListVC = self.tunnelsListVC else { return } + if let tunnel = tunnelsManager.tunnel(named: tunnelName) { + tunnelsListVC.showTunnelDetail(for: tunnel, animated: false) + if shouldToggleStatus { + if tunnel.status == .inactive { + tunnelsManager.startActivation(of: tunnel) + } else if tunnel.status == .active { + tunnelsManager.startDeactivation(of: tunnel) + } + } + } + } + if let tunnelsManager = tunnelsManager { + showTunnelDetailBlock(tunnelsManager) + } else { + onTunnelsManagerReady = showTunnelDetailBlock + } + } + + func importFromDisposableFile(url: URL) { + let importFromFileBlock: (TunnelsManager) -> Void = { [weak self] tunnelsManager in + TunnelImporter.importFromFile(urls: [url], into: tunnelsManager, sourceVC: self, errorPresenterType: ErrorPresenter.self) { + _ = FileManager.deleteFile(at: url) + } + } + if let tunnelsManager = tunnelsManager { + importFromFileBlock(tunnelsManager) + } else { + onTunnelsManagerReady = importFromFileBlock + } + } +} + +extension MainViewController: UISplitViewControllerDelegate { + func splitViewController(_ splitViewController: UISplitViewController, + collapseSecondary secondaryViewController: UIViewController, + onto primaryViewController: UIViewController) -> Bool { + // On iPhone, if the secondaryVC (detailVC) is just a UIViewController, it indicates that it's empty, + // so just show the primaryVC (masterVC). + let detailVC = (secondaryViewController as? UINavigationController)?.viewControllers.first + let isDetailVCEmpty: Bool + if let detailVC = detailVC { + isDetailVCEmpty = (type(of: detailVC) == UIViewController.self) + } else { + isDetailVCEmpty = true + } + return isDetailVCEmpty + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/QRScanViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/QRScanViewController.swift new file mode 100644 index 0000000..041421f --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/QRScanViewController.swift @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import AVFoundation +import UIKit +import WireGuardKit + +protocol QRScanViewControllerDelegate: class { + func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController, completionHandler: (() -> Void)?) +} + +class QRScanViewController: UIViewController { + weak var delegate: QRScanViewControllerDelegate? + var captureSession: AVCaptureSession? = AVCaptureSession() + let metadataOutput = AVCaptureMetadataOutput() + var previewLayer: AVCaptureVideoPreviewLayer? + + override func viewDidLoad() { + super.viewDidLoad() + + title = tr("scanQRCodeViewTitle") + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped)) + + let tipLabel = UILabel() + tipLabel.text = tr("scanQRCodeTipText") + tipLabel.adjustsFontSizeToFitWidth = true + tipLabel.textColor = .lightGray + tipLabel.textAlignment = .center + + view.addSubview(tipLabel) + tipLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tipLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + tipLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + tipLabel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -32) + ]) + + guard let videoCaptureDevice = AVCaptureDevice.default(for: .video), + let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice), + let captureSession = captureSession, + captureSession.canAddInput(videoInput), + captureSession.canAddOutput(metadataOutput) else { + scanDidEncounterError(title: tr("alertScanQRCodeCameraUnsupportedTitle"), message: tr("alertScanQRCodeCameraUnsupportedMessage")) + return + } + + captureSession.addInput(videoInput) + captureSession.addOutput(metadataOutput) + + metadataOutput.setMetadataObjectsDelegate(self, queue: .main) + metadataOutput.metadataObjectTypes = [.qr] + + let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession) + previewLayer.frame = view.layer.bounds + previewLayer.videoGravity = .resizeAspectFill + view.layer.insertSublayer(previewLayer, at: 0) + self.previewLayer = previewLayer + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if captureSession?.isRunning == false { + captureSession?.startRunning() + } + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + if captureSession?.isRunning == true { + captureSession?.stopRunning() + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if let connection = previewLayer?.connection { + let currentDevice = UIDevice.current + let orientation = currentDevice.orientation + let previewLayerConnection = connection + + if previewLayerConnection.isVideoOrientationSupported { + switch orientation { + case .portrait: + previewLayerConnection.videoOrientation = .portrait + case .landscapeRight: + previewLayerConnection.videoOrientation = .landscapeLeft + case .landscapeLeft: + previewLayerConnection.videoOrientation = .landscapeRight + case .portraitUpsideDown: + previewLayerConnection.videoOrientation = .portraitUpsideDown + default: + previewLayerConnection.videoOrientation = .portrait + + } + } + } + + previewLayer?.frame = view.bounds + } + + func scanDidComplete(withCode code: String) { + let scannedTunnelConfiguration = try? TunnelConfiguration(fromWgQuickConfig: code, called: "Scanned") + guard let tunnelConfiguration = scannedTunnelConfiguration else { + scanDidEncounterError(title: tr("alertScanQRCodeInvalidQRCodeTitle"), message: tr("alertScanQRCodeInvalidQRCodeMessage")) + return + } + + let alert = UIAlertController(title: tr("alertScanQRCodeNamePromptTitle"), message: nil, preferredStyle: .alert) + alert.addTextField(configurationHandler: nil) + alert.addAction(UIAlertAction(title: tr("actionCancel"), style: .cancel) { [weak self] _ in + self?.dismiss(animated: true, completion: nil) + }) + alert.addAction(UIAlertAction(title: tr("actionSave"), style: .default) { [weak self] _ in + guard let title = alert.textFields?[0].text?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else { return } + tunnelConfiguration.name = title + if let self = self { + self.delegate?.addScannedQRCode(tunnelConfiguration: tunnelConfiguration, qrScanViewController: self) { + self.dismiss(animated: true, completion: nil) + } + } + }) + present(alert, animated: true) + } + + func scanDidEncounterError(title: String, message: String) { + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: tr("actionOK"), style: .default) { [weak self] _ in + self?.dismiss(animated: true, completion: nil) + }) + present(alertController, animated: true) + captureSession = nil + } + + @objc func cancelTapped() { + dismiss(animated: true, completion: nil) + } +} + +extension QRScanViewController: AVCaptureMetadataOutputObjectsDelegate { + func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) { + captureSession?.stopRunning() + + guard let metadataObject = metadataObjects.first, + let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject, + let stringValue = readableObject.stringValue else { + scanDidEncounterError(title: tr("alertScanQRCodeUnreadableQRCodeTitle"), message: tr("alertScanQRCodeUnreadableQRCodeMessage")) + return + } + + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + scanDidComplete(withCode: stringValue) + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionDetailTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionDetailTableViewController.swift new file mode 100644 index 0000000..668797e --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionDetailTableViewController.swift @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit + +class SSIDOptionDetailTableViewController: UITableViewController { + + let selectedSSIDs: [String] + + init(title: String, ssids: [String]) { + selectedSSIDs = ssids + super.init(style: .grouped) + self.title = title + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.estimatedRowHeight = 44 + tableView.rowHeight = UITableView.automaticDimension + tableView.allowsSelection = false + + tableView.register(TextCell.self) + } +} + +extension SSIDOptionDetailTableViewController { + override func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return selectedSSIDs.count + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return tr("tunnelOnDemandSectionTitleSelectedSSIDs") + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: TextCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = selectedSSIDs[indexPath.row] + return cell + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionEditTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionEditTableViewController.swift new file mode 100644 index 0000000..a982258 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionEditTableViewController.swift @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit +import SystemConfiguration.CaptiveNetwork + +protocol SSIDOptionEditTableViewControllerDelegate: class { + func ssidOptionSaved(option: ActivateOnDemandViewModel.OnDemandSSIDOption, ssids: [String]) +} + +class SSIDOptionEditTableViewController: UITableViewController { + private enum Section { + case ssidOption + case selectedSSIDs + case addSSIDs + } + + private enum AddSSIDRow { + case addConnectedSSID(connectedSSID: String) + case addNewSSID + } + + weak var delegate: SSIDOptionEditTableViewControllerDelegate? + + private var sections = [Section]() + private var addSSIDRows = [AddSSIDRow]() + + let ssidOptionFields: [ActivateOnDemandViewModel.OnDemandSSIDOption] = [ + .anySSID, + .onlySpecificSSIDs, + .exceptSpecificSSIDs + ] + + var selectedOption: ActivateOnDemandViewModel.OnDemandSSIDOption + var selectedSSIDs: [String] + var connectedSSID: String? + + init(option: ActivateOnDemandViewModel.OnDemandSSIDOption, ssids: [String]) { + selectedOption = option + selectedSSIDs = ssids + super.init(style: .grouped) + connectedSSID = getConnectedSSID() + loadSections() + loadAddSSIDRows() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = tr("tunnelOnDemandSSIDViewTitle") + + tableView.estimatedRowHeight = 44 + tableView.rowHeight = UITableView.automaticDimension + + tableView.register(CheckmarkCell.self) + tableView.register(EditableTextCell.self) + tableView.register(TextCell.self) + tableView.isEditing = true + tableView.allowsSelectionDuringEditing = true + } + + func loadSections() { + sections.removeAll() + sections.append(.ssidOption) + if selectedOption != .anySSID { + sections.append(.selectedSSIDs) + sections.append(.addSSIDs) + } + } + + func loadAddSSIDRows() { + addSSIDRows.removeAll() + if let connectedSSID = connectedSSID { + if !selectedSSIDs.contains(connectedSSID) { + addSSIDRows.append(.addConnectedSSID(connectedSSID: connectedSSID)) + } + } + addSSIDRows.append(.addNewSSID) + } + + func updateTableViewAddSSIDRows() { + guard let addSSIDSection = sections.firstIndex(of: .addSSIDs) else { return } + let numberOfAddSSIDRows = addSSIDRows.count + let numberOfAddSSIDRowsInTableView = tableView.numberOfRows(inSection: addSSIDSection) + switch (numberOfAddSSIDRowsInTableView, numberOfAddSSIDRows) { + case (1, 2): + tableView.insertRows(at: [IndexPath(row: 0, section: addSSIDSection)], with: .automatic) + case (2, 1): + tableView.deleteRows(at: [IndexPath(row: 0, section: addSSIDSection)], with: .automatic) + default: + break + } + } + + override func viewWillDisappear(_ animated: Bool) { + delegate?.ssidOptionSaved(option: selectedOption, ssids: selectedSSIDs) + } +} + +extension SSIDOptionEditTableViewController { + override func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch sections[section] { + case .ssidOption: + return ssidOptionFields.count + case .selectedSSIDs: + return selectedSSIDs.isEmpty ? 1 : selectedSSIDs.count + case .addSSIDs: + return addSSIDRows.count + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch sections[indexPath.section] { + case .ssidOption: + return ssidOptionCell(for: tableView, at: indexPath) + case .selectedSSIDs: + if !selectedSSIDs.isEmpty { + return selectedSSIDCell(for: tableView, at: indexPath) + } else { + return noSSIDsCell(for: tableView, at: indexPath) + } + case .addSSIDs: + return addSSIDCell(for: tableView, at: indexPath) + } + } + + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + switch sections[indexPath.section] { + case .ssidOption: + return false + case .selectedSSIDs: + return !selectedSSIDs.isEmpty + case .addSSIDs: + return true + } + } + + override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { + switch sections[indexPath.section] { + case .ssidOption: + return .none + case .selectedSSIDs: + return .delete + case .addSSIDs: + return .insert + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch sections[section] { + case .ssidOption: + return nil + case .selectedSSIDs: + return tr("tunnelOnDemandSectionTitleSelectedSSIDs") + case .addSSIDs: + return tr("tunnelOnDemandSectionTitleAddSSIDs") + } + } + + private func ssidOptionCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let field = ssidOptionFields[indexPath.row] + let cell: CheckmarkCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = field.localizedUIString + cell.isChecked = selectedOption == field + cell.isEditing = false + return cell + } + + private func noSSIDsCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: TextCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = tr("tunnelOnDemandNoSSIDs") + if #available(iOS 13.0, *) { + cell.setTextColor(.secondaryLabel) + } else { + cell.setTextColor(.gray) + } + cell.setTextAlignment(.center) + return cell + } + + private func selectedSSIDCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: EditableTextCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = selectedSSIDs[indexPath.row] + cell.isEditing = true + cell.onValueBeingEdited = { [weak self, weak cell] text in + guard let self = self, let cell = cell else { return } + if let row = self.tableView.indexPath(for: cell)?.row { + self.selectedSSIDs[row] = text + self.loadAddSSIDRows() + self.updateTableViewAddSSIDRows() + } + } + return cell + } + + private func addSSIDCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: TextCell = tableView.dequeueReusableCell(for: indexPath) + switch addSSIDRows[indexPath.row] { + case .addConnectedSSID: + cell.message = tr(format: "tunnelOnDemandAddMessageAddConnectedSSID (%@)", connectedSSID!) + case .addNewSSID: + cell.message = tr("tunnelOnDemandAddMessageAddNewSSID") + } + cell.isEditing = true + return cell + } + + override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { + switch sections[indexPath.section] { + case .ssidOption: + assertionFailure() + case .selectedSSIDs: + assert(editingStyle == .delete) + selectedSSIDs.remove(at: indexPath.row) + if !selectedSSIDs.isEmpty { + tableView.deleteRows(at: [indexPath], with: .automatic) + } else { + tableView.reloadRows(at: [indexPath], with: .automatic) + } + loadAddSSIDRows() + updateTableViewAddSSIDRows() + case .addSSIDs: + assert(editingStyle == .insert) + let newSSID: String + switch addSSIDRows[indexPath.row] { + case .addConnectedSSID(let connectedSSID): + newSSID = connectedSSID + case .addNewSSID: + newSSID = "" + } + selectedSSIDs.append(newSSID) + loadSections() + let selectedSSIDsSection = sections.firstIndex(of: .selectedSSIDs)! + let indexPath = IndexPath(row: selectedSSIDs.count - 1, section: selectedSSIDsSection) + if selectedSSIDs.count == 1 { + tableView.reloadRows(at: [indexPath], with: .automatic) + } else { + tableView.insertRows(at: [indexPath], with: .automatic) + } + loadAddSSIDRows() + updateTableViewAddSSIDRows() + if newSSID.isEmpty { + if let selectedSSIDCell = tableView.cellForRow(at: indexPath) as? EditableTextCell { + selectedSSIDCell.beginEditing() + } + } + } + } +} + +extension SSIDOptionEditTableViewController { + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + switch sections[indexPath.section] { + case .ssidOption: + return indexPath + case .selectedSSIDs, .addSSIDs: + return nil + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + switch sections[indexPath.section] { + case .ssidOption: + let previousOption = selectedOption + selectedOption = ssidOptionFields[indexPath.row] + guard previousOption != selectedOption else { + tableView.deselectRow(at: indexPath, animated: true) + return + } + loadSections() + if previousOption == .anySSID { + let indexSet = IndexSet(1 ... 2) + tableView.insertSections(indexSet, with: .fade) + } + if selectedOption == .anySSID { + let indexSet = IndexSet(1 ... 2) + tableView.deleteSections(indexSet, with: .fade) + } + tableView.reloadSections(IndexSet(integer: indexPath.section), with: .none) + case .selectedSSIDs, .addSSIDs: + assertionFailure() + } + } +} + +private func getConnectedSSID() -> String? { + guard let supportedInterfaces = CNCopySupportedInterfaces() as? [CFString] else { return nil } + for interface in supportedInterfaces { + if let networkInfo = CNCopyCurrentNetworkInfo(interface) { + if let ssid = (networkInfo as NSDictionary)[kCNNetworkInfoKeySSID as String] as? String { + return !ssid.isEmpty ? ssid : nil + } + } + } + return nil +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/SettingsTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/SettingsTableViewController.swift new file mode 100644 index 0000000..d2c6f0c --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/SettingsTableViewController.swift @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit +import os.log +import WireGuardKit + +class SettingsTableViewController: UITableViewController { + + enum SettingsFields { + case iosAppVersion + case goBackendVersion + case exportZipArchive + case viewLog + case donateLink + + var localizedUIString: String { + switch self { + case .iosAppVersion: return tr("settingsVersionKeyWireGuardForIOS") + case .goBackendVersion: return tr("settingsVersionKeyWireGuardGoBackend") + case .exportZipArchive: return tr("settingsExportZipButtonTitle") + case .viewLog: return tr("settingsViewLogButtonTitle") + case .donateLink: return tr("donateLink") + } + } + } + + let settingsFieldsBySection: [[SettingsFields]] = [ + [.iosAppVersion, .goBackendVersion, .donateLink], + [.exportZipArchive], + [.viewLog] + ] + + let tunnelsManager: TunnelsManager? + var wireguardCaptionedImage: (view: UIView, size: CGSize)? + + init(tunnelsManager: TunnelsManager?) { + self.tunnelsManager = tunnelsManager + super.init(style: .grouped) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = tr("settingsViewTitle") + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneTapped)) + + tableView.estimatedRowHeight = 44 + tableView.rowHeight = UITableView.automaticDimension + tableView.allowsSelection = false + + tableView.register(KeyValueCell.self) + tableView.register(ButtonCell.self) + + tableView.tableFooterView = UIImageView(image: UIImage(named: "wireguard.pdf")) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + guard let logo = tableView.tableFooterView else { return } + + let bottomPadding = max(tableView.layoutMargins.bottom, 10) + let fullHeight = max(tableView.contentSize.height, tableView.bounds.size.height - tableView.layoutMargins.top - bottomPadding) + + let imageAspectRatio = logo.intrinsicContentSize.width / logo.intrinsicContentSize.height + + var height = tableView.estimatedRowHeight * 1.5 + var width = height * imageAspectRatio + let maxWidth = view.bounds.size.width - max(tableView.layoutMargins.left + tableView.layoutMargins.right, 20) + if width > maxWidth { + width = maxWidth + height = width / imageAspectRatio + } + + let needsReload = height != logo.frame.height + + logo.frame = CGRect(x: (view.bounds.size.width - width) / 2, y: fullHeight - height, width: width, height: height) + + if needsReload { + tableView.tableFooterView = logo + } + } + + @objc func doneTapped() { + dismiss(animated: true, completion: nil) + } + + func exportConfigurationsAsZipFile(sourceView: UIView) { + PrivateDataConfirmation.confirmAccess(to: tr("iosExportPrivateData")) { [weak self] in + guard let self = self else { return } + guard let tunnelsManager = self.tunnelsManager else { return } + guard let destinationDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + + let destinationURL = destinationDir.appendingPathComponent("wireguard-export.zip") + _ = FileManager.deleteFile(at: destinationURL) + + 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 + } + + let fileExportVC = UIDocumentPickerViewController(url: destinationURL, in: .exportToService) + self?.present(fileExportVC, animated: true, completion: nil) + } + } + } + + func presentLogView() { + let logVC = LogViewController() + navigationController?.pushViewController(logVC, animated: true) + + } +} + +extension SettingsTableViewController { + override func numberOfSections(in tableView: UITableView) -> Int { + return settingsFieldsBySection.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return settingsFieldsBySection[section].count + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch section { + case 0: + return tr("settingsSectionTitleAbout") + case 1: + return tr("settingsSectionTitleExportConfigurations") + case 2: + return tr("settingsSectionTitleTunnelLog") + default: + return nil + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let field = settingsFieldsBySection[indexPath.section][indexPath.row] + if field == .iosAppVersion || field == .goBackendVersion { + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.copyableGesture = false + cell.key = field.localizedUIString + if field == .iosAppVersion { + var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown version" + if let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String { + appVersion += " (\(appBuild))" + } + cell.value = appVersion + } else if field == .goBackendVersion { + cell.value = getWireGuardVersion() + } + return cell + } else if field == .exportZipArchive { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.localizedUIString + cell.onTapped = { [weak self] in + self?.exportConfigurationsAsZipFile(sourceView: cell.button) + } + return cell + } else if field == .viewLog { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.localizedUIString + cell.onTapped = { [weak self] in + self?.presentLogView() + } + return cell + } else if field == .donateLink { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.localizedUIString + cell.onTapped = { + if let url = URL(string: "https://www.wireguard.com/donations/"), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:]) + } + } + return cell + } + fatalError() + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/TunnelDetailTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelDetailTableViewController.swift new file mode 100644 index 0000000..d49acbc --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelDetailTableViewController.swift @@ -0,0 +1,458 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit +import WireGuardKit + +class TunnelDetailTableViewController: UITableViewController { + + private enum Section { + case status + case interface + case peer(index: Int, peer: TunnelViewModel.PeerData) + case onDemand + case delete + } + + static let interfaceFields: [TunnelViewModel.InterfaceField] = [ + .name, .publicKey, .addresses, + .listenPort, .mtu, .dns + ] + + static let peerFields: [TunnelViewModel.PeerField] = [ + .publicKey, .preSharedKey, .endpoint, + .allowedIPs, .persistentKeepAlive, + .rxBytes, .txBytes, .lastHandshakeTime + ] + + static let onDemandFields: [ActivateOnDemandViewModel.OnDemandField] = [ + .onDemand, .ssid + ] + + let tunnelsManager: TunnelsManager + let tunnel: TunnelContainer + var tunnelViewModel: TunnelViewModel + var onDemandViewModel: ActivateOnDemandViewModel + + private var sections = [Section]() + private var interfaceFieldIsVisible = [Bool]() + private var peerFieldIsVisible = [[Bool]]() + + private var statusObservationToken: AnyObject? + private var onDemandObservationToken: AnyObject? + 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(style: .grouped) + loadSections() + loadVisibleFields() + statusObservationToken = tunnel.observe(\.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() + } + } + onDemandObservationToken = tunnel.observe(\.isActivateOnDemandEnabled) { [weak self] tunnel, _ in + // Handle On-Demand getting turned on/off outside of the app + self?.onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel) + self?.updateActivateOnDemandFields() + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = tunnelViewModel.interfaceData[.name] + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(editTapped)) + + tableView.estimatedRowHeight = 44 + tableView.rowHeight = UITableView.automaticDimension + tableView.register(SwitchCell.self) + tableView.register(KeyValueCell.self) + tableView.register(ButtonCell.self) + tableView.register(ChevronCell.self) + + restorationIdentifier = "TunnelDetailVC:\(tunnel.name)" + } + + private func loadSections() { + sections.removeAll() + sections.append(.status) + sections.append(.interface) + for (index, peer) in tunnelViewModel.peersData.enumerated() { + sections.append(.peer(index: index, peer: peer)) + } + sections.append(.onDemand) + sections.append(.delete) + } + + private func loadVisibleFields() { + let visibleInterfaceFields = tunnelViewModel.interfaceData.filterFieldsWithValueOrControl(interfaceFields: TunnelDetailTableViewController.interfaceFields) + interfaceFieldIsVisible = TunnelDetailTableViewController.interfaceFields.map { visibleInterfaceFields.contains($0) } + peerFieldIsVisible = tunnelViewModel.peersData.map { peer in + let visiblePeerFields = peer.filterFieldsWithValueOrControl(peerFields: TunnelDetailTableViewController.peerFields) + return TunnelDetailTableViewController.peerFields.map { visiblePeerFields.contains($0) } + } + } + + override func viewWillAppear(_ animated: Bool) { + if tunnel.status == .active { + self.startUpdatingRuntimeConfiguration() + } + } + + override func viewDidDisappear(_ animated: Bool) { + stopUpdatingRuntimeConfiguration() + } + + @objc func editTapped() { + PrivateDataConfirmation.confirmAccess(to: tr("iosViewPrivateData")) { [weak self] in + guard let self = self else { return } + let editVC = TunnelEditTableViewController(tunnelsManager: self.tunnelsManager, tunnel: self.tunnel) + editVC.delegate = self + let editNC = UINavigationController(rootViewController: editVC) + editNC.modalPresentationStyle = .fullScreen + self.present(editNC, animated: true) + } + } + + 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() { + reloadRuntimeConfigurationTimer?.invalidate() + reloadRuntimeConfigurationTimer = nil + } + + func applyTunnelConfiguration(tunnelConfiguration: TunnelConfiguration) { + // Incorporates changes from tunnelConfiguation. Ignores any changes in peer ordering. + guard let tableView = self.tableView else { return } + let sections = self.sections + let interfaceSectionIndex = sections.firstIndex { + if case .interface = $0 { + return true + } else { + return false + } + }! + let firstPeerSectionIndex = interfaceSectionIndex + 1 + var interfaceFieldIsVisible = self.interfaceFieldIsVisible + var peerFieldIsVisible = self.peerFieldIsVisible + + func handleSectionFieldsModified<T>(fields: [T], fieldIsVisible: [Bool], section: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) { + for (index, field) in fields.enumerated() { + guard let change = changes[field] else { continue } + if case .modified(let newValue) = change { + let row = fieldIsVisible[0 ..< index].filter { $0 }.count + let indexPath = IndexPath(row: row, section: section) + if let cell = tableView.cellForRow(at: indexPath) as? KeyValueCell { + cell.value = newValue + } + } + } + } + + func handleSectionRowsInsertedOrRemoved<T>(fields: [T], fieldIsVisible fieldIsVisibleInput: [Bool], section: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) { + var fieldIsVisible = fieldIsVisibleInput + + var removedIndexPaths = [IndexPath]() + for (index, field) in fields.enumerated().reversed() where changes[field] == .removed { + let row = fieldIsVisible[0 ..< index].filter { $0 }.count + removedIndexPaths.append(IndexPath(row: row, section: section)) + fieldIsVisible[index] = false + } + if !removedIndexPaths.isEmpty { + tableView.deleteRows(at: removedIndexPaths, with: .automatic) + } + + var addedIndexPaths = [IndexPath]() + for (index, field) in fields.enumerated() where changes[field] == .added { + let row = fieldIsVisible[0 ..< index].filter { $0 }.count + addedIndexPaths.append(IndexPath(row: row, section: section)) + fieldIsVisible[index] = true + } + if !addedIndexPaths.isEmpty { + tableView.insertRows(at: addedIndexPaths, with: .automatic) + } + } + + let changes = self.tunnelViewModel.applyConfiguration(other: tunnelConfiguration) + + if !changes.interfaceChanges.isEmpty { + handleSectionFieldsModified(fields: TunnelDetailTableViewController.interfaceFields, fieldIsVisible: interfaceFieldIsVisible, + section: interfaceSectionIndex, changes: changes.interfaceChanges) + } + for (peerIndex, peerChanges) in changes.peerChanges { + handleSectionFieldsModified(fields: TunnelDetailTableViewController.peerFields, fieldIsVisible: peerFieldIsVisible[peerIndex], section: firstPeerSectionIndex + peerIndex, 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 } } + let peersRemovedSectionIndices = changes.peersRemovedIndices.map { firstPeerSectionIndex + $0 } + let peersInsertedSectionIndices = changes.peersInsertedIndices.map { firstPeerSectionIndex + $0 } + + if isAnyInterfaceFieldAddedOrRemoved || isAnyPeerFieldAddedOrRemoved || !peersRemovedSectionIndices.isEmpty || !peersInsertedSectionIndices.isEmpty { + tableView.beginUpdates() + if isAnyInterfaceFieldAddedOrRemoved { + handleSectionRowsInsertedOrRemoved(fields: TunnelDetailTableViewController.interfaceFields, fieldIsVisible: interfaceFieldIsVisible, section: interfaceSectionIndex, changes: changes.interfaceChanges) + } + if isAnyPeerFieldAddedOrRemoved { + for (peerIndex, peerChanges) in changes.peerChanges { + handleSectionRowsInsertedOrRemoved(fields: TunnelDetailTableViewController.peerFields, fieldIsVisible: peerFieldIsVisible[peerIndex], section: firstPeerSectionIndex + peerIndex, changes: peerChanges) + } + } + if !peersRemovedSectionIndices.isEmpty { + tableView.deleteSections(IndexSet(peersRemovedSectionIndices), with: .automatic) + } + if !peersInsertedSectionIndices.isEmpty { + tableView.insertSections(IndexSet(peersInsertedSectionIndices), with: .automatic) + } + self.loadSections() + self.loadVisibleFields() + tableView.endUpdates() + } else { + self.loadSections() + self.loadVisibleFields() + } + } + + private func reloadRuntimeConfiguration() { + tunnel.getRuntimeTunnelConfiguration { [weak self] tunnelConfiguration in + guard let tunnelConfiguration = tunnelConfiguration else { return } + guard let self = self else { return } + self.applyTunnelConfiguration(tunnelConfiguration: tunnelConfiguration) + } + } + + private func updateActivateOnDemandFields() { + guard let onDemandSection = sections.firstIndex(where: { if case .onDemand = $0 { return true } else { return false } }) else { return } + let numberOfTableViewOnDemandRows = tableView.numberOfRows(inSection: onDemandSection) + let ssidRowIndexPath = IndexPath(row: 1, section: onDemandSection) + switch (numberOfTableViewOnDemandRows, onDemandViewModel.isWiFiInterfaceEnabled) { + case (1, true): + tableView.insertRows(at: [ssidRowIndexPath], with: .automatic) + case (2, false): + tableView.deleteRows(at: [ssidRowIndexPath], with: .automatic) + default: + break + } + tableView.reloadSections(IndexSet(integer: onDemandSection), with: .automatic) + } +} + +extension TunnelDetailTableViewController: TunnelEditTableViewControllerDelegate { + func tunnelSaved(tunnel: TunnelContainer) { + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration) + onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel) + loadSections() + loadVisibleFields() + title = tunnel.name + restorationIdentifier = "TunnelDetailVC:\(tunnel.name)" + tableView.reloadData() + } + func tunnelEditingCancelled() { + // Nothing to do + } +} + +extension TunnelDetailTableViewController { + override func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch sections[section] { + case .status: + return 1 + case .interface: + return interfaceFieldIsVisible.filter { $0 }.count + case .peer(let peerIndex, _): + return peerFieldIsVisible[peerIndex].filter { $0 }.count + case .onDemand: + return onDemandViewModel.isWiFiInterfaceEnabled ? 2 : 1 + case .delete: + return 1 + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch sections[section] { + case .status: + return tr("tunnelSectionTitleStatus") + case .interface: + return tr("tunnelSectionTitleInterface") + case .peer: + return tr("tunnelSectionTitlePeer") + case .onDemand: + return tr("tunnelSectionTitleOnDemand") + case .delete: + return nil + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch sections[indexPath.section] { + case .status: + return statusCell(for: tableView, at: indexPath) + case .interface: + return interfaceCell(for: tableView, at: indexPath) + case .peer(let index, let peer): + return peerCell(for: tableView, at: indexPath, with: peer, peerIndex: index) + case .onDemand: + return onDemandCell(for: tableView, at: indexPath) + case .delete: + return deleteConfigurationCell(for: tableView, at: indexPath) + } + } + + private func statusCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath) + + let statusUpdate: (SwitchCell, TunnelStatus) -> Void = { cell, status in + let text: String + switch status { + case .inactive: + text = tr("tunnelStatusInactive") + case .activating: + text = tr("tunnelStatusActivating") + case .active: + text = tr("tunnelStatusActive") + case .deactivating: + text = tr("tunnelStatusDeactivating") + case .reasserting: + text = tr("tunnelStatusReasserting") + case .restarting: + text = tr("tunnelStatusRestarting") + case .waiting: + text = tr("tunnelStatusWaiting") + } + cell.textLabel?.text = text + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak cell] in + cell?.switchView.isOn = !(status == .deactivating || status == .inactive) + cell?.switchView.isUserInteractionEnabled = (status == .inactive || status == .active) + } + cell.isEnabled = status == .active || status == .inactive + } + + statusUpdate(cell, tunnel.status) + cell.observationToken = tunnel.observe(\.status) { [weak cell] tunnel, _ in + guard let cell = cell else { return } + statusUpdate(cell, tunnel.status) + } + + cell.onSwitchToggled = { [weak self] isOn in + guard let self = self else { return } + if isOn { + self.tunnelsManager.startActivation(of: self.tunnel) + } else { + self.tunnelsManager.startDeactivation(of: self.tunnel) + } + } + return cell + } + + private func interfaceCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let visibleInterfaceFields = TunnelDetailTableViewController.interfaceFields.enumerated().filter { interfaceFieldIsVisible[$0.offset] }.map { $0.element } + let field = visibleInterfaceFields[indexPath.row] + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + cell.value = tunnelViewModel.interfaceData[field] + return cell + } + + private func peerCell(for tableView: UITableView, at indexPath: IndexPath, with peerData: TunnelViewModel.PeerData, peerIndex: Int) -> UITableViewCell { + let visiblePeerFields = TunnelDetailTableViewController.peerFields.enumerated().filter { peerFieldIsVisible[peerIndex][$0.offset] }.map { $0.element } + let field = visiblePeerFields[indexPath.row] + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + if field == .persistentKeepAlive { + cell.value = tr(format: "tunnelPeerPersistentKeepaliveValue (%@)", peerData[field]) + } else if field == .preSharedKey { + cell.value = tr("tunnelPeerPresharedKeyEnabled") + } else { + cell.value = peerData[field] + } + return cell + } + + private func onDemandCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let field = TunnelDetailTableViewController.onDemandFields[indexPath.row] + if field == .onDemand { + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + cell.value = onDemandViewModel.localizedInterfaceDescription + return cell + } else { + assert(field == .ssid) + if onDemandViewModel.ssidOption == .anySSID { + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + cell.value = onDemandViewModel.ssidOption.localizedUIString + return cell + } else { + let cell: ChevronCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = field.localizedUIString + cell.detailMessage = onDemandViewModel.localizedSSIDDescription + return cell + } + } + } + + private func deleteConfigurationCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = tr("deleteTunnelButtonTitle") + cell.hasDestructiveAction = true + cell.onTapped = { [weak self] in + guard let self = self else { return } + ConfirmationAlertPresenter.showConfirmationAlert(message: tr("deleteTunnelConfirmationAlertMessage"), + buttonTitle: tr("deleteTunnelConfirmationAlertButtonTitle"), + from: cell, presentingVC: self) { [weak self] in + guard let self = self else { return } + self.tunnelsManager.remove(tunnel: self.tunnel) { error in + if error != nil { + print("Error removing tunnel: \(String(describing: error))") + return + } + } + } + } + return cell + } + +} + +extension TunnelDetailTableViewController { + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + if case .onDemand = sections[indexPath.section], + case .ssid = TunnelDetailTableViewController.onDemandFields[indexPath.row] { + return indexPath + } + return nil + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if case .onDemand = sections[indexPath.section], + case .ssid = TunnelDetailTableViewController.onDemandFields[indexPath.row] { + let ssidDetailVC = SSIDOptionDetailTableViewController(title: onDemandViewModel.ssidOption.localizedUIString, ssids: onDemandViewModel.selectedSSIDs) + navigationController?.pushViewController(ssidDetailVC, animated: true) + } + tableView.deselectRow(at: indexPath, animated: true) + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/TunnelEditTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelEditTableViewController.swift new file mode 100644 index 0000000..ecad2f6 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelEditTableViewController.swift @@ -0,0 +1,504 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit +import WireGuardKit + +protocol TunnelEditTableViewControllerDelegate: class { + func tunnelSaved(tunnel: TunnelContainer) + func tunnelEditingCancelled() +} + +class TunnelEditTableViewController: UITableViewController { + private enum Section { + case interface + case peer(_ peer: TunnelViewModel.PeerData) + case addPeer + case onDemand + + static func == (lhs: Section, rhs: Section) -> Bool { + switch (lhs, rhs) { + case (.interface, .interface), + (.addPeer, .addPeer), + (.onDemand, .onDemand): + return true + case let (.peer(peerA), .peer(peerB)): + return peerA.index == peerB.index + default: + return false + } + } + } + + weak var delegate: TunnelEditTableViewControllerDelegate? + + let interfaceFieldsBySection: [[TunnelViewModel.InterfaceField]] = [ + [.name], + [.privateKey, .publicKey, .generateKeyPair], + [.addresses, .listenPort, .mtu, .dns] + ] + + let peerFields: [TunnelViewModel.PeerField] = [ + .publicKey, .preSharedKey, .endpoint, + .allowedIPs, .excludePrivateIPs, .persistentKeepAlive, + .deletePeer + ] + + let onDemandFields: [ActivateOnDemandViewModel.OnDemandField] = [ + .nonWiFiInterface, + .wiFiInterface, + .ssid + ] + + let tunnelsManager: TunnelsManager + let tunnel: TunnelContainer? + let tunnelViewModel: TunnelViewModel + var onDemandViewModel: ActivateOnDemandViewModel + private var sections = [Section]() + + // Use this initializer to edit an existing tunnel. + init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) { + self.tunnelsManager = tunnelsManager + self.tunnel = tunnel + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration) + onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel) + super.init(style: .grouped) + loadSections() + } + + // Use this initializer to create a new tunnel. + init(tunnelsManager: TunnelsManager) { + self.tunnelsManager = tunnelsManager + tunnel = nil + tunnelViewModel = TunnelViewModel(tunnelConfiguration: nil) + onDemandViewModel = ActivateOnDemandViewModel() + super.init(style: .grouped) + loadSections() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = tunnel == nil ? tr("newTunnelViewTitle") : tr("editTunnelViewTitle") + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(saveTapped)) + navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped)) + + tableView.estimatedRowHeight = 44 + tableView.rowHeight = UITableView.automaticDimension + + tableView.register(TunnelEditKeyValueCell.self) + tableView.register(TunnelEditEditableKeyValueCell.self) + tableView.register(ButtonCell.self) + tableView.register(SwitchCell.self) + tableView.register(ChevronCell.self) + } + + private func loadSections() { + sections.removeAll() + interfaceFieldsBySection.forEach { _ in sections.append(.interface) } + tunnelViewModel.peersData.forEach { sections.append(.peer($0)) } + sections.append(.addPeer) + sections.append(.onDemand) + } + + @objc func saveTapped() { + tableView.endEditing(false) + let tunnelSaveResult = tunnelViewModel.save() + switch tunnelSaveResult { + case .error(let errorMessage): + let alertTitle = (tunnelViewModel.interfaceData.validatedConfiguration == nil || tunnelViewModel.interfaceData.validatedName == nil) ? + tr("alertInvalidInterfaceTitle") : tr("alertInvalidPeerTitle") + ErrorPresenter.showErrorAlert(title: alertTitle, message: errorMessage, from: self) + tableView.reloadData() // Highlight erroring fields + case .saved(let tunnelConfiguration): + let onDemandOption = onDemandViewModel.toOnDemandOption() + if let tunnel = tunnel { + // We're modifying an existing tunnel + tunnelsManager.modify(tunnel: tunnel, tunnelConfiguration: tunnelConfiguration, onDemandOption: onDemandOption) { [weak self] error in + if let error = error { + ErrorPresenter.showErrorAlert(error: error, from: self) + } else { + self?.dismiss(animated: true, completion: nil) + self?.delegate?.tunnelSaved(tunnel: tunnel) + } + } + } else { + // We're adding a new tunnel + tunnelsManager.add(tunnelConfiguration: tunnelConfiguration, onDemandOption: onDemandOption) { [weak self] result in + switch result { + case .failure(let error): + ErrorPresenter.showErrorAlert(error: error, from: self) + case .success(let tunnel): + self?.dismiss(animated: true, completion: nil) + self?.delegate?.tunnelSaved(tunnel: tunnel) + } + } + } + } + } + + @objc func cancelTapped() { + dismiss(animated: true, completion: nil) + delegate?.tunnelEditingCancelled() + } +} + +// MARK: UITableViewDataSource + +extension TunnelEditTableViewController { + override func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch sections[section] { + case .interface: + return interfaceFieldsBySection[section].count + case .peer(let peerData): + let peerFieldsToShow = peerData.shouldAllowExcludePrivateIPsControl ? peerFields : peerFields.filter { $0 != .excludePrivateIPs } + return peerFieldsToShow.count + case .addPeer: + return 1 + case .onDemand: + if onDemandViewModel.isWiFiInterfaceEnabled { + return 3 + } else { + return 2 + } + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch sections[section] { + case .interface: + return section == 0 ? tr("tunnelSectionTitleInterface") : nil + case .peer: + return tr("tunnelSectionTitlePeer") + case .addPeer: + return nil + case .onDemand: + return tr("tunnelSectionTitleOnDemand") + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch sections[indexPath.section] { + case .interface: + return interfaceFieldCell(for: tableView, at: indexPath) + case .peer(let peerData): + return peerCell(for: tableView, at: indexPath, with: peerData) + case .addPeer: + return addPeerCell(for: tableView, at: indexPath) + case .onDemand: + return onDemandCell(for: tableView, at: indexPath) + } + } + + private func interfaceFieldCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let field = interfaceFieldsBySection[indexPath.section][indexPath.row] + switch field { + case .generateKeyPair: + return generateKeyPairCell(for: tableView, at: indexPath, with: field) + case .publicKey: + return publicKeyCell(for: tableView, at: indexPath, with: field) + default: + return interfaceFieldKeyValueCell(for: tableView, at: indexPath, with: field) + } + } + + private func generateKeyPairCell(for tableView: UITableView, at indexPath: IndexPath, with field: TunnelViewModel.InterfaceField) -> UITableViewCell { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.localizedUIString + cell.onTapped = { [weak self] in + guard let self = self else { return } + + self.tunnelViewModel.interfaceData[.privateKey] = PrivateKey().base64Key + if let privateKeyRow = self.interfaceFieldsBySection[indexPath.section].firstIndex(of: .privateKey), + let publicKeyRow = self.interfaceFieldsBySection[indexPath.section].firstIndex(of: .publicKey) { + let privateKeyIndex = IndexPath(row: privateKeyRow, section: indexPath.section) + let publicKeyIndex = IndexPath(row: publicKeyRow, section: indexPath.section) + self.tableView.reloadRows(at: [privateKeyIndex, publicKeyIndex], with: .fade) + } + } + return cell + } + + private func publicKeyCell(for tableView: UITableView, at indexPath: IndexPath, with field: TunnelViewModel.InterfaceField) -> UITableViewCell { + let cell: TunnelEditKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + cell.value = tunnelViewModel.interfaceData[field] + return cell + } + + private func interfaceFieldKeyValueCell(for tableView: UITableView, at indexPath: IndexPath, with field: TunnelViewModel.InterfaceField) -> UITableViewCell { + let cell: TunnelEditEditableKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + + switch field { + case .name, .privateKey: + cell.placeholderText = tr("tunnelEditPlaceholderTextRequired") + cell.keyboardType = .default + case .addresses: + cell.placeholderText = tr("tunnelEditPlaceholderTextStronglyRecommended") + cell.keyboardType = .numbersAndPunctuation + case .dns: + cell.placeholderText = tunnelViewModel.peersData.contains(where: { $0.shouldStronglyRecommendDNS }) ? tr("tunnelEditPlaceholderTextStronglyRecommended") : tr("tunnelEditPlaceholderTextOptional") + cell.keyboardType = .numbersAndPunctuation + case .listenPort, .mtu: + cell.placeholderText = tr("tunnelEditPlaceholderTextAutomatic") + cell.keyboardType = .numberPad + case .publicKey, .generateKeyPair: + cell.keyboardType = .default + case .status, .toggleStatus: + fatalError("Unexpected interface field") + } + + cell.isValueValid = (!tunnelViewModel.interfaceData.fieldsWithError.contains(field)) + // Bind values to view model + cell.value = tunnelViewModel.interfaceData[field] + if field == .dns { // While editing DNS, you might directly set exclude private IPs + cell.onValueBeingEdited = { [weak self] value in + self?.tunnelViewModel.interfaceData[field] = value + } + cell.onValueChanged = { [weak self] oldValue, newValue in + guard let self = self else { return } + let isAllowedIPsChanged = self.tunnelViewModel.updateDNSServersInAllowedIPsIfRequired(oldDNSServers: oldValue, newDNSServers: newValue) + if isAllowedIPsChanged { + let section = self.sections.firstIndex { if case .peer(_) = $0 { return true } else { return false } } + if let section = section, let row = self.peerFields.firstIndex(of: .allowedIPs) { + self.tableView.reloadRows(at: [IndexPath(row: row, section: section)], with: .none) + } + } + } + } else { + cell.onValueChanged = { [weak self] _, value in + self?.tunnelViewModel.interfaceData[field] = value + } + } + // Compute public key live + if field == .privateKey { + cell.onValueBeingEdited = { [weak self] value in + guard let self = self else { return } + + self.tunnelViewModel.interfaceData[.privateKey] = value + if let row = self.interfaceFieldsBySection[indexPath.section].firstIndex(of: .publicKey) { + self.tableView.reloadRows(at: [IndexPath(row: row, section: indexPath.section)], with: .none) + } + } + } + return cell + } + + private func peerCell(for tableView: UITableView, at indexPath: IndexPath, with peerData: TunnelViewModel.PeerData) -> UITableViewCell { + let peerFieldsToShow = peerData.shouldAllowExcludePrivateIPsControl ? peerFields : peerFields.filter { $0 != .excludePrivateIPs } + let field = peerFieldsToShow[indexPath.row] + + switch field { + case .deletePeer: + return deletePeerCell(for: tableView, at: indexPath, peerData: peerData, field: field) + case .excludePrivateIPs: + return excludePrivateIPsCell(for: tableView, at: indexPath, peerData: peerData, field: field) + default: + return peerFieldKeyValueCell(for: tableView, at: indexPath, peerData: peerData, field: field) + } + } + + private func deletePeerCell(for tableView: UITableView, at indexPath: IndexPath, peerData: TunnelViewModel.PeerData, field: TunnelViewModel.PeerField) -> UITableViewCell { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.localizedUIString + cell.hasDestructiveAction = true + cell.onTapped = { [weak self, weak peerData] in + guard let self = self, let peerData = peerData else { return } + ConfirmationAlertPresenter.showConfirmationAlert(message: tr("deletePeerConfirmationAlertMessage"), + buttonTitle: tr("deletePeerConfirmationAlertButtonTitle"), + from: cell, presentingVC: self) { [weak self] in + guard let self = self else { return } + let removedSectionIndices = self.deletePeer(peer: peerData) + let shouldShowExcludePrivateIPs = (self.tunnelViewModel.peersData.count == 1 && self.tunnelViewModel.peersData[0].shouldAllowExcludePrivateIPsControl) + + //swiftlint:disable:next trailing_closure + tableView.performBatchUpdates({ + self.tableView.deleteSections(removedSectionIndices, with: .fade) + if shouldShowExcludePrivateIPs { + if let row = self.peerFields.firstIndex(of: .excludePrivateIPs) { + let rowIndexPath = IndexPath(row: row, section: self.interfaceFieldsBySection.count /* First peer section */) + self.tableView.insertRows(at: [rowIndexPath], with: .fade) + } + } + }) + } + } + return cell + } + + private func excludePrivateIPsCell(for tableView: UITableView, at indexPath: IndexPath, peerData: TunnelViewModel.PeerData, field: TunnelViewModel.PeerField) -> UITableViewCell { + let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = field.localizedUIString + cell.isEnabled = peerData.shouldAllowExcludePrivateIPsControl + cell.isOn = peerData.excludePrivateIPsValue + cell.onSwitchToggled = { [weak self] isOn in + guard let self = self else { return } + peerData.excludePrivateIPsValueChanged(isOn: isOn, dnsServers: self.tunnelViewModel.interfaceData[.dns]) + if let row = self.peerFields.firstIndex(of: .allowedIPs) { + self.tableView.reloadRows(at: [IndexPath(row: row, section: indexPath.section)], with: .none) + } + } + return cell + } + + private func peerFieldKeyValueCell(for tableView: UITableView, at indexPath: IndexPath, peerData: TunnelViewModel.PeerData, field: TunnelViewModel.PeerField) -> UITableViewCell { + let cell: TunnelEditEditableKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + + switch field { + case .publicKey: + cell.placeholderText = tr("tunnelEditPlaceholderTextRequired") + cell.keyboardType = .default + case .preSharedKey, .endpoint: + cell.placeholderText = tr("tunnelEditPlaceholderTextOptional") + cell.keyboardType = .default + case .allowedIPs: + cell.placeholderText = tr("tunnelEditPlaceholderTextOptional") + cell.keyboardType = .numbersAndPunctuation + case .persistentKeepAlive: + cell.placeholderText = tr("tunnelEditPlaceholderTextOff") + cell.keyboardType = .numberPad + case .excludePrivateIPs, .deletePeer: + cell.keyboardType = .default + case .rxBytes, .txBytes, .lastHandshakeTime: + fatalError() + } + + cell.isValueValid = !peerData.fieldsWithError.contains(field) + cell.value = peerData[field] + + if field == .allowedIPs { + let firstInterfaceSection = sections.firstIndex { $0 == .interface }! + let interfaceSubSection = interfaceFieldsBySection.firstIndex { $0.contains(.dns) }! + let dnsRow = interfaceFieldsBySection[interfaceSubSection].firstIndex { $0 == .dns }! + + cell.onValueBeingEdited = { [weak self, weak peerData] value in + guard let self = self, let peerData = peerData else { return } + + let oldValue = peerData.shouldAllowExcludePrivateIPsControl + peerData[.allowedIPs] = value + if oldValue != peerData.shouldAllowExcludePrivateIPsControl, let row = self.peerFields.firstIndex(of: .excludePrivateIPs) { + if peerData.shouldAllowExcludePrivateIPsControl { + self.tableView.insertRows(at: [IndexPath(row: row, section: indexPath.section)], with: .fade) + } else { + self.tableView.deleteRows(at: [IndexPath(row: row, section: indexPath.section)], with: .fade) + } + } + + tableView.reloadRows(at: [IndexPath(row: dnsRow, section: firstInterfaceSection + interfaceSubSection)], with: .none) + } + } else { + cell.onValueChanged = { [weak peerData] _, value in + peerData?[field] = value + } + } + + return cell + } + + private func addPeerCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = tr("addPeerButtonTitle") + cell.onTapped = { [weak self] in + guard let self = self else { return } + let shouldHideExcludePrivateIPs = (self.tunnelViewModel.peersData.count == 1 && self.tunnelViewModel.peersData[0].shouldAllowExcludePrivateIPsControl) + let addedSectionIndices = self.appendEmptyPeer() + tableView.performBatchUpdates({ + tableView.insertSections(addedSectionIndices, with: .fade) + if shouldHideExcludePrivateIPs { + if let row = self.peerFields.firstIndex(of: .excludePrivateIPs) { + let rowIndexPath = IndexPath(row: row, section: self.interfaceFieldsBySection.count /* First peer section */) + self.tableView.deleteRows(at: [rowIndexPath], with: .fade) + } + } + }, completion: nil) + } + return cell + } + + private func onDemandCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let field = onDemandFields[indexPath.row] + if indexPath.row < 2 { + let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = field.localizedUIString + cell.isOn = onDemandViewModel.isEnabled(field: field) + cell.onSwitchToggled = { [weak self] isOn in + guard let self = self else { return } + self.onDemandViewModel.setEnabled(field: field, isEnabled: isOn) + let section = self.sections.firstIndex { $0 == .onDemand }! + let indexPath = IndexPath(row: 2, section: section) + if field == .wiFiInterface { + if isOn { + tableView.insertRows(at: [indexPath], with: .fade) + } else { + tableView.deleteRows(at: [indexPath], with: .fade) + } + } + } + return cell + } else { + let cell: ChevronCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = field.localizedUIString + cell.detailMessage = onDemandViewModel.localizedSSIDDescription + return cell + } + } + + func appendEmptyPeer() -> IndexSet { + tunnelViewModel.appendEmptyPeer() + loadSections() + let addedPeerIndex = tunnelViewModel.peersData.count - 1 + return IndexSet(integer: interfaceFieldsBySection.count + addedPeerIndex) + } + + func deletePeer(peer: TunnelViewModel.PeerData) -> IndexSet { + tunnelViewModel.deletePeer(peer: peer) + loadSections() + return IndexSet(integer: interfaceFieldsBySection.count + peer.index) + } +} + +extension TunnelEditTableViewController { + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + if case .onDemand = sections[indexPath.section], indexPath.row == 2 { + return indexPath + } else { + return nil + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + switch sections[indexPath.section] { + case .onDemand: + assert(indexPath.row == 2) + tableView.deselectRow(at: indexPath, animated: true) + let ssidOptionVC = SSIDOptionEditTableViewController(option: onDemandViewModel.ssidOption, ssids: onDemandViewModel.selectedSSIDs) + ssidOptionVC.delegate = self + navigationController?.pushViewController(ssidOptionVC, animated: true) + default: + assertionFailure() + } + } +} + +extension TunnelEditTableViewController: SSIDOptionEditTableViewControllerDelegate { + func ssidOptionSaved(option: ActivateOnDemandViewModel.OnDemandSSIDOption, ssids: [String]) { + onDemandViewModel.selectedSSIDs = ssids + onDemandViewModel.ssidOption = option + onDemandViewModel.fixSSIDOption() + if let onDemandSection = sections.firstIndex(where: { $0 == .onDemand }) { + if let ssidRowIndex = onDemandFields.firstIndex(of: .ssid) { + let indexPath = IndexPath(row: ssidRowIndex, section: onDemandSection) + tableView.reloadRows(at: [indexPath], with: .none) + } + } + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/TunnelsListTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelsListTableViewController.swift new file mode 100644 index 0000000..f442420 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelsListTableViewController.swift @@ -0,0 +1,429 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import UIKit +import MobileCoreServices +import UserNotifications +import WireGuardKit + +class TunnelsListTableViewController: UIViewController { + + var tunnelsManager: TunnelsManager? + + enum TableState: Equatable { + case normal + case rowSwiped + case multiSelect(selectionCount: Int) + } + + let tableView: UITableView = { + let tableView = UITableView(frame: CGRect.zero, style: .plain) + tableView.estimatedRowHeight = 60 + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.register(TunnelListCell.self) + return tableView + }() + + let centeredAddButton: BorderedTextButton = { + let button = BorderedTextButton() + button.title = tr("tunnelsListCenteredAddTunnelButtonTitle") + button.isHidden = true + return button + }() + + let busyIndicator: UIActivityIndicatorView = { + if #available(iOS 13.0, *) { + let busyIndicator = UIActivityIndicatorView(style: .medium) + busyIndicator.hidesWhenStopped = true + return busyIndicator + } else { + let busyIndicator = UIActivityIndicatorView(style: .gray) + busyIndicator.hidesWhenStopped = true + return busyIndicator + } + }() + + var detailDisplayedTunnel: TunnelContainer? + var tableState: TableState = .normal { + didSet { + handleTableStateChange() + } + } + + override func loadView() { + view = UIView() + if #available(iOS 13.0, *) { + view.backgroundColor = .systemBackground + } else { + view.backgroundColor = .white + } + + tableView.dataSource = self + tableView.delegate = self + + view.addSubview(tableView) + tableView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + view.addSubview(busyIndicator) + busyIndicator.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + busyIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + busyIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + + view.addSubview(centeredAddButton) + centeredAddButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + centeredAddButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + centeredAddButton.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + + centeredAddButton.onTapped = { [weak self] in + guard let self = self else { return } + self.addButtonTapped(sender: self.centeredAddButton) + } + + busyIndicator.startAnimating() + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableState = .normal + restorationIdentifier = "TunnelsListVC" + } + + func handleTableStateChange() { + switch tableState { + case .normal: + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped(sender:))) + navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSettingsButtonTitle"), style: .plain, target: self, action: #selector(settingsButtonTapped(sender:))) + case .rowSwiped: + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButtonTapped)) + navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSelectButtonTitle"), style: .plain, target: self, action: #selector(selectButtonTapped)) + case .multiSelect(let selectionCount): + if selectionCount > 0 { + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonTapped)) + navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListDeleteButtonTitle"), style: .plain, target: self, action: #selector(deleteButtonTapped(sender:))) + } else { + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonTapped)) + navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSelectAllButtonTitle"), style: .plain, target: self, action: #selector(selectAllButtonTapped)) + } + } + if case .multiSelect(let selectionCount) = tableState, selectionCount > 0 { + navigationItem.title = tr(format: "tunnelsListSelectedTitle (%d)", selectionCount) + } else { + navigationItem.title = tr("tunnelsListTitle") + } + if case .multiSelect = tableState { + tableView.allowsMultipleSelectionDuringEditing = true + } else { + tableView.allowsMultipleSelectionDuringEditing = false + } + } + + func setTunnelsManager(tunnelsManager: TunnelsManager) { + self.tunnelsManager = tunnelsManager + tunnelsManager.tunnelsListDelegate = self + + busyIndicator.stopAnimating() + tableView.reloadData() + centeredAddButton.isHidden = tunnelsManager.numberOfTunnels() > 0 + } + + override func viewWillAppear(_: Bool) { + if let selectedRowIndexPath = tableView.indexPathForSelectedRow { + tableView.deselectRow(at: selectedRowIndexPath, animated: false) + } + } + + @objc func addButtonTapped(sender: AnyObject) { + guard tunnelsManager != nil else { return } + + let alert = UIAlertController(title: "", message: tr("addTunnelMenuHeader"), preferredStyle: .actionSheet) + let importFileAction = UIAlertAction(title: tr("addTunnelMenuImportFile"), style: .default) { [weak self] _ in + self?.presentViewControllerForFileImport() + } + alert.addAction(importFileAction) + + let scanQRCodeAction = UIAlertAction(title: tr("addTunnelMenuQRCode"), style: .default) { [weak self] _ in + self?.presentViewControllerForScanningQRCode() + } + alert.addAction(scanQRCodeAction) + + let createFromScratchAction = UIAlertAction(title: tr("addTunnelMenuFromScratch"), style: .default) { [weak self] _ in + if let self = self, let tunnelsManager = self.tunnelsManager { + self.presentViewControllerForTunnelCreation(tunnelsManager: tunnelsManager) + } + } + alert.addAction(createFromScratchAction) + + let cancelAction = UIAlertAction(title: tr("actionCancel"), style: .cancel) + alert.addAction(cancelAction) + + if let sender = sender as? UIBarButtonItem { + alert.popoverPresentationController?.barButtonItem = sender + } else if let sender = sender as? UIView { + alert.popoverPresentationController?.sourceView = sender + alert.popoverPresentationController?.sourceRect = sender.bounds + } + present(alert, animated: true, completion: nil) + } + + @objc func settingsButtonTapped(sender: UIBarButtonItem) { + guard tunnelsManager != nil else { return } + + let settingsVC = SettingsTableViewController(tunnelsManager: tunnelsManager) + let settingsNC = UINavigationController(rootViewController: settingsVC) + settingsNC.modalPresentationStyle = .formSheet + present(settingsNC, animated: true) + } + + func presentViewControllerForTunnelCreation(tunnelsManager: TunnelsManager) { + let editVC = TunnelEditTableViewController(tunnelsManager: tunnelsManager) + let editNC = UINavigationController(rootViewController: editVC) + editNC.modalPresentationStyle = .fullScreen + present(editNC, animated: true) + } + + func presentViewControllerForFileImport() { + let documentTypes = ["com.wireguard.config.quick", String(kUTTypeText), String(kUTTypeZipArchive)] + let filePicker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import) + filePicker.delegate = self + present(filePicker, animated: true) + } + + func presentViewControllerForScanningQRCode() { + let scanQRCodeVC = QRScanViewController() + scanQRCodeVC.delegate = self + let scanQRCodeNC = UINavigationController(rootViewController: scanQRCodeVC) + scanQRCodeNC.modalPresentationStyle = .fullScreen + present(scanQRCodeNC, animated: true) + } + + @objc func selectButtonTapped() { + let shouldCancelSwipe = tableState == .rowSwiped + tableState = .multiSelect(selectionCount: 0) + if shouldCancelSwipe { + tableView.setEditing(false, animated: false) + } + tableView.setEditing(true, animated: true) + } + + @objc func doneButtonTapped() { + tableState = .normal + tableView.setEditing(false, animated: true) + } + + @objc func selectAllButtonTapped() { + guard tableView.isEditing else { return } + guard let tunnelsManager = tunnelsManager else { return } + for index in 0 ..< tunnelsManager.numberOfTunnels() { + tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none) + } + tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0) + } + + @objc func cancelButtonTapped() { + tableState = .normal + tableView.setEditing(false, animated: true) + } + + @objc func deleteButtonTapped(sender: AnyObject?) { + guard let sender = sender as? UIBarButtonItem else { return } + guard let tunnelsManager = tunnelsManager else { return } + + let selectedTunnelIndices = tableView.indexPathsForSelectedRows?.map { $0.row } ?? [] + let selectedTunnels = selectedTunnelIndices.compactMap { tunnelIndex in + tunnelIndex >= 0 && tunnelIndex < tunnelsManager.numberOfTunnels() ? tunnelsManager.tunnel(at: tunnelIndex) : nil + } + guard !selectedTunnels.isEmpty else { return } + let message = selectedTunnels.count == 1 ? + tr(format: "deleteTunnelConfirmationAlertButtonMessage (%d)", selectedTunnels.count) : + tr(format: "deleteTunnelsConfirmationAlertButtonMessage (%d)", selectedTunnels.count) + let title = tr("deleteTunnelsConfirmationAlertButtonTitle") + ConfirmationAlertPresenter.showConfirmationAlert(message: message, buttonTitle: title, + from: sender, presentingVC: self) { [weak self] in + self?.tunnelsManager?.removeMultiple(tunnels: selectedTunnels) { [weak self] error in + guard let self = self else { return } + if let error = error { + ErrorPresenter.showErrorAlert(error: error, from: self) + return + } + self.tableState = .normal + self.tableView.setEditing(false, animated: true) + } + } + } + + func showTunnelDetail(for tunnel: TunnelContainer, animated: Bool) { + guard let tunnelsManager = tunnelsManager else { return } + guard let splitViewController = splitViewController else { return } + guard let navController = navigationController else { return } + + let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager, + tunnel: tunnel) + let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC) + tunnelDetailNC.restorationIdentifier = "DetailNC" + if splitViewController.isCollapsed && navController.viewControllers.count > 1 { + navController.setViewControllers([self, tunnelDetailNC], animated: animated) + } else { + splitViewController.showDetailViewController(tunnelDetailNC, sender: self, animated: animated) + } + detailDisplayedTunnel = tunnel + self.presentedViewController?.dismiss(animated: false, completion: nil) + } +} + +extension TunnelsListTableViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let tunnelsManager = tunnelsManager else { return } + TunnelImporter.importFromFile(urls: urls, into: tunnelsManager, sourceVC: self, errorPresenterType: ErrorPresenter.self) + } +} + +extension TunnelsListTableViewController: QRScanViewControllerDelegate { + func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController, + completionHandler: (() -> Void)?) { + tunnelsManager?.add(tunnelConfiguration: tunnelConfiguration) { result in + switch result { + case .failure(let error): + ErrorPresenter.showErrorAlert(error: error, from: qrScanViewController, onDismissal: completionHandler) + case .success: + completionHandler?() + } + } + } +} + +extension TunnelsListTableViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return (tunnelsManager?.numberOfTunnels() ?? 0) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: TunnelListCell = tableView.dequeueReusableCell(for: indexPath) + if let tunnelsManager = tunnelsManager { + let tunnel = tunnelsManager.tunnel(at: indexPath.row) + cell.tunnel = tunnel + cell.onSwitchToggled = { [weak self] isOn in + guard let self = self, let tunnelsManager = self.tunnelsManager else { return } + if isOn { + tunnelsManager.startActivation(of: tunnel) + } else { + tunnelsManager.startDeactivation(of: tunnel) + } + } + } + return cell + } +} + +extension TunnelsListTableViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard !tableView.isEditing else { + tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0) + return + } + guard let tunnelsManager = tunnelsManager else { return } + let tunnel = tunnelsManager.tunnel(at: indexPath.row) + showTunnelDetail(for: tunnel, animated: true) + } + + func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) { + guard !tableView.isEditing else { + tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0) + return + } + } + + func tableView(_ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let deleteAction = UIContextualAction(style: .destructive, title: tr("tunnelsListSwipeDeleteButtonTitle")) { [weak self] _, _, completionHandler in + guard let tunnelsManager = self?.tunnelsManager else { return } + let tunnel = tunnelsManager.tunnel(at: indexPath.row) + tunnelsManager.remove(tunnel: tunnel) { error in + if error != nil { + ErrorPresenter.showErrorAlert(error: error!, from: self) + completionHandler(false) + } else { + completionHandler(true) + } + } + } + return UISwipeActionsConfiguration(actions: [deleteAction]) + } + + func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) { + if tableState == .normal { + tableState = .rowSwiped + } + } + + func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) { + if tableState == .rowSwiped { + tableState = .normal + } + } +} + +extension TunnelsListTableViewController: TunnelsManagerListDelegate { + func tunnelAdded(at index: Int) { + tableView.insertRows(at: [IndexPath(row: index, section: 0)], with: .automatic) + centeredAddButton.isHidden = (tunnelsManager?.numberOfTunnels() ?? 0 > 0) + } + + func tunnelModified(at index: Int) { + tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .automatic) + } + + func tunnelMoved(from oldIndex: Int, to newIndex: Int) { + tableView.moveRow(at: IndexPath(row: oldIndex, section: 0), to: IndexPath(row: newIndex, section: 0)) + } + + func tunnelRemoved(at index: Int, tunnel: TunnelContainer) { + tableView.deleteRows(at: [IndexPath(row: index, section: 0)], with: .automatic) + centeredAddButton.isHidden = tunnelsManager?.numberOfTunnels() ?? 0 > 0 + if detailDisplayedTunnel == tunnel, let splitViewController = splitViewController { + if splitViewController.isCollapsed != false { + (splitViewController.viewControllers[0] as? UINavigationController)?.popToRootViewController(animated: false) + } else { + let detailVC = UIViewController() + if #available(iOS 13.0, *) { + detailVC.view.backgroundColor = .systemBackground + } else { + detailVC.view.backgroundColor = .white + } + let detailNC = UINavigationController(rootViewController: detailVC) + splitViewController.showDetailViewController(detailNC, sender: self) + } + detailDisplayedTunnel = nil + if let presentedNavController = self.presentedViewController as? UINavigationController, presentedNavController.viewControllers.first is TunnelEditTableViewController { + self.presentedViewController?.dismiss(animated: false, completion: nil) + } + } + } +} + +extension UISplitViewController { + func showDetailViewController(_ vc: UIViewController, sender: Any?, animated: Bool) { + if animated { + showDetailViewController(vc, sender: sender) + } else { + UIView.performWithoutAnimation { + showDetailViewController(vc, sender: sender) + } + } + } +} diff --git a/Sources/WireGuardApp/UI/iOS/WireGuard.entitlements b/Sources/WireGuardApp/UI/iOS/WireGuard.entitlements new file mode 100644 index 0000000..93c7249 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/WireGuard.entitlements @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.developer.networking.networkextension</key> + <array> + <string>packet-tunnel-provider</string> + </array> + <key>com.apple.developer.networking.wifi-info</key> + <true/> + <key>com.apple.security.application-groups</key> + <array> + <string>group.$(APP_ID_IOS)</string> + </array> +</dict> +</plist> diff --git a/Sources/WireGuardApp/UI/macOS/AppDelegate.swift b/Sources/WireGuardApp/UI/macOS/AppDelegate.swift new file mode 100644 index 0000000..6e3783a --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/AppDelegate.swift @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa +import ServiceManagement +import WireGuardKit + +@NSApplicationMain +class AppDelegate: NSObject, NSApplicationDelegate { + + var tunnelsManager: TunnelsManager? + var tunnelsTracker: TunnelsTracker? + var statusItemController: StatusItemController? + + var manageTunnelsRootVC: ManageTunnelsRootViewController? + var manageTunnelsWindowObject: NSWindow? + var onAppDeactivation: (() -> Void)? + + func applicationWillFinishLaunching(_ notification: Notification) { + // To workaround a possible AppKit bug that causes the main menu to become unresponsive sometimes + // (especially when launched through Xcode) if we call setActivationPolicy(.regular) in + // in applicationDidFinishLaunching, we set it to .prohibited here. + // Setting it to .regular would fix that problem too, but at this point, we don't know + // whether the app was launched at login or not, so we're not sure whether we should + // show the app icon in the dock or not. + NSApp.setActivationPolicy(.prohibited) + } + + func applicationDidFinishLaunching(_ aNotification: Notification) { + Logger.configureGlobal(tagged: "APP", withFilePath: FileManager.logFileURL?.path) + registerLoginItem(shouldLaunchAtLogin: true) + + var isLaunchedAtLogin = false + if let appleEvent = NSAppleEventManager.shared().currentAppleEvent { + isLaunchedAtLogin = LaunchedAtLoginDetector.isLaunchedAtLogin(openAppleEvent: appleEvent) + } + + NSApp.mainMenu = MainMenu() + setDockIconAndMainMenuVisibility(isVisible: !isLaunchedAtLogin) + + TunnelsManager.create { [weak self] result in + guard let self = self else { return } + + switch result { + case .failure(let error): + ErrorPresenter.showErrorAlert(error: error, from: nil) + case .success(let tunnelsManager): + let statusMenu = StatusMenu(tunnelsManager: tunnelsManager) + statusMenu.windowDelegate = self + + let statusItemController = StatusItemController() + statusItemController.statusItem.menu = statusMenu + + let tunnelsTracker = TunnelsTracker(tunnelsManager: tunnelsManager) + tunnelsTracker.statusMenu = statusMenu + tunnelsTracker.statusItemController = statusItemController + + self.tunnelsManager = tunnelsManager + self.tunnelsTracker = tunnelsTracker + self.statusItemController = statusItemController + + if !isLaunchedAtLogin { + self.showManageTunnelsWindow(completion: nil) + } + } + } + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { + if let appleEvent = NSAppleEventManager.shared().currentAppleEvent { + if LaunchedAtLoginDetector.isReopenedByLoginItemHelper(reopenAppleEvent: appleEvent) { + return false + } + } + if hasVisibleWindows { + return true + } + showManageTunnelsWindow(completion: nil) + return false + } + + @objc func confirmAndQuit() { + let alert = NSAlert() + alert.messageText = tr("macConfirmAndQuitAlertMessage") + if let currentTunnel = tunnelsTracker?.currentTunnel, currentTunnel.status == .active || currentTunnel.status == .activating { + alert.informativeText = tr(format: "macConfirmAndQuitInfoWithActiveTunnel (%@)", currentTunnel.name) + } else { + alert.informativeText = tr("macConfirmAndQuitAlertInfo") + } + alert.addButton(withTitle: tr("macConfirmAndQuitAlertCloseWindow")) + alert.addButton(withTitle: tr("macConfirmAndQuitAlertQuitWireGuard")) + + NSApp.activate(ignoringOtherApps: true) + if let manageWindow = manageTunnelsWindowObject { + manageWindow.orderFront(self) + alert.beginSheetModal(for: manageWindow) { response in + switch response { + case .alertFirstButtonReturn: + manageWindow.close() + case .alertSecondButtonReturn: + NSApp.terminate(nil) + default: + break + } + } + } + } + + @objc func quit() { + if let manageWindow = manageTunnelsWindowObject, manageWindow.attachedSheet != nil { + NSApp.activate(ignoringOtherApps: true) + manageWindow.orderFront(self) + return + } + registerLoginItem(shouldLaunchAtLogin: false) + guard let currentTunnel = tunnelsTracker?.currentTunnel, currentTunnel.status == .active || currentTunnel.status == .activating else { + NSApp.terminate(nil) + return + } + let alert = NSAlert() + alert.messageText = tr("macAppExitingWithActiveTunnelMessage") + alert.informativeText = tr("macAppExitingWithActiveTunnelInfo") + NSApp.activate(ignoringOtherApps: true) + if let manageWindow = manageTunnelsWindowObject { + manageWindow.orderFront(self) + alert.beginSheetModal(for: manageWindow) { _ in + NSApp.terminate(nil) + } + } else { + alert.runModal() + NSApp.terminate(nil) + } + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard let currentTunnel = tunnelsTracker?.currentTunnel, currentTunnel.status == .active || currentTunnel.status == .activating else { + return .terminateNow + } + guard let appleEvent = NSAppleEventManager.shared().currentAppleEvent else { + return .terminateNow + } + guard MacAppStoreUpdateDetector.isUpdatingFromMacAppStore(quitAppleEvent: appleEvent) else { + return .terminateNow + } + let alert = NSAlert() + alert.messageText = tr("macAppStoreUpdatingAlertMessage") + if currentTunnel.isActivateOnDemandEnabled { + alert.informativeText = tr(format: "macAppStoreUpdatingAlertInfoWithOnDemand (%@)", currentTunnel.name) + } else { + alert.informativeText = tr(format: "macAppStoreUpdatingAlertInfoWithoutOnDemand (%@)", currentTunnel.name) + } + NSApp.activate(ignoringOtherApps: true) + if let manageWindow = manageTunnelsWindowObject { + alert.beginSheetModal(for: manageWindow) { _ in } + } else { + alert.runModal() + } + return .terminateCancel + } + + func applicationShouldTerminateAfterLastWindowClosed(_ application: NSApplication) -> Bool { + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak self] in + self?.setDockIconAndMainMenuVisibility(isVisible: false) + } + return false + } + + private func setDockIconAndMainMenuVisibility(isVisible: Bool, completion: (() -> Void)? = nil) { + let currentActivationPolicy = NSApp.activationPolicy() + let newActivationPolicy: NSApplication.ActivationPolicy = isVisible ? .regular : .accessory + guard currentActivationPolicy != newActivationPolicy else { + if newActivationPolicy == .regular { + NSApp.activate(ignoringOtherApps: true) + } + completion?() + return + } + if newActivationPolicy == .regular && NSApp.isActive { + // To workaround a possible AppKit bug that causes the main menu to become unresponsive, + // we should deactivate the app first and then set the activation policy. + // NSApp.deactivate() doesn't always deactivate the app, so we instead use + // setActivationPolicy(.prohibited). + onAppDeactivation = { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + completion?() + } + NSApp.setActivationPolicy(.prohibited) + } else { + NSApp.setActivationPolicy(newActivationPolicy) + if newActivationPolicy == .regular { + NSApp.activate(ignoringOtherApps: true) + } + completion?() + } + } + + func applicationDidResignActive(_ notification: Notification) { + onAppDeactivation?() + onAppDeactivation = nil + } +} + +extension AppDelegate { + @objc func aboutClicked() { + var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + if let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String { + appVersion += " (\(appBuild))" + } + let appVersionString = [ + tr(format: "macAppVersion (%@)", appVersion), + tr(format: "macGoBackendVersion (%@)", wireGuardVersion) + ].joined(separator: "\n") + let donateString = NSMutableAttributedString(string: tr("donateLink")) + donateString.addAttribute(.link, value: "https://www.wireguard.com/donations/", range: NSRange(location: 0, length: donateString.length)) + NSApp.activate(ignoringOtherApps: true) + NSApp.orderFrontStandardAboutPanel(options: [ + .applicationVersion: appVersionString, + .version: "", + .credits: donateString + ]) + } +} + +extension AppDelegate: StatusMenuWindowDelegate { + func showManageTunnelsWindow(completion: ((NSWindow?) -> Void)?) { + guard let tunnelsManager = tunnelsManager else { + completion?(nil) + return + } + if manageTunnelsWindowObject == nil { + manageTunnelsRootVC = ManageTunnelsRootViewController(tunnelsManager: tunnelsManager) + let window = NSWindow(contentViewController: manageTunnelsRootVC!) + window.title = tr("macWindowTitleManageTunnels") + window.setContentSize(NSSize(width: 800, height: 480)) + window.setFrameAutosaveName(NSWindow.FrameAutosaveName("ManageTunnelsWindow")) // Auto-save window position and size + manageTunnelsWindowObject = window + tunnelsTracker?.manageTunnelsRootVC = manageTunnelsRootVC + } + setDockIconAndMainMenuVisibility(isVisible: true) { [weak manageTunnelsWindowObject] in + manageTunnelsWindowObject?.makeKeyAndOrderFront(self) + completion?(manageTunnelsWindowObject) + } + } +} + +@discardableResult +func registerLoginItem(shouldLaunchAtLogin: Bool) -> Bool { + let appId = Bundle.main.bundleIdentifier! + let helperBundleId = "\(appId).login-item-helper" + return SMLoginItemSetEnabled(helperBundleId as CFString, shouldLaunchAtLogin) +} diff --git a/Sources/WireGuardApp/UI/macOS/Application.swift b/Sources/WireGuardApp/UI/macOS/Application.swift new file mode 100644 index 0000000..0ce274a --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Application.swift @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +class Application: NSApplication { + + private var appDelegate: AppDelegate? //swiftlint:disable:this weak_delegate + + override init() { + super.init() + appDelegate = AppDelegate() // Keep a strong reference to the app delegate + delegate = appDelegate // Set delegate before app.run() gets called in NSApplicationMain() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..32ea528 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "WireGuardMacAppIcon16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "WireGuardMacAppIcon32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "WireGuardMacAppIcon32-1.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "WireGuardMacAppIcon64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "WireGuardMacAppIcon128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "WireGuardMacAppIcon256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "WireGuardMacAppIcon256-1.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "WireGuardMacAppIcon512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "WireGuardMacAppIcon512-1.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "WireGuardMacAppIcon.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +}
\ No newline at end of file diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon.png Binary files differnew file mode 100644 index 0000000..83c4701 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon128.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon128.png Binary files differnew file mode 100644 index 0000000..16f9056 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon128.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon16.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon16.png Binary files differnew file mode 100644 index 0000000..e05c706 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon16.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256-1.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256-1.png Binary files differnew file mode 100644 index 0000000..ac09bdd --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256-1.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256.png Binary files differnew file mode 100644 index 0000000..ac09bdd --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon256.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32-1.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32-1.png Binary files differnew file mode 100644 index 0000000..edf0ca5 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32-1.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32.png Binary files differnew file mode 100644 index 0000000..edf0ca5 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon32.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512-1.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512-1.png Binary files differnew file mode 100644 index 0000000..54b28ff --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512-1.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512.png Binary files differnew file mode 100644 index 0000000..54b28ff --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon512.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon64.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon64.png Binary files differnew file mode 100644 index 0000000..98441a8 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/AppIcon.appiconset/WireGuardMacAppIcon64.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/Contents.json b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/Contents.json new file mode 100644 index 0000000..da4a164 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + } +}
\ No newline at end of file diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/Contents.json b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/Contents.json new file mode 100644 index 0000000..a8cd607 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "StatusBarIcon@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "StatusBarIcon@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "StatusBarIcon@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@1x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@1x.png Binary files differnew file mode 100644 index 0000000..c0a43e7 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@1x.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@2x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@2x.png Binary files differnew file mode 100644 index 0000000..2057c31 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@2x.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@3x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@3x.png Binary files differnew file mode 100644 index 0000000..60cc363 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIcon.imageset/StatusBarIcon@3x.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/Contents.json b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/Contents.json new file mode 100644 index 0000000..f9e2cd6 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "StatusBarIconDimmed@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "StatusBarIconDimmed@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "StatusBarIconDimmed@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@1x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@1x.png Binary files differnew file mode 100644 index 0000000..fb9d8f7 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@1x.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@2x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@2x.png Binary files differnew file mode 100644 index 0000000..2f4e613 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@2x.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@3x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@3x.png Binary files differnew file mode 100644 index 0000000..cc5ead9 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDimmed.imageset/StatusBarIconDimmed@3x.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/Contents.json b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/Contents.json new file mode 100644 index 0000000..15384b9 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "StatusBarIconDot1@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "StatusBarIconDot1@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "StatusBarIconDot1@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@1x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@1x.png Binary files differnew file mode 100644 index 0000000..bbbe0c3 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@1x.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@2x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@2x.png Binary files differnew file mode 100644 index 0000000..01b5eb3 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@2x.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@3x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@3x.png Binary files differnew file mode 100644 index 0000000..76afa15 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot1.imageset/StatusBarIconDot1@3x.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/Contents.json b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/Contents.json new file mode 100644 index 0000000..f728363 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "StatusBarIconDot2@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "StatusBarIconDot2@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "StatusBarIconDot2@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@1x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@1x.png Binary files differnew file mode 100644 index 0000000..a16143f --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@1x.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@2x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@2x.png Binary files differnew file mode 100644 index 0000000..ce00482 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@2x.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@3x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@3x.png Binary files differnew file mode 100644 index 0000000..82640f7 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot2.imageset/StatusBarIconDot2@3x.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/Contents.json b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/Contents.json new file mode 100644 index 0000000..eeaa5d1 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/Contents.json @@ -0,0 +1,26 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "StatusBarIconDot3@1x.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "StatusBarIconDot3@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "StatusBarIconDot3@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@1x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@1x.png Binary files differnew file mode 100644 index 0000000..80e221b --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@1x.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@2x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@2x.png Binary files differnew file mode 100644 index 0000000..663f92b --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@2x.png diff --git a/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@3x.png b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@3x.png Binary files differnew file mode 100644 index 0000000..10e43d9 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Assets.xcassets/StatusBarIconDot3.imageset/StatusBarIconDot3@3x.png diff --git a/Sources/WireGuardApp/UI/macOS/ErrorPresenter.swift b/Sources/WireGuardApp/UI/macOS/ErrorPresenter.swift new file mode 100644 index 0000000..1eb2b04 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/ErrorPresenter.swift @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +class ErrorPresenter: ErrorPresenterProtocol { + static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?, onPresented: (() -> Void)?, onDismissal: (() -> Void)?) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + onPresented?() + if let sourceVC = sourceVC as? NSViewController { + NSApp.activate(ignoringOtherApps: true) + sourceVC.view.window!.makeKeyAndOrderFront(nil) + alert.beginSheetModal(for: sourceVC.view.window!) { _ in + onDismissal?() + } + } else { + alert.runModal() + onDismissal?() + } + } +} diff --git a/Sources/WireGuardApp/UI/macOS/ImportPanelPresenter.swift b/Sources/WireGuardApp/UI/macOS/ImportPanelPresenter.swift new file mode 100644 index 0000000..d081f8c --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/ImportPanelPresenter.swift @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +class ImportPanelPresenter { + static func presentImportPanel(tunnelsManager: TunnelsManager, sourceVC: NSViewController?) { + guard let window = sourceVC?.view.window else { return } + let openPanel = NSOpenPanel() + openPanel.prompt = tr("macSheetButtonImport") + openPanel.allowedFileTypes = ["conf", "zip"] + openPanel.allowsMultipleSelection = true + openPanel.beginSheetModal(for: window) { [weak tunnelsManager] response in + guard let tunnelsManager = tunnelsManager else { return } + guard response == .OK else { return } + TunnelImporter.importFromFile(urls: openPanel.urls, into: tunnelsManager, sourceVC: sourceVC, errorPresenterType: ErrorPresenter.self) + } + } +} diff --git a/Sources/WireGuardApp/UI/macOS/Info.plist b/Sources/WireGuardApp/UI/macOS/Info.plist new file mode 100644 index 0000000..db1afeb --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/Info.plist @@ -0,0 +1,40 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>ITSAppUsesNonExemptEncryption</key> + <false/> + <key>LSMultipleInstancesProhibited</key> + <true/> + <key>CFBundleDevelopmentRegion</key> + <string>$(DEVELOPMENT_LANGUAGE)</string> + <key>CFBundleExecutable</key> + <string>$(EXECUTABLE_NAME)</string> + <key>CFBundleIconFile</key> + <string></string> + <key>CFBundleIdentifier</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleName</key> + <string>$(PRODUCT_NAME)</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleShortVersionString</key> + <string>$(VERSION_NAME)</string> + <key>CFBundleVersion</key> + <string>$(VERSION_ID)</string> + <key>LSMinimumSystemVersion</key> + <string>$(MACOSX_DEPLOYMENT_TARGET)</string> + <key>LSUIElement</key> + <true/> + <key>NSHumanReadableCopyright</key> + <string>Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.</string> + <key>NSPrincipalClass</key> + <string>WireGuard.Application</string> + <key>LSApplicationCategoryType</key> + <string>public.app-category.utilities</string> + <key>com.wireguard.macos.app_group_id</key> + <string>$(DEVELOPMENT_TEAM).group.$(APP_ID_MACOS)</string> +</dict> +</plist> diff --git a/Sources/WireGuardApp/UI/macOS/LaunchedAtLoginDetector.swift b/Sources/WireGuardApp/UI/macOS/LaunchedAtLoginDetector.swift new file mode 100644 index 0000000..0d8e3d8 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/LaunchedAtLoginDetector.swift @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +class LaunchedAtLoginDetector { + static let launchCode = "LaunchedByWireGuardLoginItemHelper" + + static func isLaunchedAtLogin(openAppleEvent: NSAppleEventDescriptor) -> Bool { + guard isOpenEvent(openAppleEvent) else { return false } + guard let propData = openAppleEvent.paramDescriptor(forKeyword: keyAEPropData) else { return false } + return propData.stringValue == launchCode + } + + static func isReopenedByLoginItemHelper(reopenAppleEvent: NSAppleEventDescriptor) -> Bool { + guard isReopenEvent(reopenAppleEvent) else { return false } + guard let propData = reopenAppleEvent.paramDescriptor(forKeyword: keyAEPropData) else { return false } + return propData.stringValue == launchCode + } +} + +private func isOpenEvent(_ event: NSAppleEventDescriptor) -> Bool { + return event.eventClass == kCoreEventClass && event.eventID == kAEOpenApplication +} + +private func isReopenEvent(_ event: NSAppleEventDescriptor) -> Bool { + return event.eventClass == kCoreEventClass && event.eventID == kAEReopenApplication +} diff --git a/Sources/WireGuardApp/UI/macOS/LoginItemHelper/Info.plist b/Sources/WireGuardApp/UI/macOS/LoginItemHelper/Info.plist new file mode 100644 index 0000000..7ddff91 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/LoginItemHelper/Info.plist @@ -0,0 +1,36 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>ITSAppUsesNonExemptEncryption</key> + <false/> + <key>CFBundleDevelopmentRegion</key> + <string>$(DEVELOPMENT_LANGUAGE)</string> + <key>CFBundleExecutable</key> + <string>$(EXECUTABLE_NAME)</string> + <key>CFBundleIconFile</key> + <string></string> + <key>CFBundleIdentifier</key> + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> + <key>CFBundleInfoDictionaryVersion</key> + <string>6.0</string> + <key>CFBundleName</key> + <string>$(PRODUCT_NAME)</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + <key>CFBundleShortVersionString</key> + <string>$(VERSION_NAME)</string> + <key>CFBundleVersion</key> + <string>$(VERSION_ID)</string> + <key>LSMinimumSystemVersion</key> + <string>$(MACOSX_DEPLOYMENT_TARGET)</string> + <key>NSHumanReadableCopyright</key> + <string>Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.</string> + <key>NSPrincipalClass</key> + <string>NSApplication</string> + <key>LSBackgroundOnly</key> + <true/> + <key>com.wireguard.macos.app_id</key> + <string>$(APP_ID_MACOS)</string> +</dict> +</plist> diff --git a/Sources/WireGuardApp/UI/macOS/LoginItemHelper/LoginItemHelper.entitlements b/Sources/WireGuardApp/UI/macOS/LoginItemHelper/LoginItemHelper.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/LoginItemHelper/LoginItemHelper.entitlements @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.security.app-sandbox</key> + <true/> +</dict> +</plist> diff --git a/Sources/WireGuardApp/UI/macOS/LoginItemHelper/main.m b/Sources/WireGuardApp/UI/macOS/LoginItemHelper/main.m new file mode 100644 index 0000000..1010b49 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/LoginItemHelper/main.m @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +#import <Cocoa/Cocoa.h> + +int main(int argc, char *argv[]) +{ + NSString *appIdInfoDictionaryKey = @"com.wireguard.macos.app_id"; + NSString *appId = [NSBundle.mainBundle objectForInfoDictionaryKey:appIdInfoDictionaryKey]; + + NSString *launchCode = @"LaunchedByWireGuardLoginItemHelper"; + NSAppleEventDescriptor *paramDescriptor = [NSAppleEventDescriptor descriptorWithString:launchCode]; + + [NSWorkspace.sharedWorkspace launchAppWithBundleIdentifier:appId options:NSWorkspaceLaunchWithoutActivation + additionalEventParamDescriptor:paramDescriptor launchIdentifier:NULL]; + return 0; +} diff --git a/Sources/WireGuardApp/UI/macOS/MacAppStoreUpdateDetector.swift b/Sources/WireGuardApp/UI/macOS/MacAppStoreUpdateDetector.swift new file mode 100644 index 0000000..68608ca --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/MacAppStoreUpdateDetector.swift @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +class MacAppStoreUpdateDetector { + static func isUpdatingFromMacAppStore(quitAppleEvent: NSAppleEventDescriptor) -> Bool { + guard isQuitEvent(quitAppleEvent) else { return false } + guard let senderPIDDescriptor = quitAppleEvent.attributeDescriptor(forKeyword: keySenderPIDAttr) else { return false } + let pid = senderPIDDescriptor.int32Value + guard let executablePath = getExecutablePath(from: pid) else { return false } + wg_log(.debug, message: "aevt/quit Apple event received from: \(executablePath)") + if executablePath.hasPrefix("/System/Library/") { + let executableName = URL(fileURLWithPath: executablePath, isDirectory: false).lastPathComponent + return executableName == "com.apple.CommerceKit.StoreAEService" + } + return false + } +} + +private func isQuitEvent(_ event: NSAppleEventDescriptor) -> Bool { + return event.eventClass == kCoreEventClass && event.eventID == kAEQuitApplication +} + +private func getExecutablePath(from pid: pid_t) -> String? { + let bufferSize = Int(PATH_MAX) + var buffer = Data(capacity: bufferSize) + return buffer.withUnsafeMutableBytes { (ptr: UnsafeMutableRawBufferPointer) -> String? in + if let basePtr = ptr.baseAddress { + let byteCount = proc_pidpath(pid, basePtr, UInt32(bufferSize)) + return byteCount > 0 ? String(cString: basePtr.bindMemory(to: CChar.self, capacity: bufferSize)) : nil + } + return nil + } +} diff --git a/Sources/WireGuardApp/UI/macOS/MainMenu.swift b/Sources/WireGuardApp/UI/macOS/MainMenu.swift new file mode 100644 index 0000000..92fca4b --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/MainMenu.swift @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import Cocoa + +// swiftlint:disable colon + +class MainMenu: NSMenu { + init() { + super.init(title: "") + addSubmenu(createApplicationMenu()) + addSubmenu(createFileMenu()) + addSubmenu(createEditMenu()) + addSubmenu(createTunnelMenu()) + addSubmenu(createWindowMenu()) + } + + required init(coder decoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func addSubmenu(_ menu: NSMenu) { + let menuItem = self.addItem(withTitle: "", action: nil, keyEquivalent: "") + self.setSubmenu(menu, for: menuItem) + } + + private func createApplicationMenu() -> NSMenu { + let menu = NSMenu() + + let aboutMenuItem = menu.addItem(withTitle: tr("macMenuAbout"), + action: #selector(AppDelegate.aboutClicked), keyEquivalent: "") + aboutMenuItem.target = NSApp.delegate + + menu.addItem(NSMenuItem.separator()) + + menu.addItem(withTitle: tr("macMenuViewLog"), + action: #selector(TunnelsListTableViewController.handleViewLogAction), keyEquivalent: "") + + menu.addItem(NSMenuItem.separator()) + + let hideMenuItem = menu.addItem(withTitle: tr("macMenuHideApp"), + action: #selector(NSApplication.hide), keyEquivalent: "h") + hideMenuItem.target = NSApp + let hideOthersMenuItem = menu.addItem(withTitle: tr("macMenuHideOtherApps"), + action: #selector(NSApplication.hideOtherApplications), keyEquivalent: "h") + hideOthersMenuItem.keyEquivalentModifierMask = [.command, .option] + hideOthersMenuItem.target = NSApp + let showAllMenuItem = menu.addItem(withTitle: tr("macMenuShowAllApps"), + action: #selector(NSApplication.unhideAllApplications), keyEquivalent: "") + showAllMenuItem.target = NSApp + + menu.addItem(NSMenuItem.separator()) + + menu.addItem(withTitle: tr("macMenuQuit"), + action: #selector(AppDelegate.confirmAndQuit), keyEquivalent: "q") + + return menu + } + + private func createFileMenu() -> NSMenu { + let menu = NSMenu(title: tr("macMenuFile")) + + menu.addItem(withTitle: tr("macMenuAddEmptyTunnel"), + action: #selector(TunnelsListTableViewController.handleAddEmptyTunnelAction), keyEquivalent: "n") + menu.addItem(withTitle: tr("macMenuImportTunnels"), + action: #selector(TunnelsListTableViewController.handleImportTunnelAction), keyEquivalent: "o") + + menu.addItem(NSMenuItem.separator()) + + menu.addItem(withTitle: tr("macMenuExportTunnels"), + action: #selector(TunnelsListTableViewController.handleExportTunnelsAction), keyEquivalent: "") + + menu.addItem(NSMenuItem.separator()) + + menu.addItem(withTitle: tr("macMenuCloseWindow"), action: #selector(NSWindow.performClose(_:)), keyEquivalent:"w") + + return menu + } + + private func createEditMenu() -> NSMenu { + let menu = NSMenu(title: tr("macMenuEdit")) + + menu.addItem(withTitle: "", action: #selector(UndoActionRespondable.undo(_:)), keyEquivalent:"z") + menu.addItem(withTitle: "", action: #selector(UndoActionRespondable.redo(_:)), keyEquivalent:"Z") + + menu.addItem(NSMenuItem.separator()) + + menu.addItem(withTitle: tr("macMenuCut"), action: #selector(NSText.cut(_:)), keyEquivalent:"x") + menu.addItem(withTitle: tr("macMenuCopy"), action: #selector(NSText.copy(_:)), keyEquivalent:"c") + menu.addItem(withTitle: tr("macMenuPaste"), action: #selector(NSText.paste(_:)), keyEquivalent:"v") + + menu.addItem(NSMenuItem.separator()) + + menu.addItem(withTitle: tr("macMenuSelectAll"), action: #selector(NSText.selectAll(_:)), keyEquivalent:"a") + + return menu + } + + private func createTunnelMenu() -> NSMenu { + let menu = NSMenu(title: tr("macMenuTunnel")) + + menu.addItem(withTitle: tr("macMenuToggleStatus"), action: #selector(TunnelDetailTableViewController.handleToggleActiveStatusAction), keyEquivalent:"t") + + menu.addItem(NSMenuItem.separator()) + + menu.addItem(withTitle: tr("macMenuEditTunnel"), action: #selector(TunnelDetailTableViewController.handleEditTunnelAction), keyEquivalent:"e") + menu.addItem(withTitle: tr("macMenuDeleteSelected"), action: #selector(TunnelsListTableViewController.handleRemoveTunnelAction), keyEquivalent: "") + + return menu + } + + private func createWindowMenu() -> NSMenu { + let menu = NSMenu(title: tr("macMenuWindow")) + + menu.addItem(withTitle: tr("macMenuMinimize"), action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent:"m") + menu.addItem(withTitle: tr("macMenuZoom"), action: #selector(NSWindow.performZoom(_:)), keyEquivalent:"") + + menu.addItem(NSMenuItem.separator()) + + let fullScreenMenuItem = menu.addItem(withTitle: "", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent:"f") + fullScreenMenuItem.keyEquivalentModifierMask = [.command, .control] + + return menu + } +} + +@objc protocol UndoActionRespondable { + func undo(_ sender: AnyObject) + func redo(_ sender: AnyObject) +} diff --git a/Sources/WireGuardApp/UI/macOS/NSColor+Hex.swift b/Sources/WireGuardApp/UI/macOS/NSColor+Hex.swift new file mode 100644 index 0000000..4ca4f05 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/NSColor+Hex.swift @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import AppKit + +extension NSColor { + + convenience init(hex: String) { + var hexString = hex.uppercased() + + if hexString.hasPrefix("#") { + hexString.remove(at: hexString.startIndex) + } + + if hexString.count != 6 { + fatalError("Invalid hex string \(hex)") + } + + var rgb: UInt32 = 0 + Scanner(string: hexString).scanHexInt32(&rgb) + + self.init(red: CGFloat((rgb >> 16) & 0xff) / 255.0, green: CGFloat((rgb >> 8) & 0xff) / 255.0, blue: CGFloat((rgb >> 0) & 0xff) / 255.0, alpha: 1) + } + +} diff --git a/Sources/WireGuardApp/UI/macOS/NSTableView+Reuse.swift b/Sources/WireGuardApp/UI/macOS/NSTableView+Reuse.swift new file mode 100644 index 0000000..979b123 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/NSTableView+Reuse.swift @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +extension NSTableView { + func dequeueReusableCell<T: NSView>() -> T { + let identifier = NSUserInterfaceItemIdentifier(NSStringFromClass(T.self)) + if let cellView = makeView(withIdentifier: identifier, owner: self) { + //swiftlint:disable:next force_cast + return cellView as! T + } + let cellView = T() + cellView.identifier = identifier + return cellView + } +} diff --git a/Sources/WireGuardApp/UI/macOS/ParseError+WireGuardAppError.swift b/Sources/WireGuardApp/UI/macOS/ParseError+WireGuardAppError.swift new file mode 100644 index 0000000..8622510 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/ParseError+WireGuardAppError.swift @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa +import WireGuardKit + +// We have this in a separate file because we don't want the network extension +// code to see WireGuardAppError and tr(). Also, this extension is used only on macOS. + +extension TunnelConfiguration.ParseError: WireGuardAppError { + var alertText: AlertText { + switch self { + case .invalidLine(let line): + return (tr(format: "macAlertInvalidLine (%@)", String(line)), "") + case .noInterface: + return (tr("macAlertNoInterface"), "") + case .multipleInterfaces: + return (tr("macAlertMultipleInterfaces"), "") + case .interfaceHasNoPrivateKey: + return (tr("alertInvalidInterfaceMessagePrivateKeyRequired"), tr("alertInvalidInterfaceMessagePrivateKeyInvalid")) + case .interfaceHasInvalidPrivateKey: + return (tr("macAlertPrivateKeyInvalid"), tr("alertInvalidInterfaceMessagePrivateKeyInvalid")) + case .interfaceHasInvalidListenPort(let value): + return (tr(format: "macAlertListenPortInvalid (%@)", value), tr("alertInvalidInterfaceMessageListenPortInvalid")) + case .interfaceHasInvalidAddress(let value): + return (tr(format: "macAlertAddressInvalid (%@)", value), tr("alertInvalidInterfaceMessageAddressInvalid")) + case .interfaceHasInvalidDNS(let value): + return (tr(format: "macAlertDNSInvalid (%@)", value), tr("alertInvalidInterfaceMessageDNSInvalid")) + case .interfaceHasInvalidMTU(let value): + return (tr(format: "macAlertMTUInvalid (%@)", value), tr("alertInvalidInterfaceMessageMTUInvalid")) + case .interfaceHasUnrecognizedKey(let value): + return (tr(format: "macAlertUnrecognizedInterfaceKey (%@)", value), tr("macAlertInfoUnrecognizedInterfaceKey")) + case .peerHasNoPublicKey: + return (tr("alertInvalidPeerMessagePublicKeyRequired"), tr("alertInvalidPeerMessagePublicKeyInvalid")) + case .peerHasInvalidPublicKey: + return (tr("macAlertPublicKeyInvalid"), tr("alertInvalidPeerMessagePublicKeyInvalid")) + case .peerHasInvalidPreSharedKey: + return (tr("macAlertPreSharedKeyInvalid"), tr("alertInvalidPeerMessagePreSharedKeyInvalid")) + case .peerHasInvalidAllowedIP(let value): + return (tr(format: "macAlertAllowedIPInvalid (%@)", value), tr("alertInvalidPeerMessageAllowedIPsInvalid")) + case .peerHasInvalidEndpoint(let value): + return (tr(format: "macAlertEndpointInvalid (%@)", value), tr("alertInvalidPeerMessageEndpointInvalid")) + case .peerHasInvalidPersistentKeepAlive(let value): + return (tr(format: "macAlertPersistentKeepliveInvalid (%@)", value), tr("alertInvalidPeerMessagePersistentKeepaliveInvalid")) + case .peerHasUnrecognizedKey(let value): + return (tr(format: "macAlertUnrecognizedPeerKey (%@)", value), tr("macAlertInfoUnrecognizedPeerKey")) + case .peerHasInvalidTransferBytes(let line): + return (tr(format: "macAlertInvalidLine (%@)", String(line)), "") + case .peerHasInvalidLastHandshakeTime(let line): + return (tr(format: "macAlertInvalidLine (%@)", String(line)), "") + case .multiplePeersWithSamePublicKey: + return (tr("alertInvalidPeerMessagePublicKeyDuplicated"), "") + case .multipleEntriesForKey(let value): + return (tr(format: "macAlertMultipleEntriesForKey (%@)", value), "") + } + } +} diff --git a/Sources/WireGuardApp/UI/macOS/StatusItemController.swift b/Sources/WireGuardApp/UI/macOS/StatusItemController.swift new file mode 100644 index 0000000..ef0ccd0 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/StatusItemController.swift @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +class StatusItemController { + var currentTunnel: TunnelContainer? { + didSet { + updateStatusItemImage() + } + } + + let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + private let statusBarImageWhenActive = NSImage(named: "StatusBarIcon")! + private let statusBarImageWhenInactive = NSImage(named: "StatusBarIconDimmed")! + + private let animationImages = [ + NSImage(named: "StatusBarIconDot1")!, + NSImage(named: "StatusBarIconDot2")!, + NSImage(named: "StatusBarIconDot3")! + ] + private var animationImageIndex: Int = 0 + private var animationTimer: Timer? + + init() { + updateStatusItemImage() + } + + func updateStatusItemImage() { + guard let currentTunnel = currentTunnel else { + stopActivatingAnimation() + statusItem.button?.image = statusBarImageWhenInactive + return + } + switch currentTunnel.status { + case .inactive: + stopActivatingAnimation() + statusItem.button?.image = statusBarImageWhenInactive + case .active: + stopActivatingAnimation() + statusItem.button?.image = statusBarImageWhenActive + case .activating, .waiting, .reasserting, .restarting, .deactivating: + startActivatingAnimation() + } + } + + func startActivatingAnimation() { + guard animationTimer == nil else { return } + let timer = Timer(timeInterval: 0.3, repeats: true) { [weak self] _ in + guard let self = self else { return } + self.statusItem.button?.image = self.animationImages[self.animationImageIndex] + self.animationImageIndex = (self.animationImageIndex + 1) % self.animationImages.count + } + RunLoop.main.add(timer, forMode: .common) + animationTimer = timer + } + + func stopActivatingAnimation() { + guard let timer = self.animationTimer else { return } + timer.invalidate() + animationTimer = nil + animationImageIndex = 0 + } +} diff --git a/Sources/WireGuardApp/UI/macOS/StatusMenu.swift b/Sources/WireGuardApp/UI/macOS/StatusMenu.swift new file mode 100644 index 0000000..ec9ffe8 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/StatusMenu.swift @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +protocol StatusMenuWindowDelegate: class { + func showManageTunnelsWindow(completion: ((NSWindow?) -> Void)?) +} + +class StatusMenu: NSMenu { + + let tunnelsManager: TunnelsManager + + var statusMenuItem: NSMenuItem? + var networksMenuItem: NSMenuItem? + var deactivateMenuItem: NSMenuItem? + var firstTunnelMenuItemIndex = 0 + var numberOfTunnelMenuItems = 0 + + var currentTunnel: TunnelContainer? { + didSet { + updateStatusMenuItems(with: currentTunnel) + } + } + weak var windowDelegate: StatusMenuWindowDelegate? + + init(tunnelsManager: TunnelsManager) { + self.tunnelsManager = tunnelsManager + super.init(title: tr("macMenuTitle")) + + addStatusMenuItems() + addItem(NSMenuItem.separator()) + + firstTunnelMenuItemIndex = numberOfItems + let isAdded = addTunnelMenuItems() + if isAdded { + addItem(NSMenuItem.separator()) + } + addTunnelManagementItems() + addItem(NSMenuItem.separator()) + addApplicationItems() + } + + required init(coder decoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func addStatusMenuItems() { + let statusTitle = tr(format: "macStatus (%@)", tr("tunnelStatusInactive")) + let statusMenuItem = NSMenuItem(title: statusTitle, action: nil, keyEquivalent: "") + statusMenuItem.isEnabled = false + addItem(statusMenuItem) + let networksMenuItem = NSMenuItem(title: "", action: nil, keyEquivalent: "") + networksMenuItem.isEnabled = false + networksMenuItem.isHidden = true + addItem(networksMenuItem) + let deactivateMenuItem = NSMenuItem(title: tr("macToggleStatusButtonDeactivate"), action: #selector(deactivateClicked), keyEquivalent: "") + deactivateMenuItem.target = self + deactivateMenuItem.isHidden = true + addItem(deactivateMenuItem) + self.statusMenuItem = statusMenuItem + self.networksMenuItem = networksMenuItem + self.deactivateMenuItem = deactivateMenuItem + } + + func updateStatusMenuItems(with tunnel: TunnelContainer?) { + guard let statusMenuItem = statusMenuItem, let networksMenuItem = networksMenuItem, let deactivateMenuItem = deactivateMenuItem else { return } + guard let tunnel = tunnel else { + statusMenuItem.title = tr(format: "macStatus (%@)", tr("tunnelStatusInactive")) + networksMenuItem.title = "" + networksMenuItem.isHidden = true + deactivateMenuItem.isHidden = true + return + } + var statusText: String + + switch tunnel.status { + case .waiting: + statusText = tr("tunnelStatusWaiting") + case .inactive: + statusText = tr("tunnelStatusInactive") + case .activating: + statusText = tr("tunnelStatusActivating") + case .active: + statusText = tr("tunnelStatusActive") + case .deactivating: + statusText = tr("tunnelStatusDeactivating") + case .reasserting: + statusText = tr("tunnelStatusReasserting") + case .restarting: + statusText = tr("tunnelStatusRestarting") + } + + statusMenuItem.title = tr(format: "macStatus (%@)", statusText) + + if tunnel.status == .inactive { + networksMenuItem.title = "" + networksMenuItem.isHidden = true + } else { + let allowedIPs = tunnel.tunnelConfiguration?.peers.flatMap { $0.allowedIPs }.map { $0.stringRepresentation }.joined(separator: ", ") ?? "" + if !allowedIPs.isEmpty { + networksMenuItem.title = tr(format: "macMenuNetworks (%@)", allowedIPs) + } else { + networksMenuItem.title = tr("macMenuNetworksNone") + } + networksMenuItem.isHidden = false + } + deactivateMenuItem.isHidden = tunnel.status != .active + } + + func addTunnelMenuItems() -> Bool { + let numberOfTunnels = tunnelsManager.numberOfTunnels() + for index in 0 ..< tunnelsManager.numberOfTunnels() { + let tunnel = tunnelsManager.tunnel(at: index) + insertTunnelMenuItem(for: tunnel, at: numberOfTunnelMenuItems) + } + return numberOfTunnels > 0 + } + + func addTunnelManagementItems() { + let manageItem = NSMenuItem(title: tr("macMenuManageTunnels"), action: #selector(manageTunnelsClicked), keyEquivalent: "") + manageItem.target = self + addItem(manageItem) + let importItem = NSMenuItem(title: tr("macMenuImportTunnels"), action: #selector(importTunnelsClicked), keyEquivalent: "") + importItem.target = self + addItem(importItem) + } + + func addApplicationItems() { + let aboutItem = NSMenuItem(title: tr("macMenuAbout"), action: #selector(AppDelegate.aboutClicked), keyEquivalent: "") + aboutItem.target = NSApp.delegate + addItem(aboutItem) + let quitItem = NSMenuItem(title: tr("macMenuQuit"), action: #selector(AppDelegate.quit), keyEquivalent: "") + quitItem.target = NSApp.delegate + addItem(quitItem) + } + + @objc func deactivateClicked() { + if let currentTunnel = currentTunnel { + tunnelsManager.startDeactivation(of: currentTunnel) + } + } + + @objc func tunnelClicked(sender: AnyObject) { + guard let tunnelMenuItem = sender as? TunnelMenuItem else { return } + if tunnelMenuItem.state == .off { + tunnelsManager.startActivation(of: tunnelMenuItem.tunnel) + } else { + tunnelsManager.startDeactivation(of: tunnelMenuItem.tunnel) + } + } + + @objc func manageTunnelsClicked() { + windowDelegate?.showManageTunnelsWindow(completion: nil) + } + + @objc func importTunnelsClicked() { + windowDelegate?.showManageTunnelsWindow { [weak self] manageTunnelsWindow in + guard let self = self else { return } + guard let manageTunnelsWindow = manageTunnelsWindow else { return } + ImportPanelPresenter.presentImportPanel(tunnelsManager: self.tunnelsManager, + sourceVC: manageTunnelsWindow.contentViewController) + } + } +} + +extension StatusMenu { + func insertTunnelMenuItem(for tunnel: TunnelContainer, at tunnelIndex: Int) { + let menuItem = TunnelMenuItem(tunnel: tunnel, action: #selector(tunnelClicked(sender:))) + menuItem.target = self + menuItem.isHidden = !tunnel.isTunnelAvailableToUser + insertItem(menuItem, at: firstTunnelMenuItemIndex + tunnelIndex) + if numberOfTunnelMenuItems == 0 { + insertItem(NSMenuItem.separator(), at: firstTunnelMenuItemIndex + tunnelIndex + 1) + } + numberOfTunnelMenuItems += 1 + } + + func removeTunnelMenuItem(at tunnelIndex: Int) { + removeItem(at: firstTunnelMenuItemIndex + tunnelIndex) + numberOfTunnelMenuItems -= 1 + if numberOfTunnelMenuItems == 0 { + if let firstItem = item(at: firstTunnelMenuItemIndex), firstItem.isSeparatorItem { + removeItem(at: firstTunnelMenuItemIndex) + } + } + } + + func moveTunnelMenuItem(from oldTunnelIndex: Int, to newTunnelIndex: Int) { + guard let oldMenuItem = item(at: firstTunnelMenuItemIndex + oldTunnelIndex) as? TunnelMenuItem else { return } + let oldMenuItemTunnel = oldMenuItem.tunnel + removeItem(at: firstTunnelMenuItemIndex + oldTunnelIndex) + let menuItem = TunnelMenuItem(tunnel: oldMenuItemTunnel, action: #selector(tunnelClicked(sender:))) + menuItem.target = self + insertItem(menuItem, at: firstTunnelMenuItemIndex + newTunnelIndex) + + } +} + +class TunnelMenuItem: NSMenuItem { + + var tunnel: TunnelContainer + + private var statusObservationToken: AnyObject? + private var nameObservationToken: AnyObject? + + init(tunnel: TunnelContainer, action selector: Selector?) { + self.tunnel = tunnel + super.init(title: tunnel.name, action: selector, keyEquivalent: "") + updateStatus() + let statusObservationToken = tunnel.observe(\.status) { [weak self] _, _ in + self?.updateStatus() + } + updateTitle() + let nameObservationToken = tunnel.observe(\TunnelContainer.name) { [weak self] _, _ in + self?.updateTitle() + } + self.statusObservationToken = statusObservationToken + self.nameObservationToken = nameObservationToken + } + + required init(coder decoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func updateTitle() { + title = tunnel.name + } + + func updateStatus() { + let shouldShowCheckmark = (tunnel.status != .inactive && tunnel.status != .deactivating) + state = shouldShowCheckmark ? .on : .off + } +} diff --git a/Sources/WireGuardApp/UI/macOS/TunnelsTracker.swift b/Sources/WireGuardApp/UI/macOS/TunnelsTracker.swift new file mode 100644 index 0000000..69cc533 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/TunnelsTracker.swift @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +// Keeps track of tunnels and informs the following objects of changes in tunnels: +// - Status menu +// - Status item controller +// - Tunnels list view controller in the Manage Tunnels window + +class TunnelsTracker { + + weak var statusMenu: StatusMenu? { + didSet { + statusMenu?.currentTunnel = currentTunnel + } + } + weak var statusItemController: StatusItemController? { + didSet { + statusItemController?.currentTunnel = currentTunnel + } + } + weak var manageTunnelsRootVC: ManageTunnelsRootViewController? + + private var tunnelsManager: TunnelsManager + private var tunnelStatusObservers = [AnyObject]() + private(set) var currentTunnel: TunnelContainer? { + didSet { + statusMenu?.currentTunnel = currentTunnel + statusItemController?.currentTunnel = currentTunnel + } + } + + init(tunnelsManager: TunnelsManager) { + self.tunnelsManager = tunnelsManager + currentTunnel = tunnelsManager.tunnelInOperation() + + for index in 0 ..< tunnelsManager.numberOfTunnels() { + let tunnel = tunnelsManager.tunnel(at: index) + let statusObservationToken = observeStatus(of: tunnel) + tunnelStatusObservers.insert(statusObservationToken, at: index) + } + + tunnelsManager.tunnelsListDelegate = self + tunnelsManager.activationDelegate = self + } + + func observeStatus(of tunnel: TunnelContainer) -> AnyObject { + return tunnel.observe(\.status) { [weak self] tunnel, _ in + guard let self = self else { return } + if tunnel.status == .deactivating || tunnel.status == .inactive { + if self.currentTunnel == tunnel { + self.currentTunnel = self.tunnelsManager.tunnelInOperation() + } + } else { + self.currentTunnel = tunnel + } + } + } +} + +extension TunnelsTracker: TunnelsManagerListDelegate { + func tunnelAdded(at index: Int) { + let tunnel = tunnelsManager.tunnel(at: index) + if tunnel.status != .deactivating && tunnel.status != .inactive { + self.currentTunnel = tunnel + } + let statusObservationToken = observeStatus(of: tunnel) + tunnelStatusObservers.insert(statusObservationToken, at: index) + + statusMenu?.insertTunnelMenuItem(for: tunnel, at: index) + manageTunnelsRootVC?.tunnelsListVC?.tunnelAdded(at: index) + } + + func tunnelModified(at index: Int) { + manageTunnelsRootVC?.tunnelsListVC?.tunnelModified(at: index) + } + + func tunnelMoved(from oldIndex: Int, to newIndex: Int) { + let statusObserver = tunnelStatusObservers.remove(at: oldIndex) + tunnelStatusObservers.insert(statusObserver, at: newIndex) + + statusMenu?.moveTunnelMenuItem(from: oldIndex, to: newIndex) + manageTunnelsRootVC?.tunnelsListVC?.tunnelMoved(from: oldIndex, to: newIndex) + } + + func tunnelRemoved(at index: Int, tunnel: TunnelContainer) { + tunnelStatusObservers.remove(at: index) + + statusMenu?.removeTunnelMenuItem(at: index) + manageTunnelsRootVC?.tunnelsListVC?.tunnelRemoved(at: index) + } +} + +extension TunnelsTracker: TunnelsManagerActivationDelegate { + func tunnelActivationAttemptFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationAttemptError) { + if let manageTunnelsRootVC = manageTunnelsRootVC, manageTunnelsRootVC.view.window?.isVisible ?? false { + ErrorPresenter.showErrorAlert(error: error, from: manageTunnelsRootVC) + } else { + ErrorPresenter.showErrorAlert(error: error, from: nil) + } + } + + func tunnelActivationAttemptSucceeded(tunnel: TunnelContainer) { + // Nothing to do + } + + func tunnelActivationFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationError) { + if let manageTunnelsRootVC = manageTunnelsRootVC, manageTunnelsRootVC.view.window?.isVisible ?? false { + ErrorPresenter.showErrorAlert(error: error, from: manageTunnelsRootVC) + } else { + ErrorPresenter.showErrorAlert(error: error, from: nil) + } + } + + func tunnelActivationSucceeded(tunnel: TunnelContainer) { + // Nothing to do + } +} diff --git a/Sources/WireGuardApp/UI/macOS/View/ButtonRow.swift b/Sources/WireGuardApp/UI/macOS/View/ButtonRow.swift new file mode 100644 index 0000000..4d15f5e --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/ButtonRow.swift @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +class ButtonRow: NSView { + let button: NSButton = { + let button = NSButton() + button.title = "" + button.setButtonType(.momentaryPushIn) + button.bezelStyle = .rounded + return button + }() + + var buttonTitle: String { + get { return button.title } + set(value) { button.title = value } + } + + var isButtonEnabled: Bool { + get { return button.isEnabled } + set(value) { button.isEnabled = value } + } + + var buttonToolTip: String { + get { return button.toolTip ?? "" } + set(value) { button.toolTip = value } + } + + var onButtonClicked: (() -> Void)? + var observationToken: AnyObject? + + override var intrinsicContentSize: NSSize { + return NSSize(width: NSView.noIntrinsicMetric, height: button.intrinsicContentSize.height) + } + + init() { + super.init(frame: CGRect.zero) + + button.target = self + button.action = #selector(buttonClicked) + + addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + button.centerYAnchor.constraint(equalTo: self.centerYAnchor), + button.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 155), + button.widthAnchor.constraint(greaterThanOrEqualToConstant: 100) + ]) + } + + required init?(coder decoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func buttonClicked() { + onButtonClicked?() + } + + override func prepareForReuse() { + buttonTitle = "" + buttonToolTip = "" + onButtonClicked = nil + observationToken = nil + } +} diff --git a/Sources/WireGuardApp/UI/macOS/View/ConfTextColorTheme.swift b/Sources/WireGuardApp/UI/macOS/View/ConfTextColorTheme.swift new file mode 100644 index 0000000..5229d50 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/ConfTextColorTheme.swift @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +protocol ConfTextColorTheme { + static var defaultColor: NSColor { get } + static var colorMap: [UInt32: NSColor] { get } +} + +struct ConfTextAquaColorTheme: ConfTextColorTheme { + static let defaultColor = NSColor(hex: "#000000") + static let colorMap: [UInt32: NSColor] = [ + HighlightSection.rawValue: NSColor(hex: "#326D74"), // Class name in Xcode + HighlightField.rawValue: NSColor(hex: "#9B2393"), // Keywords in Xcode + HighlightPublicKey.rawValue: NSColor(hex: "#643820"), // Preprocessor directives in Xcode + HighlightPrivateKey.rawValue: NSColor(hex: "#643820"), // Preprocessor directives in Xcode + HighlightPresharedKey.rawValue: NSColor(hex: "#643820"), // Preprocessor directives in Xcode + HighlightIP.rawValue: NSColor(hex: "#0E0EFF"), // URLs in Xcode + HighlightHost.rawValue: NSColor(hex: "#0E0EFF"), // URLs in Xcode + HighlightCidr.rawValue: NSColor(hex: "#815F03"), // Attributes in Xcode + HighlightPort.rawValue: NSColor(hex: "#815F03"), // Attributes in Xcode + HighlightMTU.rawValue: NSColor(hex: "#1C00CF"), // Numbers in Xcode + HighlightKeepalive.rawValue: NSColor(hex: "#1C00CF"), // Numbers in Xcode + HighlightComment.rawValue: NSColor(hex: "#536579"), // Comments in Xcode + HighlightError.rawValue: NSColor(hex: "#C41A16") // Strings in Xcode + ] +} + +struct ConfTextDarkAquaColorTheme: ConfTextColorTheme { + static let defaultColor = NSColor(hex: "#FFFFFF") // Plain text in Xcode + static let colorMap: [UInt32: NSColor] = [ + HighlightSection.rawValue: NSColor(hex: "#91D462"), // Class name in Xcode + HighlightField.rawValue: NSColor(hex: "#FC5FA3"), // Keywords in Xcode + HighlightPublicKey.rawValue: NSColor(hex: "#FD8F3F"), // Preprocessor directives in Xcode + HighlightPrivateKey.rawValue: NSColor(hex: "#FD8F3F"), // Preprocessor directives in Xcode + HighlightPresharedKey.rawValue: NSColor(hex: "#FD8F3F"), // Preprocessor directives in Xcode + HighlightIP.rawValue: NSColor(hex: "#53A5FB"), // URLs in Xcode + HighlightHost.rawValue: NSColor(hex: "#53A5FB"), // URLs in Xcode + HighlightCidr.rawValue: NSColor(hex: "#75B492"), // Attributes in Xcode + HighlightPort.rawValue: NSColor(hex: "#75B492"), // Attributes in Xcode + HighlightMTU.rawValue: NSColor(hex: "#9686F5"), // Numbers in Xcode + HighlightKeepalive.rawValue: NSColor(hex: "#9686F5"), // Numbers in Xcode + HighlightComment.rawValue: NSColor(hex: "#6C7986"), // Comments in Xcode + HighlightError.rawValue: NSColor(hex: "#FF4C4C") // Strings in Xcode + ] +} diff --git a/Sources/WireGuardApp/UI/macOS/View/ConfTextStorage.swift b/Sources/WireGuardApp/UI/macOS/View/ConfTextStorage.swift new file mode 100644 index 0000000..3c92db3 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/ConfTextStorage.swift @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +private let fontSize: CGFloat = 15 + +class ConfTextStorage: NSTextStorage { + let defaultFont = NSFontManager.shared.convertWeight(true, of: NSFont.systemFont(ofSize: fontSize)) + private let boldFont = NSFont.boldSystemFont(ofSize: fontSize) + private lazy var italicFont = NSFontManager.shared.convert(defaultFont, toHaveTrait: .italicFontMask) + + private var textColorTheme: ConfTextColorTheme.Type? + + private let backingStore: NSMutableAttributedString + private(set) var hasError = false + private(set) var privateKeyString: String? + + private(set) var hasOnePeer: Bool = false + private(set) var lastOnePeerAllowedIPs = [String]() + private(set) var lastOnePeerDNSServers = [String]() + private(set) var lastOnePeerHasPublicKey = false + + override init() { + backingStore = NSMutableAttributedString(string: "") + super.init() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + required init?(pasteboardPropertyList propertyList: Any, ofType type: NSPasteboard.PasteboardType) { + fatalError("init(pasteboardPropertyList:ofType:) has not been implemented") + } + + func nonColorAttributes(for highlightType: highlight_type) -> [NSAttributedString.Key: Any] { + switch highlightType.rawValue { + case HighlightSection.rawValue, HighlightField.rawValue: + return [.font: boldFont] + case HighlightPublicKey.rawValue, HighlightPrivateKey.rawValue, HighlightPresharedKey.rawValue, + HighlightIP.rawValue, HighlightCidr.rawValue, HighlightHost.rawValue, HighlightPort.rawValue, + HighlightMTU.rawValue, HighlightKeepalive.rawValue, HighlightDelimiter.rawValue: + return [.font: defaultFont] + case HighlightComment.rawValue: + return [.font: italicFont] + case HighlightError.rawValue: + return [.font: defaultFont, .underlineStyle: 1] + default: + return [:] + } + } + + func updateAttributes(for textColorTheme: ConfTextColorTheme.Type) { + self.textColorTheme = textColorTheme + highlightSyntax() + } + + override var string: String { + return backingStore.string + } + + override func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [NSAttributedString.Key: Any] { + return backingStore.attributes(at: location, effectiveRange: range) + } + + override func replaceCharacters(in range: NSRange, with str: String) { + beginEditing() + backingStore.replaceCharacters(in: range, with: str) + edited(.editedCharacters, range: range, changeInLength: str.count - range.length) + endEditing() + } + + override func replaceCharacters(in range: NSRange, with attrString: NSAttributedString) { + beginEditing() + backingStore.replaceCharacters(in: range, with: attrString) + edited(.editedCharacters, range: range, changeInLength: attrString.length - range.length) + endEditing() + } + + override func setAttributes(_ attrs: [NSAttributedString.Key: Any]?, range: NSRange) { + beginEditing() + backingStore.setAttributes(attrs, range: range) + edited(.editedAttributes, range: range, changeInLength: 0) + endEditing() + } + + func resetLastPeer() { + hasOnePeer = false + lastOnePeerAllowedIPs = [] + lastOnePeerDNSServers = [] + lastOnePeerHasPublicKey = false + } + + func evaluateExcludePrivateIPs(highlightSpans: UnsafePointer<highlight_span>) { + var spans = highlightSpans + enum FieldType: String { + case dns + case allowedips + } + var fieldType: FieldType? + resetLastPeer() + while spans.pointee.type != HighlightEnd { + let span = spans.pointee + var substring = backingStore.attributedSubstring(from: NSRange(location: span.start, length: span.len)).string.lowercased() + + if span.type == HighlightError { + resetLastPeer() + return + } else if span.type == HighlightSection { + if substring == "[peer]" { + if hasOnePeer { + resetLastPeer() + return + } + hasOnePeer = true + } + } else if span.type == HighlightField { + fieldType = FieldType(rawValue: substring) + } else if span.type == HighlightIP && fieldType == .dns { + lastOnePeerDNSServers.append(substring) + } else if span.type == HighlightIP && fieldType == .allowedips { + let next = spans.successor() + let nextnext = next.successor() + if next.pointee.type == HighlightDelimiter && nextnext.pointee.type == HighlightCidr { + substring += backingStore.attributedSubstring(from: NSRange(location: next.pointee.start, length: next.pointee.len)).string + + backingStore.attributedSubstring(from: NSRange(location: nextnext.pointee.start, length: nextnext.pointee.len)).string + } + lastOnePeerAllowedIPs.append(substring) + } else if span.type == HighlightPublicKey { + lastOnePeerHasPublicKey = true + } + spans = spans.successor() + } + } + + func highlightSyntax() { + guard let textColorTheme = textColorTheme else { return } + hasError = false + privateKeyString = nil + + let fullTextRange = NSRange(location: 0, length: (backingStore.string as NSString).length) + + backingStore.beginEditing() + let defaultAttributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: textColorTheme.defaultColor, + .font: defaultFont + ] + backingStore.setAttributes(defaultAttributes, range: fullTextRange) + var spans = highlight_config(backingStore.string)! + evaluateExcludePrivateIPs(highlightSpans: spans) + + let spansStart = spans + while spans.pointee.type != HighlightEnd { + let span = spans.pointee + + let range = NSRange(location: span.start, length: span.len) + backingStore.setAttributes(nonColorAttributes(for: span.type), range: range) + let color = textColorTheme.colorMap[span.type.rawValue, default: textColorTheme.defaultColor] + backingStore.addAttribute(.foregroundColor, value: color, range: range) + + if span.type == HighlightError { + hasError = true + } + + if span.type == HighlightPrivateKey { + privateKeyString = backingStore.attributedSubstring(from: NSRange(location: span.start, length: span.len)).string + } + + spans = spans.successor() + } + backingStore.endEditing() + free(spansStart) + + beginEditing() + edited(.editedAttributes, range: fullTextRange, changeInLength: 0) + endEditing() + } + +} diff --git a/Sources/WireGuardApp/UI/macOS/View/ConfTextView.swift b/Sources/WireGuardApp/UI/macOS/View/ConfTextView.swift new file mode 100644 index 0000000..6016e08 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/ConfTextView.swift @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +class ConfTextView: NSTextView { + + private let confTextStorage = ConfTextStorage() + + @objc dynamic var hasError: Bool = false + @objc dynamic var privateKeyString: String? + @objc dynamic var singlePeerAllowedIPs: [String]? + + override var string: String { + didSet { + confTextStorage.highlightSyntax() + updateConfigData() + } + } + + init() { + let textContainer = NSTextContainer() + let layoutManager = NSLayoutManager() + layoutManager.addTextContainer(textContainer) + confTextStorage.addLayoutManager(layoutManager) + super.init(frame: CGRect(x: 0, y: 0, width: 1, height: 60), textContainer: textContainer) + font = confTextStorage.defaultFont + allowsUndo = true + isAutomaticSpellingCorrectionEnabled = false + isAutomaticDataDetectionEnabled = false + isAutomaticLinkDetectionEnabled = false + isAutomaticTextCompletionEnabled = false + isAutomaticTextReplacementEnabled = false + isAutomaticDashSubstitutionEnabled = false + isAutomaticQuoteSubstitutionEnabled = false + updateTheme() + delegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidChangeEffectiveAppearance() { + updateTheme() + } + + private func updateTheme() { + switch effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) ?? .aqua { + case .darkAqua: + confTextStorage.updateAttributes(for: ConfTextDarkAquaColorTheme.self) + default: + confTextStorage.updateAttributes(for: ConfTextAquaColorTheme.self) + } + } + + private func updateConfigData() { + if hasError != confTextStorage.hasError { + hasError = confTextStorage.hasError + } + if privateKeyString != confTextStorage.privateKeyString { + privateKeyString = confTextStorage.privateKeyString + } + let hasSyntaxError = confTextStorage.hasError + let hasSemanticError = confTextStorage.privateKeyString == nil || !confTextStorage.lastOnePeerHasPublicKey + let updatedSinglePeerAllowedIPs = confTextStorage.hasOnePeer && !hasSyntaxError && !hasSemanticError ? confTextStorage.lastOnePeerAllowedIPs : nil + if singlePeerAllowedIPs != updatedSinglePeerAllowedIPs { + singlePeerAllowedIPs = updatedSinglePeerAllowedIPs + } + } + + func setConfText(_ text: String) { + let fullTextRange = NSRange(location: 0, length: (string as NSString).length) + if shouldChangeText(in: fullTextRange, replacementString: text) { + replaceCharacters(in: fullTextRange, with: text) + didChangeText() + } + } +} + +extension ConfTextView: NSTextViewDelegate { + + func textDidChange(_ notification: Notification) { + confTextStorage.highlightSyntax() + updateConfigData() + needsDisplay = true + } + +} diff --git a/Sources/WireGuardApp/UI/macOS/View/DeleteTunnelsConfirmationAlert.swift b/Sources/WireGuardApp/UI/macOS/View/DeleteTunnelsConfirmationAlert.swift new file mode 100644 index 0000000..e52ed89 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/DeleteTunnelsConfirmationAlert.swift @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +class DeleteTunnelsConfirmationAlert: NSAlert { + var alertDeleteButton: NSButton? + var alertCancelButton: NSButton? + + var onDeleteClicked: ((_ completionHandler: @escaping () -> Void) -> Void)? + + override init() { + super.init() + let alertDeleteButton = addButton(withTitle: tr("macDeleteTunnelConfirmationAlertButtonTitleDelete")) + alertDeleteButton.target = self + alertDeleteButton.action = #selector(removeTunnelAlertDeleteClicked) + self.alertDeleteButton = alertDeleteButton + self.alertCancelButton = addButton(withTitle: tr("macDeleteTunnelConfirmationAlertButtonTitleCancel")) + } + + @objc func removeTunnelAlertDeleteClicked() { + alertDeleteButton?.title = tr("macDeleteTunnelConfirmationAlertButtonTitleDeleting") + alertDeleteButton?.isEnabled = false + alertCancelButton?.isEnabled = false + if let onDeleteClicked = onDeleteClicked { + onDeleteClicked { [weak self] in + guard let self = self else { return } + self.window.sheetParent?.endSheet(self.window) + } + } + } + + func beginSheetModal(for sheetWindow: NSWindow) { + beginSheetModal(for: sheetWindow) { _ in } + } +} diff --git a/Sources/WireGuardApp/UI/macOS/View/KeyValueRow.swift b/Sources/WireGuardApp/UI/macOS/View/KeyValueRow.swift new file mode 100644 index 0000000..2f037d8 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/KeyValueRow.swift @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +class EditableKeyValueRow: NSView { + let keyLabel: NSTextField = { + let keyLabel = NSTextField() + keyLabel.isEditable = false + keyLabel.isSelectable = false + keyLabel.isBordered = false + keyLabel.alignment = .right + keyLabel.maximumNumberOfLines = 1 + keyLabel.lineBreakMode = .byTruncatingTail + keyLabel.backgroundColor = .clear + return keyLabel + }() + + let valueLabel: NSTextField = { + let valueLabel = NSTextField() + valueLabel.isSelectable = true + valueLabel.maximumNumberOfLines = 1 + valueLabel.lineBreakMode = .byTruncatingTail + return valueLabel + }() + + let valueImageView: NSImageView? + + var key: String { + get { return keyLabel.stringValue } + set(value) { keyLabel.stringValue = value } + } + var value: String { + get { return valueLabel.stringValue } + set(value) { valueLabel.stringValue = value } + } + var isKeyInBold: Bool { + get { return keyLabel.font == NSFont.boldSystemFont(ofSize: 0) } + set(value) { + if value { + keyLabel.font = NSFont.boldSystemFont(ofSize: 0) + } else { + keyLabel.font = NSFont.systemFont(ofSize: 0) + } + } + } + var valueImage: NSImage? { + get { return valueImageView?.image } + set(value) { valueImageView?.image = value } + } + + var observationToken: AnyObject? + + override var intrinsicContentSize: NSSize { + let height = max(keyLabel.intrinsicContentSize.height, valueLabel.intrinsicContentSize.height) + return NSSize(width: NSView.noIntrinsicMetric, height: height) + } + + convenience init() { + self.init(hasValueImage: false) + } + + fileprivate init(hasValueImage: Bool) { + valueImageView = hasValueImage ? NSImageView() : nil + super.init(frame: CGRect.zero) + + addSubview(keyLabel) + addSubview(valueLabel) + keyLabel.translatesAutoresizingMaskIntoConstraints = false + valueLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + keyLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor), + keyLabel.firstBaselineAnchor.constraint(equalTo: valueLabel.firstBaselineAnchor), + self.leadingAnchor.constraint(equalTo: keyLabel.leadingAnchor), + valueLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor) + ]) + + let spacing: CGFloat = 5 + if let valueImageView = valueImageView { + addSubview(valueImageView) + valueImageView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + valueImageView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + valueImageView.leadingAnchor.constraint(equalTo: keyLabel.trailingAnchor, constant: spacing), + valueLabel.leadingAnchor.constraint(equalTo: valueImageView.trailingAnchor) + ]) + } else { + NSLayoutConstraint.activate([ + valueLabel.leadingAnchor.constraint(equalTo: keyLabel.trailingAnchor, constant: spacing) + ]) + } + + keyLabel.setContentCompressionResistancePriority(.defaultHigh + 2, for: .horizontal) + keyLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + valueLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let widthConstraint = keyLabel.widthAnchor.constraint(equalToConstant: 150) + widthConstraint.priority = .defaultHigh + 1 + widthConstraint.isActive = true + } + + required init?(coder decoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + key = "" + value = "" + isKeyInBold = false + observationToken = nil + } +} + +class KeyValueRow: EditableKeyValueRow { + init() { + super.init(hasValueImage: false) + valueLabel.isEditable = false + valueLabel.isBordered = false + valueLabel.backgroundColor = .clear + } + + required init?(coder decoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class KeyValueImageRow: EditableKeyValueRow { + init() { + super.init(hasValueImage: true) + valueLabel.isEditable = false + valueLabel.isBordered = false + valueLabel.backgroundColor = .clear + } + + required init?(coder decoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Sources/WireGuardApp/UI/macOS/View/LogViewCell.swift b/Sources/WireGuardApp/UI/macOS/View/LogViewCell.swift new file mode 100644 index 0000000..c1c6cc5 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/LogViewCell.swift @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +class LogViewCell: NSTableCellView { + var text: String = "" { + didSet { textField?.stringValue = text } + } + + init() { + super.init(frame: .zero) + + let textField = NSTextField(wrappingLabelWithString: "") + addSubview(textField) + textField.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + textField.leadingAnchor.constraint(equalTo: self.leadingAnchor), + textField.trailingAnchor.constraint(equalTo: self.trailingAnchor), + textField.topAnchor.constraint(equalTo: self.topAnchor), + textField.bottomAnchor.constraint(equalTo: self.bottomAnchor) + ]) + + self.textField = textField + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + textField?.stringValue = "" + } +} + +class LogViewTimestampCell: LogViewCell { + override init() { + super.init() + if let textField = textField { + textField.maximumNumberOfLines = 1 + textField.lineBreakMode = .byClipping + textField.setContentCompressionResistancePriority(.defaultHigh, for: .vertical) + textField.setContentHuggingPriority(.defaultLow, for: .vertical) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class LogViewMessageCell: LogViewCell { + override init() { + super.init() + if let textField = textField { + textField.maximumNumberOfLines = 0 + textField.lineBreakMode = .byWordWrapping + textField.setContentCompressionResistancePriority(.required, for: .vertical) + textField.setContentHuggingPriority(.required, for: .vertical) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Sources/WireGuardApp/UI/macOS/View/OnDemandWiFiControls.swift b/Sources/WireGuardApp/UI/macOS/View/OnDemandWiFiControls.swift new file mode 100644 index 0000000..2e3de48 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/OnDemandWiFiControls.swift @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa +import CoreWLAN + +class OnDemandControlsRow: NSView { + let keyLabel: NSTextField = { + let keyLabel = NSTextField() + keyLabel.stringValue = tr("macFieldOnDemand") + keyLabel.isEditable = false + keyLabel.isSelectable = false + keyLabel.isBordered = false + keyLabel.alignment = .right + keyLabel.maximumNumberOfLines = 1 + keyLabel.lineBreakMode = .byTruncatingTail + keyLabel.backgroundColor = .clear + return keyLabel + }() + + let onDemandEthernetCheckbox: NSButton = { + let checkbox = NSButton() + checkbox.title = tr("tunnelOnDemandEthernet") + checkbox.setButtonType(.switch) + checkbox.state = .off + return checkbox + }() + + let onDemandWiFiCheckbox: NSButton = { + let checkbox = NSButton() + checkbox.title = tr("tunnelOnDemandWiFi") + checkbox.setButtonType(.switch) + checkbox.state = .off + return checkbox + }() + + static let onDemandSSIDOptions: [ActivateOnDemandViewModel.OnDemandSSIDOption] = [ + .anySSID, .onlySpecificSSIDs, .exceptSpecificSSIDs + ] + + let onDemandSSIDOptionsPopup = NSPopUpButton() + + let onDemandSSIDsField: NSTokenField = { + let tokenField = NSTokenField() + tokenField.tokenizingCharacterSet = CharacterSet([]) + tokenField.tokenStyle = .squared + NSLayoutConstraint.activate([ + tokenField.widthAnchor.constraint(greaterThanOrEqualToConstant: 180) + ]) + return tokenField + }() + + override var intrinsicContentSize: NSSize { + let minHeight: CGFloat = 22 + let height = max(minHeight, keyLabel.intrinsicContentSize.height, + onDemandEthernetCheckbox.intrinsicContentSize.height, onDemandWiFiCheckbox.intrinsicContentSize.height, + onDemandSSIDOptionsPopup.intrinsicContentSize.height, onDemandSSIDsField.intrinsicContentSize.height) + return NSSize(width: NSView.noIntrinsicMetric, height: height) + } + + var onDemandViewModel: ActivateOnDemandViewModel? { + didSet { updateControls() } + } + + var currentSSIDs: [String] + + init() { + currentSSIDs = getCurrentSSIDs() + super.init(frame: CGRect.zero) + + onDemandSSIDOptionsPopup.addItems(withTitles: OnDemandControlsRow.onDemandSSIDOptions.map { $0.localizedUIString }) + + let stackView = NSStackView() + stackView.setViews([onDemandEthernetCheckbox, onDemandWiFiCheckbox, onDemandSSIDOptionsPopup, onDemandSSIDsField], in: .leading) + stackView.orientation = .horizontal + + addSubview(keyLabel) + addSubview(stackView) + keyLabel.translatesAutoresizingMaskIntoConstraints = false + stackView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + keyLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor), + stackView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + self.leadingAnchor.constraint(equalTo: keyLabel.leadingAnchor), + stackView.leadingAnchor.constraint(equalTo: keyLabel.trailingAnchor, constant: 5), + stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor) + ]) + + keyLabel.setContentCompressionResistancePriority(.defaultHigh + 2, for: .horizontal) + keyLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + let widthConstraint = keyLabel.widthAnchor.constraint(equalToConstant: 150) + widthConstraint.priority = .defaultHigh + 1 + widthConstraint.isActive = true + + NSLayoutConstraint.activate([ + onDemandEthernetCheckbox.centerYAnchor.constraint(equalTo: stackView.centerYAnchor), + onDemandWiFiCheckbox.lastBaselineAnchor.constraint(equalTo: onDemandEthernetCheckbox.lastBaselineAnchor), + onDemandSSIDOptionsPopup.lastBaselineAnchor.constraint(equalTo: onDemandEthernetCheckbox.lastBaselineAnchor), + onDemandSSIDsField.lastBaselineAnchor.constraint(equalTo: onDemandEthernetCheckbox.lastBaselineAnchor) + ]) + + onDemandSSIDsField.setContentHuggingPriority(.defaultLow, for: .horizontal) + + onDemandEthernetCheckbox.target = self + onDemandEthernetCheckbox.action = #selector(ethernetCheckboxToggled) + + onDemandWiFiCheckbox.target = self + onDemandWiFiCheckbox.action = #selector(wiFiCheckboxToggled) + + onDemandSSIDOptionsPopup.target = self + onDemandSSIDOptionsPopup.action = #selector(ssidOptionsPopupValueChanged) + + onDemandSSIDsField.delegate = self + + updateControls() + } + + required init?(coder decoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func saveToViewModel() { + guard let onDemandViewModel = onDemandViewModel else { return } + onDemandViewModel.isNonWiFiInterfaceEnabled = onDemandEthernetCheckbox.state == .on + onDemandViewModel.isWiFiInterfaceEnabled = onDemandWiFiCheckbox.state == .on + onDemandViewModel.ssidOption = OnDemandControlsRow.onDemandSSIDOptions[onDemandSSIDOptionsPopup.indexOfSelectedItem] + onDemandViewModel.selectedSSIDs = (onDemandSSIDsField.objectValue as? [String]) ?? [] + } + + func updateControls() { + guard let onDemandViewModel = onDemandViewModel else { return } + onDemandEthernetCheckbox.state = onDemandViewModel.isNonWiFiInterfaceEnabled ? .on : .off + onDemandWiFiCheckbox.state = onDemandViewModel.isWiFiInterfaceEnabled ? .on : .off + let optionIndex = OnDemandControlsRow.onDemandSSIDOptions.firstIndex(of: onDemandViewModel.ssidOption) + onDemandSSIDOptionsPopup.selectItem(at: optionIndex ?? 0) + onDemandSSIDsField.objectValue = onDemandViewModel.selectedSSIDs + onDemandSSIDOptionsPopup.isHidden = !onDemandViewModel.isWiFiInterfaceEnabled + onDemandSSIDsField.isHidden = !onDemandViewModel.isWiFiInterfaceEnabled || onDemandViewModel.ssidOption == .anySSID + } + + @objc func ethernetCheckboxToggled() { + onDemandViewModel?.isNonWiFiInterfaceEnabled = onDemandEthernetCheckbox.state == .on + } + + @objc func wiFiCheckboxToggled() { + onDemandViewModel?.isWiFiInterfaceEnabled = onDemandWiFiCheckbox.state == .on + updateControls() + } + + @objc func ssidOptionsPopupValueChanged() { + let selectedIndex = onDemandSSIDOptionsPopup.indexOfSelectedItem + onDemandViewModel?.ssidOption = OnDemandControlsRow.onDemandSSIDOptions[selectedIndex] + onDemandViewModel?.selectedSSIDs = (onDemandSSIDsField.objectValue as? [String]) ?? [] + updateControls() + if !onDemandSSIDsField.isHidden { + onDemandSSIDsField.becomeFirstResponder() + } + } +} + +extension OnDemandControlsRow: NSTokenFieldDelegate { + func tokenField(_ tokenField: NSTokenField, completionsForSubstring substring: String, indexOfToken tokenIndex: Int, indexOfSelectedItem selectedIndex: UnsafeMutablePointer<Int>?) -> [Any]? { + return currentSSIDs.filter { $0.hasPrefix(substring) } + } +} + +private func getCurrentSSIDs() -> [String] { + return CWWiFiClient.shared().interfaces()?.compactMap { $0.ssid() } ?? [] +} diff --git a/Sources/WireGuardApp/UI/macOS/View/TunnelListRow.swift b/Sources/WireGuardApp/UI/macOS/View/TunnelListRow.swift new file mode 100644 index 0000000..f5e37c1 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/TunnelListRow.swift @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved. + +import Cocoa + +class TunnelListRow: NSView { + var tunnel: TunnelContainer? { + didSet(value) { + // Bind to the tunnel's name + nameLabel.stringValue = tunnel?.name ?? "" + nameObservationToken = tunnel?.observe(\TunnelContainer.name) { [weak self] tunnel, _ in + self?.nameLabel.stringValue = tunnel.name + } + // Bind to the tunnel's status + statusImageView.image = TunnelListRow.image(for: tunnel?.status) + statusObservationToken = tunnel?.observe(\TunnelContainer.status) { [weak self] tunnel, _ in + self?.statusImageView.image = TunnelListRow.image(for: tunnel.status) + } + } + } + + let nameLabel: NSTextField = { + let nameLabel = NSTextField() + nameLabel.isEditable = false + nameLabel.isSelectable = false + nameLabel.isBordered = false + nameLabel.maximumNumberOfLines = 1 + nameLabel.lineBreakMode = .byTruncatingTail + return nameLabel + }() + + let statusImageView = NSImageView() + + private var statusObservationToken: AnyObject? + private var nameObservationToken: AnyObject? + + init() { + super.init(frame: CGRect.zero) + + addSubview(statusImageView) + addSubview(nameLabel) + statusImageView.translatesAutoresizingMaskIntoConstraints = false + nameLabel.translatesAutoresizingMaskIntoConstraints = false + nameLabel.backgroundColor = .clear + NSLayoutConstraint.activate([ + self.leadingAnchor.constraint(equalTo: statusImageView.leadingAnchor), + statusImageView.trailingAnchor.constraint(equalTo: nameLabel.leadingAnchor), + statusImageView.widthAnchor.constraint(equalToConstant: 20), + nameLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor), + statusImageView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + nameLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor) + ]) + } + + required init?(coder decoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + static func image(for 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) + } + } + + override func prepareForReuse() { + nameLabel.stringValue = "" + statusImageView.image = nil + } +} diff --git a/Sources/WireGuardApp/UI/macOS/View/highlighter.c b/Sources/WireGuardApp/UI/macOS/View/highlighter.c new file mode 100644 index 0000000..e0d4e04 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/highlighter.c @@ -0,0 +1,641 @@ +// SPDX-License-Identifier: GPL-2.0 +/* + * Copyright (C) 2015-2020 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved. + */ + +#include <stdbool.h> +#include <stdint.h> +#include <stdlib.h> +#include <string.h> +#include <errno.h> +#include "highlighter.h" + +typedef struct { + const char *s; + size_t len; +} string_span_t; + +static bool is_decimal(char c) +{ + return c >= '0' && c <= '9'; +} + +static bool is_hexadecimal(char c) +{ + return is_decimal(c) || ((c | 32) >= 'a' && (c | 32) <= 'f'); +} + +static bool is_alphabet(char c) +{ + return (c | 32) >= 'a' && (c | 32) <= 'z'; +} + +static bool is_same(string_span_t s, const char *c) +{ + size_t len = strlen(c); + + if (len != s.len) + return false; + return !memcmp(s.s, c, len); +} + +static bool is_caseless_same(string_span_t s, const char *c) +{ + size_t len = strlen(c); + + if (len != s.len) + return false; + for (size_t i = 0; i < len; ++i) { + char a = c[i], b = s.s[i]; + if ((unsigned)a - 'a' < 26) + a &= 95; + if ((unsigned)b - 'a' < 26) + b &= 95; + if (a != b) + return false; + } + return true; +} + +static bool is_valid_key(string_span_t s) +{ + if (s.len != 44 || s.s[43] != '=') + return false; + + for (size_t i = 0; i < 42; ++i) { + if (!is_decimal(s.s[i]) && !is_alphabet(s.s[i]) && + s.s[i] != '/' && s.s[i] != '+') + return false; + } + switch (s.s[42]) { + case 'A': + case 'E': + case 'I': + case 'M': + case 'Q': + case 'U': + case 'Y': + case 'c': + case 'g': + case 'k': + case 'o': + case 's': + case 'w': + case '4': + case '8': + case '0': + break; + default: + return false; + } + return true; +} + +static bool is_valid_hostname(string_span_t s) +{ + size_t num_digit = 0, num_entity = s.len; + + if (s.len > 63 || !s.len) + return false; + if (s.s[0] == '-' || s.s[s.len - 1] == '-') + return false; + if (s.s[0] == '.' || s.s[s.len - 1] == '.') + return false; + + for (size_t i = 0; i < s.len; ++i) { + if (is_decimal(s.s[i])) { + ++num_digit; + continue; + } + if (s.s[i] == '.') { + --num_entity; + continue; + } + + if (!is_alphabet(s.s[i]) && s.s[i] != '-') + return false; + + if (i && s.s[i] == '.' && s.s[i - 1] == '.') + return false; + } + return num_digit != num_entity; +} + +static bool is_valid_ipv4(string_span_t s) +{ + for (size_t j, i = 0, pos = 0; i < 4 && pos < s.len; ++i) { + uint32_t val = 0; + + for (j = 0; j < 3 && pos + j < s.len && is_decimal(s.s[pos + j]); ++j) + val = 10 * val + s.s[pos + j] - '0'; + if (j == 0 || (j > 1 && s.s[pos] == '0') || val > 255) + return false; + if (pos + j == s.len && i == 3) + return true; + if (s.s[pos + j] != '.') + return false; + pos += j + 1; + } + return false; +} + +static bool is_valid_ipv6(string_span_t s) +{ + size_t pos = 0; + bool seen_colon = false; + + if (s.len < 2) + return false; + if (s.s[pos] == ':' && s.s[++pos] != ':') + return false; + if (s.s[s.len - 1] == ':' && s.s[s.len - 2] != ':') + return false; + + for (size_t j, i = 0; pos < s.len; ++i) { + if (s.s[pos] == ':' && !seen_colon) { + seen_colon = true; + if (++pos == s.len) + break; + if (i == 7) + return false; + continue; + } + for (j = 0; j < 4 && pos + j < s.len && is_hexadecimal(s.s[pos + j]); ++j); + if (j == 0) + return false; + if (pos + j == s.len && (seen_colon || i == 7)) + break; + if (i == 7) + return false; + if (s.s[pos + j] != ':') { + if (s.s[pos + j] != '.' || (i < 6 && !seen_colon)) + return false; + return is_valid_ipv4((string_span_t){ s.s + pos, s.len - pos }); + } + pos += j + 1; + } + return true; +} + +static bool is_valid_uint(string_span_t s, bool support_hex, uint64_t min, uint64_t max) +{ + uint64_t val = 0; + + /* Bound this around 32 bits, so that we don't have to write overflow logic. */ + if (s.len > 10 || !s.len) + return false; + + if (support_hex && s.len > 2 && s.s[0] == '0' && s.s[1] == 'x') { + for (size_t i = 2; i < s.len; ++i) { + if ((unsigned)s.s[i] - '0' < 10) + val = 16 * val + (s.s[i] - '0'); + else if (((unsigned)s.s[i] | 32) - 'a' < 6) + val = 16 * val + (s.s[i] | 32) - 'a' + 10; + else + return false; + } + } else { + for (size_t i = 0; i < s.len; ++i) { + if (!is_decimal(s.s[i])) + return false; + val = 10 * val + s.s[i] - '0'; + } + } + return val <= max && val >= min; +} + +static bool is_valid_port(string_span_t s) +{ + return is_valid_uint(s, false, 0, 65535); +} + +static bool is_valid_mtu(string_span_t s) +{ + return is_valid_uint(s, false, 576, 65535); +} + +static bool is_valid_persistentkeepalive(string_span_t s) +{ + if (is_same(s, "off")) + return true; + return is_valid_uint(s, false, 0, 65535); +} + +#ifndef MOBILE_WGQUICK_SUBSET + +static bool is_valid_fwmark(string_span_t s) +{ + if (is_same(s, "off")) + return true; + return is_valid_uint(s, true, 0, 4294967295); +} + +static bool is_valid_table(string_span_t s) +{ + if (is_same(s, "auto")) + return true; + if (is_same(s, "off")) + return true; + /* This pretty much invalidates the other checks, but rt_names.c's + * fread_id_name does no validation aside from this. */ + if (s.len < 512) + return true; + return is_valid_uint(s, false, 0, 4294967295); +} + +static bool is_valid_saveconfig(string_span_t s) +{ + return is_same(s, "true") || is_same(s, "false"); +} + +static bool is_valid_prepostupdown(string_span_t s) +{ + /* It's probably not worthwhile to try to validate a bash expression. + * So instead we just demand non-zero length. */ + return s.len; +} +#endif + +static bool is_valid_scope(string_span_t s) +{ + if (s.len > 64 || !s.len) + return false; + for (size_t i = 0; i < s.len; ++i) { + if (!is_alphabet(s.s[i]) && !is_decimal(s.s[i]) && + s.s[i] != '_' && s.s[i] != '=' && s.s[i] != '+' && + s.s[i] != '.' && s.s[i] != '-') + return false; + } + return true; +} + +static bool is_valid_endpoint(string_span_t s) +{ + + if (!s.len) + return false; + + if (s.s[0] == '[') { + bool seen_scope = false; + string_span_t hostspan = { s.s + 1, 0 }; + + for (size_t i = 1; i < s.len; ++i) { + if (s.s[i] == '%') { + if (seen_scope) + return false; + seen_scope = true; + if (!is_valid_ipv6(hostspan)) + return false; + hostspan = (string_span_t){ s.s + i + 1, 0 }; + } else if (s.s[i] == ']') { + if (seen_scope) { + if (!is_valid_scope(hostspan)) + return false; + } else if (!is_valid_ipv6(hostspan)) { + return false; + } + if (i == s.len - 1 || s.s[i + 1] != ':') + return false; + return is_valid_port((string_span_t){ s.s + i + 2, s.len - i - 2 }); + } else { + ++hostspan.len; + } + } + return false; + } + for (size_t i = 0; i < s.len; ++i) { + if (s.s[i] == ':') { + string_span_t host = { s.s, i }, port = { s.s + i + 1, s.len - i - 1}; + return is_valid_port(port) && (is_valid_ipv4(host) || is_valid_hostname(host)); + } + } + return false; +} + +static bool is_valid_network(string_span_t s) +{ + for (size_t i = 0; i < s.len; ++i) { + if (s.s[i] == '/') { + string_span_t ip = { s.s, i }, cidr = { s.s + i + 1, s.len - i - 1}; + uint16_t cidrval = 0; + + if (cidr.len > 3 || !cidr.len) + return false; + + for (size_t j = 0; j < cidr.len; ++j) { + if (!is_decimal(cidr.s[j])) + return false; + cidrval = 10 * cidrval + cidr.s[j] - '0'; + } + if (is_valid_ipv4(ip)) + return cidrval <= 32; + else if (is_valid_ipv6(ip)) + return cidrval <= 128; + return false; + } + } + return is_valid_ipv4(s) || is_valid_ipv6(s); +} + +static bool is_valid_dns(string_span_t s) +{ + return is_valid_ipv4(s) || is_valid_ipv6(s); +} + +enum field { + InterfaceSection, + PrivateKey, + ListenPort, + Address, + DNS, + MTU, +#ifndef MOBILE_WGQUICK_SUBSET + FwMark, + Table, + PreUp, PostUp, PreDown, PostDown, + SaveConfig, +#endif + + PeerSection, + PublicKey, + PresharedKey, + AllowedIPs, + Endpoint, + PersistentKeepalive, + + Invalid +}; + +static enum field section_for_field(enum field t) +{ + if (t > InterfaceSection && t < PeerSection) + return InterfaceSection; + if (t > PeerSection && t < Invalid) + return PeerSection; + return Invalid; +} + +static enum field get_field(string_span_t s) +{ +#define check_enum(t) do { if (is_caseless_same(s, #t)) return t; } while (0) + check_enum(PrivateKey); + check_enum(ListenPort); + check_enum(Address); + check_enum(DNS); + check_enum(MTU); + check_enum(PublicKey); + check_enum(PresharedKey); + check_enum(AllowedIPs); + check_enum(Endpoint); + check_enum(PersistentKeepalive); +#ifndef MOBILE_WGQUICK_SUBSET + check_enum(FwMark); + check_enum(Table); + check_enum(PreUp); + check_enum(PostUp); + check_enum(PreDown); + check_enum(PostDown); + check_enum(SaveConfig); +#endif + return Invalid; +#undef check_enum +} + +static enum field get_sectiontype(string_span_t s) +{ + if (is_caseless_same(s, "[Peer]")) + return PeerSection; + if (is_caseless_same(s, "[Interface]")) + return InterfaceSection; + return Invalid; +} + +struct highlight_span_array { + size_t len, capacity; + struct highlight_span *spans; +}; + +/* A useful OpenBSD-ism. */ +static void *realloc_array(void *optr, size_t nmemb, size_t size) +{ + if ((nmemb >= (size_t)1 << (sizeof(size_t) * 4) || + size >= (size_t)1 << (sizeof(size_t) * 4)) && + nmemb > 0 && SIZE_MAX / nmemb < size) { + errno = ENOMEM; + return NULL; + } + return realloc(optr, size * nmemb); +} + +static bool append_highlight_span(struct highlight_span_array *a, const char *o, string_span_t s, enum highlight_type t) +{ + if (!s.len) + return true; + if (a->len >= a->capacity) { + struct highlight_span *resized; + + a->capacity = a->capacity ? a->capacity * 2 : 64; + resized = realloc_array(a->spans, a->capacity, sizeof(*resized)); + if (!resized) { + free(a->spans); + memset(a, 0, sizeof(*a)); + return false; + } + a->spans = resized; + } + a->spans[a->len++] = (struct highlight_span){ t, s.s - o, s.len }; + return true; +} + +static void highlight_multivalue_value(struct highlight_span_array *ret, const string_span_t parent, const string_span_t s, enum field section) +{ + switch (section) { + case DNS: + append_highlight_span(ret, parent.s, s, is_valid_dns(s) ? HighlightIP : HighlightError); + break; + case Address: + case AllowedIPs: { + size_t slash; + + if (!is_valid_network(s)) { + append_highlight_span(ret, parent.s, s, HighlightError); + break; + } + for (slash = 0; slash < s.len; ++slash) { + if (s.s[slash] == '/') + break; + } + if (slash == s.len) { + append_highlight_span(ret, parent.s, s, HighlightIP); + } else { + append_highlight_span(ret, parent.s, (string_span_t){ s.s, slash }, HighlightIP); + append_highlight_span(ret, parent.s, (string_span_t){ s.s + slash, 1 }, HighlightDelimiter); + append_highlight_span(ret, parent.s, (string_span_t){ s.s + slash + 1, s.len - slash - 1 }, HighlightCidr); + } + break; + } + default: + append_highlight_span(ret, parent.s, s, HighlightError); + } +} + +static void highlight_multivalue(struct highlight_span_array *ret, const string_span_t parent, const string_span_t s, enum field section) +{ + string_span_t current_span = { s.s, 0 }; + size_t len_at_last_space = 0; + + for (size_t i = 0; i < s.len; ++i) { + if (s.s[i] == ',') { + current_span.len = len_at_last_space; + highlight_multivalue_value(ret, parent, current_span, section); + append_highlight_span(ret, parent.s, (string_span_t){ s.s + i, 1 }, HighlightDelimiter); + len_at_last_space = 0; + current_span = (string_span_t){ s.s + i + 1, 0 }; + } else if (s.s[i] == ' ' || s.s[i] == '\t') { + if (&s.s[i] == current_span.s && !current_span.len) + ++current_span.s; + else + ++current_span.len; + } else { + len_at_last_space = ++current_span.len; + } + } + current_span.len = len_at_last_space; + if (current_span.len) + highlight_multivalue_value(ret, parent, current_span, section); + else if (ret->spans[ret->len - 1].type == HighlightDelimiter) + ret->spans[ret->len - 1].type = HighlightError; +} + +static void highlight_value(struct highlight_span_array *ret, const string_span_t parent, const string_span_t s, enum field section) +{ + switch (section) { + case PrivateKey: + append_highlight_span(ret, parent.s, s, is_valid_key(s) ? HighlightPrivateKey : HighlightError); + break; + case PublicKey: + append_highlight_span(ret, parent.s, s, is_valid_key(s) ? HighlightPublicKey : HighlightError); + break; + case PresharedKey: + append_highlight_span(ret, parent.s, s, is_valid_key(s) ? HighlightPresharedKey : HighlightError); + break; + case MTU: + append_highlight_span(ret, parent.s, s, is_valid_mtu(s) ? HighlightMTU : HighlightError); + break; +#ifndef MOBILE_WGQUICK_SUBSET + case SaveConfig: + append_highlight_span(ret, parent.s, s, is_valid_saveconfig(s) ? HighlightSaveConfig : HighlightError); + break; + case FwMark: + append_highlight_span(ret, parent.s, s, is_valid_fwmark(s) ? HighlightFwMark : HighlightError); + break; + case Table: + append_highlight_span(ret, parent.s, s, is_valid_table(s) ? HighlightTable : HighlightError); + break; + case PreUp: + case PostUp: + case PreDown: + case PostDown: + append_highlight_span(ret, parent.s, s, is_valid_prepostupdown(s) ? HighlightCmd : HighlightError); + break; +#endif + case ListenPort: + append_highlight_span(ret, parent.s, s, is_valid_port(s) ? HighlightPort : HighlightError); + break; + case PersistentKeepalive: + append_highlight_span(ret, parent.s, s, is_valid_persistentkeepalive(s) ? HighlightKeepalive : HighlightError); + break; + case Endpoint: { + size_t colon; + + if (!is_valid_endpoint(s)) { + append_highlight_span(ret, parent.s, s, HighlightError); + break; + } + for (colon = s.len; colon --> 0;) { + if (s.s[colon] == ':') + break; + } + append_highlight_span(ret, parent.s, (string_span_t){ s.s, colon }, HighlightHost); + append_highlight_span(ret, parent.s, (string_span_t){ s.s + colon, 1 }, HighlightDelimiter); + append_highlight_span(ret, parent.s, (string_span_t){ s.s + colon + 1, s.len - colon - 1 }, HighlightPort); + break; + } + case Address: + case DNS: + case AllowedIPs: + highlight_multivalue(ret, parent, s, section); + break; + default: + append_highlight_span(ret, parent.s, s, HighlightError); + } +} + +struct highlight_span *highlight_config(const char *config) +{ + struct highlight_span_array ret = { 0 }; + const string_span_t s = { config, strlen(config) }; + string_span_t current_span = { s.s, 0 }; + enum field current_section = Invalid, current_field = Invalid; + enum { OnNone, OnKey, OnValue, OnComment, OnSection } state = OnNone; + size_t len_at_last_space = 0, equals_location = 0; + + for (size_t i = 0; i <= s.len; ++i) { + if (i == s.len || s.s[i] == '\n' || (state != OnComment && s.s[i] == '#')) { + if (state == OnKey) { + current_span.len = len_at_last_space; + append_highlight_span(&ret, s.s, current_span, HighlightError); + } else if (state == OnValue) { + if (current_span.len) { + append_highlight_span(&ret, s.s, (string_span_t){ s.s + equals_location, 1 }, HighlightDelimiter); + current_span.len = len_at_last_space; + highlight_value(&ret, s, current_span, current_field); + } else { + append_highlight_span(&ret, s.s, (string_span_t){ s.s + equals_location, 1 }, HighlightError); + } + } else if (state == OnSection) { + current_span.len = len_at_last_space; + current_section = get_sectiontype(current_span); + append_highlight_span(&ret, s.s, current_span, current_section == Invalid ? HighlightError : HighlightSection); + } else if (state == OnComment) { + append_highlight_span(&ret, s.s, current_span, HighlightComment); + } + if (i == s.len) + break; + len_at_last_space = 0; + current_field = Invalid; + if (s.s[i] == '#') { + current_span = (string_span_t){ s.s + i, 1 }; + state = OnComment; + } else { + current_span = (string_span_t){ s.s + i + 1, 0 }; + state = OnNone; + } + } else if (state == OnComment) { + ++current_span.len; + } else if (s.s[i] == ' ' || s.s[i] == '\t') { + if (&s.s[i] == current_span.s && !current_span.len) + ++current_span.s; + else + ++current_span.len; + } else if (s.s[i] == '=' && state == OnKey) { + current_span.len = len_at_last_space; + current_field = get_field(current_span); + enum field section = section_for_field(current_field); + if (section == Invalid || current_field == Invalid || section != current_section) + append_highlight_span(&ret, s.s, current_span, HighlightError); + else + append_highlight_span(&ret, s.s, current_span, HighlightField); + equals_location = i; + current_span = (string_span_t){ s.s + i + 1, 0 }; + state = OnValue; + } else { + if (state == OnNone) + state = s.s[i] == '[' ? OnSection : OnKey; + len_at_last_space = ++current_span.len; + } + } + + append_highlight_span(&ret, s.s, (string_span_t){ s.s, -1 }, HighlightEnd); + return ret.spans; +} diff --git a/Sources/WireGuardApp/UI/macOS/View/highlighter.h b/Sources/WireGuardApp/UI/macOS/View/highlighter.h new file mode 100644 index 0000000..885db2d --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/View/highlighter.h @@ -0,0 +1,38 @@ +/* SPDX-License-Identifier: GPL-2.0 */ +/* + * Copyright (C) 2015-2019 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved. + */ + +#include <sys/types.h> +#define MOBILE_WGQUICK_SUBSET + +enum highlight_type { + HighlightSection, + HighlightField, + HighlightPrivateKey, + HighlightPublicKey, + HighlightPresharedKey, + HighlightIP, + HighlightCidr, + HighlightHost, + HighlightPort, + HighlightMTU, + HighlightKeepalive, + HighlightComment, + HighlightDelimiter, +#ifndef MOBILE_WGQUICK_SUBSET + HighlightTable, + HighlightFwMark, + HighlightSaveConfig, + HighlightCmd, +#endif + HighlightError, + HighlightEnd +}; + +struct highlight_span { + enum highlight_type type; + size_t start, len; +}; + +struct highlight_span *highlight_config(const char *config); 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?() + } +} diff --git a/Sources/WireGuardApp/UI/macOS/WireGuard.entitlements b/Sources/WireGuardApp/UI/macOS/WireGuard.entitlements new file mode 100644 index 0000000..a39ba80 --- /dev/null +++ b/Sources/WireGuardApp/UI/macOS/WireGuard.entitlements @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>com.apple.developer.networking.networkextension</key> + <array> + <string>packet-tunnel-provider</string> + </array> + <key>com.apple.security.app-sandbox</key> + <true/> + <key>com.apple.security.application-groups</key> + <array> + <string>$(DEVELOPMENT_TEAM).group.$(APP_ID_MACOS)</string> + </array> + <key>com.apple.security.files.user-selected.read-write</key> + <true/> +</dict> +</plist> |