Move all source files to Sources/ and rename WireGuardKit targets
Signed-off-by: Andrej Mihajlov <and@mullvad.net>
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 865 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "wireguard.pdf"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
},
|
||||
"properties" : {
|
||||
"preserves-vector-representation" : true
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?()
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 978 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 881 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 953 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 942 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 958 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
@@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||