Compare commits
353 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea827e2ebd | |||
| 91b1734b7a | |||
| bac4851e95 | |||
| 0e2556544e | |||
| 2ec51ba8cf | |||
| 998bbd73e4 | |||
| 38a6ba7091 | |||
| 407b367c8d | |||
| a231410c52 | |||
| f518c00722 | |||
| 0539929d0c | |||
| 05547861b6 | |||
| 9eed5fd898 | |||
| 1b8b9ed7ee | |||
| ef6af03412 | |||
| a99a755c34 | |||
| ecd66defe5 | |||
| 1f3ec042e0 | |||
| 631e9bb70d | |||
| 446c3e3698 | |||
| 02e9172940 | |||
| 8676f3a663 | |||
| 394a0cbeb0 | |||
| 868fee0477 | |||
| 0cddb562fc | |||
| bebcaa012b | |||
| ed8dc516dc | |||
| 8c3557a907 | |||
| a26d620f11 | |||
| 30a73a75fd | |||
| 71d26b4122 | |||
| 71525c9d4e | |||
| 02a96d4566 | |||
| 466db151b8 | |||
| 1be133f269 | |||
| 80de2ac6ac | |||
| 8a6a60482c | |||
| 657ec34d19 | |||
| f7a31ca7bb | |||
| 3c61db3a21 | |||
| 618d89941a | |||
| cbc602245e | |||
| ca61e09536 | |||
| 4ff6105053 | |||
| 4134baced1 | |||
| 0c5739db82 | |||
| 1f51ff6b17 | |||
| 08e5d65045 | |||
| 1189b3d700 | |||
| f292a0ec7a | |||
| 3b29578524 | |||
| 70ac48ceba | |||
| acecc70397 | |||
| b0bb2e993a | |||
| d1f83d167e | |||
| a796c6c485 | |||
| 6ad3487a9d | |||
| eabeb8ff05 | |||
| 52eec55d36 | |||
| 3c80490273 | |||
| c36a9e4ffd | |||
| 812e660491 | |||
| 2fe9f83ba5 | |||
| 22625e8cc4 | |||
| ab3cbee6a2 | |||
| fadef52e1b | |||
| 19f353127e | |||
| e5a76be6fd | |||
| 76527ef0f8 | |||
| 11e44f9ae5 | |||
| 77d4a02139 | |||
| 54f45cb3f8 | |||
| 704de3b26c | |||
| d05b735703 | |||
| 9f362e8cb0 | |||
| 2677efc9bf | |||
| 2aad8cf0e8 | |||
| 668c4a475c | |||
| 557b093232 | |||
| 6f9fa35c5a | |||
| d37e230ee0 | |||
| e812683d6c | |||
| 6d8e5cf3ed | |||
| 244a2df2dd | |||
| 0848765f50 | |||
| 8951ea338f | |||
| 273ee04450 | |||
| e2b068af1a | |||
| 4e2f4e7124 | |||
| 6509009afc | |||
| 0b2a4c2811 | |||
| e1198b6e08 | |||
| d7a88300f6 | |||
| b22aeaeb20 | |||
| e0798b8a97 | |||
| 1bc2177883 | |||
| 15517c4c3d | |||
| f6c9ce2d71 | |||
| 5aa0f1a25f | |||
| 602639ee25 | |||
| d06887d435 | |||
| 470e4e7f7f | |||
| 86165d25f7 | |||
| 69b973efdc | |||
| dc8f27c5c3 | |||
| 796342ddec | |||
| b7a5b8eff1 | |||
| 8a0245190c | |||
| 98775bf8a0 | |||
| 7d5eb476e4 | |||
| c477d24d67 | |||
| bf9cb092a9 | |||
| dbd5108475 | |||
| 47c5f23a0a | |||
| a2871e63a7 | |||
| 811714e21a | |||
| f63c9fd598 | |||
| e29cf19fdd | |||
| 26ea353933 | |||
| cd5427ef92 | |||
| 595f7943e2 | |||
| 55f022688d | |||
| 96bd50504b | |||
| 97fec0d992 | |||
| 470532d146 | |||
| 321b88864c | |||
| ba731e0099 | |||
| 70848c04de | |||
| 13e8c6b178 | |||
| 53e915c578 | |||
| bc8ea55023 | |||
| e0af06844d | |||
| 8980b5a524 | |||
| df8ab96139 | |||
| 4a8366421f | |||
| c9ee549a2e | |||
| f5059ce55b | |||
| 5a73244ec9 | |||
| 922b6f76b2 | |||
| fc9e2de72c | |||
| 80977b95de | |||
| bbeb732ef3 | |||
| 94c4922913 | |||
| fc03c635c1 | |||
| b0612df990 | |||
| c2a6241b5c | |||
| 96fa6d3ba6 | |||
| 64fe415879 | |||
| 59bfa7f1df | |||
| c2633987c3 | |||
| f7b2f73015 | |||
| c72f7056b3 | |||
| cb778fe7e0 | |||
| f3c2904241 | |||
| df8b400850 | |||
| 252d940d34 | |||
| efb64b1959 | |||
| dfc4b37518 | |||
| 60cfceec4f | |||
| 361830a69e | |||
| f6ea25573b | |||
| de12c27d5b | |||
| a221cb566b | |||
| f33cd0b6fd | |||
| 38bb0faf86 | |||
| 8d9c5e2950 | |||
| 09f4be17de | |||
| 60e18dfdd5 | |||
| 5bc0c5b2b4 | |||
| 4a4eeb4a21 | |||
| ada7db3dca | |||
| c946c0ea48 | |||
| 4a4690b5fa | |||
| 37fce31d16 | |||
| 7934d6b0c7 | |||
| 98e9088aba | |||
| 2c81c3a379 | |||
| 04f6ee0f11 | |||
| 545f8c88f4 | |||
| 6a27626fc0 | |||
| fb1607d4a2 | |||
| 51a2c272b9 | |||
| b5751b6321 | |||
| 110012dbcc | |||
| 5c7a149167 | |||
| 629009d3be | |||
| d7d4355f5e | |||
| 55d6961a2f | |||
| c8cd663a05 | |||
| a754c4d7ab | |||
| 95415cd917 | |||
| b32b897181 | |||
| d5c1acb57e | |||
| 573f9640de | |||
| f6772dc353 | |||
| 0cbe66df99 | |||
| 3cd33ebe8f | |||
| c7a40d3cb0 | |||
| d02b0fd10e | |||
| 09d7a5229a | |||
| 1ca5407b85 | |||
| 10982a57ef | |||
| 5f15b664fc | |||
| 49f287439e | |||
| 150cd119c7 | |||
| e2384e143c | |||
| 52c59704de | |||
| 0b828f9b96 | |||
| 51a3e5c0b4 | |||
| c9c343cde2 | |||
| c563a24348 | |||
| 808852c547 | |||
| 035055ef0a | |||
| 508ba44576 | |||
| 999b761ed0 | |||
| 129f94dccd | |||
| aaee3c5cbe | |||
| dddbf3b370 | |||
| d29f47fc9b | |||
| e6e1795d08 | |||
| fd29cf3402 | |||
| 56ad5f74e9 | |||
| 49bf55021f | |||
| 0bec5b04b0 | |||
| d36e7e27ff | |||
| b0b6866c51 | |||
| 9098cd1161 | |||
| 8365adf435 | |||
| 9d9859248e | |||
| f7e9f4d631 | |||
| f2000aa1da | |||
| 82de3e3090 | |||
| 41a4c6362a | |||
| aede9f6e45 | |||
| 1eeed89174 | |||
| c1c5f7a7c7 | |||
| 4ed646973e | |||
| 9295895e3a | |||
| 7b9d4cb9e3 | |||
| 1fecd8eb6c | |||
| accf60b82f | |||
| f6af9d9ffb | |||
| 78b38a4eba | |||
| ec031b1f19 | |||
| 8553723e04 | |||
| 38445114e0 | |||
| a21c569e9f | |||
| 0552d75aa1 | |||
| e47a8232d8 | |||
| f818cdd963 | |||
| 28ce4d5164 | |||
| c2131cb757 | |||
| b23578dc7c | |||
| a89ad95901 | |||
| 5618c465a2 | |||
| de08978a80 | |||
| 9268c0c4bc | |||
| 5c501ac9a6 | |||
| 35450bf407 | |||
| f93c9797ea | |||
| bba6d2f919 | |||
| fa51e3f1d1 | |||
| 04a8c2ff5a | |||
| 4e516d6769 | |||
| fab7af6f38 | |||
| 3ae9fb538d | |||
| 78eaab8b5b | |||
| 20f8abdf04 | |||
| 2582ddd6f6 | |||
| 9556901a33 | |||
| ed9b4c85ed | |||
| fc452753a7 | |||
| d775682ef4 | |||
| ae339a000e | |||
| a80cf6a0dc | |||
| 727992f5d2 | |||
| 2a22c0f2d6 | |||
| b3f5635f4e | |||
| 946524aa8b | |||
| 1450538846 | |||
| 5a08c67f33 | |||
| 1e9c806614 | |||
| ccd8cfe478 | |||
| cb051f695d | |||
| 7a24f18eb7 | |||
| 83c95dc26d | |||
| e0bc5e12b3 | |||
| c4263da231 | |||
| 1eb3fd4de0 | |||
| 73be704b01 | |||
| 2699c613bd | |||
| 74e983ea6f | |||
| 48552d2663 | |||
| 501e412b84 | |||
| 77a26e4cd2 | |||
| 05d750539b | |||
| 7323a00612 | |||
| a6912ca7a2 | |||
| b256acc372 | |||
| 7e093575a4 | |||
| 740ffd68b6 | |||
| f67e1d8fc4 | |||
| 33af8845b6 | |||
| 154774ada2 | |||
| 3bddab8a9e | |||
| f9239dae75 | |||
| 642b627d27 | |||
| 38accad27d | |||
| bf58159d99 | |||
| efd4b28a0d | |||
| ae565db371 | |||
| e199ed0d6c | |||
| ba1d0c05be | |||
| 12503ae51d | |||
| ae7fb7323f | |||
| 5ae9eec555 | |||
| 6528a581de | |||
| e11224f394 | |||
| 5971c197bd | |||
| ecbab37e0e | |||
| 4eec53d6d3 | |||
| 8a916beb38 | |||
| e4ac48bc75 | |||
| d06cff2a36 | |||
| de14b76b4d | |||
| af78fa9a1c | |||
| 7ef12d93a7 | |||
| 8259145f85 | |||
| 034a1a12f7 | |||
| 9bc7e58487 | |||
| 27265fc222 | |||
| 63e67a5e13 | |||
| bde984625c | |||
| 1ded24f0e0 | |||
| 1fd0c56f08 | |||
| e59dbe6364 | |||
| 4d63a3e9bd | |||
| 9946d8f989 | |||
| 15b6cf5412 | |||
| 851bd8102d | |||
| 0d7a585bf7 | |||
| 663bb02c68 | |||
| b491b9c371 | |||
| 707f292e4e | |||
| aa41bd7d6c | |||
| ec5be76d06 | |||
| 692b0c6519 | |||
| bb836b8fdc | |||
| 8eda4641ca | |||
| 60e13ddbf6 | |||
| 0dcb285b67 | |||
| 6838dc3a74 | |||
| 46f4be6087 |
@@ -19,6 +19,7 @@ DerivedData
|
||||
*.perspectivev3
|
||||
!default.perspectivev3
|
||||
xcuserdata
|
||||
*.xcscheme
|
||||
|
||||
## Other
|
||||
*.xccheckout
|
||||
@@ -38,3 +39,9 @@ fastlane/screenshots
|
||||
fastlane/test_output
|
||||
Preview.html
|
||||
output
|
||||
|
||||
# Wireguard specific
|
||||
WireGuard/WireGuard/Config/Developer.xcconfig
|
||||
|
||||
# Vim
|
||||
.*.sw*
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
disabled_rules:
|
||||
- line_length
|
||||
- trailing_comma
|
||||
excluded:
|
||||
- Pods
|
||||
file_length:
|
||||
warning: 500
|
||||
type_name:
|
||||
min_length: 2 # only warning
|
||||
max_length: # warning and error
|
||||
warning: 50
|
||||
error: 60
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
|
||||
@@ -23,6 +23,12 @@ $ cp WireGuard/WireGuard/Config/Developer.xcconfig.template WireGuard/WireGuard/
|
||||
$ vim WireGuard/WireGuard/Config/Developer.xcconfig
|
||||
```
|
||||
|
||||
- Install swiftlint:
|
||||
|
||||
```
|
||||
$ brew install swiftlint
|
||||
```
|
||||
|
||||
- Open project in XCode:
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
disabled_rules:
|
||||
- line_length
|
||||
- trailing_whitespace
|
||||
- todo
|
||||
- cyclomatic_complexity
|
||||
- file_length
|
||||
- type_body_length
|
||||
- function_body_length
|
||||
- nesting
|
||||
opt_in_rules:
|
||||
- empty_count
|
||||
- empty_string
|
||||
- implicitly_unwrapped_optional
|
||||
- legacy_random
|
||||
- let_var_whitespace
|
||||
- literal_expression_end_indentation
|
||||
- override_in_extension
|
||||
- redundant_type_annotation
|
||||
- toggle_bool
|
||||
- unneeded_parentheses_in_closure_argument
|
||||
- unused_import
|
||||
- trailing_closure
|
||||
variable_name:
|
||||
min_length:
|
||||
warning: 0
|
||||
@@ -1,27 +1,52 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
extension FileManager {
|
||||
static var networkExtensionLogFileURL: URL? {
|
||||
guard let appGroupId = Bundle.main.object(forInfoDictionaryKey: "com.wireguard.ios.app_group_id") as? String else {
|
||||
os_log("Can't obtain app group id from bundle", log: OSLog.default, type: .error)
|
||||
static var appGroupId: String? {
|
||||
#if os(iOS)
|
||||
let appGroupIdInfoDictionaryKey = "com.wireguard.ios.app_group_id"
|
||||
#elseif os(macOS)
|
||||
let appGroupIdInfoDictionaryKey = "com.wireguard.macos.app_group_id"
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
return Bundle.main.object(forInfoDictionaryKey: appGroupIdInfoDictionaryKey) as? String
|
||||
}
|
||||
private static var sharedFolderURL: URL? {
|
||||
guard let appGroupId = FileManager.appGroupId else {
|
||||
os_log("Cannot obtain app group ID from bundle", log: OSLog.default, type: .error)
|
||||
return nil
|
||||
}
|
||||
guard let sharedFolderURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
|
||||
os_log("Can't obtain shared folder URL", log: OSLog.default, type: .error)
|
||||
wg_log(.error, message: "Cannot obtain shared folder URL")
|
||||
return nil
|
||||
}
|
||||
return sharedFolderURL.appendingPathComponent("last-activated-tunnel-log.txt")
|
||||
return sharedFolderURL
|
||||
}
|
||||
|
||||
static var networkExtensionLogFileURL: URL? {
|
||||
return sharedFolderURL?.appendingPathComponent("tunnel-log.bin")
|
||||
}
|
||||
|
||||
static var networkExtensionLastErrorFileURL: URL? {
|
||||
return sharedFolderURL?.appendingPathComponent("last-error.txt")
|
||||
}
|
||||
|
||||
static var appLogFileURL: URL? {
|
||||
guard let documentDirURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
|
||||
wg_log(.error, message: "Cannot obtain app documents folder URL")
|
||||
return nil
|
||||
}
|
||||
return documentDirURL.appendingPathComponent("app-log.bin")
|
||||
}
|
||||
|
||||
static func deleteFile(at url: URL) -> Bool {
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
} catch(let e) {
|
||||
os_log("Failed to delete file '%{public}@': %{public}@", log: OSLog.default, type: .debug, url.absoluteString, e.localizedDescription)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
class Keychain {
|
||||
static func openReference(called ref: Data) -> String? {
|
||||
var result: CFTypeRef?
|
||||
let ret = SecItemCopyMatching([kSecClass as String: kSecClassGenericPassword,
|
||||
kSecValuePersistentRef as String: ref,
|
||||
kSecReturnData as String: true] as CFDictionary,
|
||||
&result)
|
||||
if ret != errSecSuccess || result == nil {
|
||||
wg_log(.error, message: "Unable to open config from keychain: \(ret)")
|
||||
return nil
|
||||
}
|
||||
guard let data = result as? Data else { return nil }
|
||||
return String(data: data, encoding: String.Encoding.utf8)
|
||||
}
|
||||
|
||||
static func makeReference(containing value: String, called name: String, previouslyReferencedBy oldRef: Data? = nil) -> Data? {
|
||||
var ret: OSStatus
|
||||
guard var id = Bundle.main.bundleIdentifier else {
|
||||
wg_log(.error, staticMessage: "Unable to determine bundle identifier")
|
||||
return nil
|
||||
}
|
||||
if id.hasSuffix(".network-extension") {
|
||||
id.removeLast(".network-extension".count)
|
||||
}
|
||||
var items: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrLabel as String: "WireGuard Tunnel: " + name,
|
||||
kSecAttrAccount as String: name + ": " + UUID().uuidString,
|
||||
kSecAttrDescription as String: "wg-quick(8) config",
|
||||
kSecAttrService as String: id,
|
||||
kSecValueData as String: value.data(using: .utf8) as Any,
|
||||
kSecReturnPersistentRef as String: true]
|
||||
|
||||
#if os(iOS)
|
||||
items[kSecAttrAccessGroup as String] = FileManager.appGroupId
|
||||
items[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||
#elseif os(macOS)
|
||||
items[kSecAttrSynchronizable as String] = false
|
||||
items[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
|
||||
guard let extensionPath = Bundle.main.builtInPlugInsURL?.appendingPathComponent("WireGuardNetworkExtension.appex").path else {
|
||||
wg_log(.error, staticMessage: "Unable to determine app extension path")
|
||||
return nil
|
||||
}
|
||||
var extensionApp: SecTrustedApplication?
|
||||
var mainApp: SecTrustedApplication?
|
||||
ret = SecTrustedApplicationCreateFromPath(extensionPath, &extensionApp)
|
||||
if ret != kOSReturnSuccess || extensionApp == nil {
|
||||
wg_log(.error, message: "Unable to create keychain extension trusted application object: \(ret)")
|
||||
return nil
|
||||
}
|
||||
ret = SecTrustedApplicationCreateFromPath(nil, &mainApp)
|
||||
if ret != errSecSuccess || mainApp == nil {
|
||||
wg_log(.error, message: "Unable to create keychain local trusted application object: \(ret)")
|
||||
return nil
|
||||
}
|
||||
var access: SecAccess?
|
||||
ret = SecAccessCreate((items[kSecAttrLabel as String] as? String)! as CFString,
|
||||
[extensionApp!, mainApp!] as CFArray,
|
||||
&access)
|
||||
if ret != errSecSuccess || access == nil {
|
||||
wg_log(.error, message: "Unable to create keychain ACL object: \(ret)")
|
||||
return nil
|
||||
}
|
||||
items[kSecAttrAccess as String] = access!
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
|
||||
var ref: CFTypeRef?
|
||||
ret = SecItemAdd(items as CFDictionary, &ref)
|
||||
if ret != errSecSuccess || ref == nil {
|
||||
wg_log(.error, message: "Unable to add config to keychain: \(ret)")
|
||||
return nil
|
||||
}
|
||||
if let oldRef = oldRef {
|
||||
deleteReference(called: oldRef)
|
||||
}
|
||||
return ref as? Data
|
||||
}
|
||||
|
||||
static func deleteReference(called ref: Data) {
|
||||
let ret = SecItemDelete([kSecValuePersistentRef as String: ref] as CFDictionary)
|
||||
if ret != errSecSuccess {
|
||||
wg_log(.error, message: "Unable to delete config from keychain: \(ret)")
|
||||
}
|
||||
}
|
||||
|
||||
static func deleteReferences(except whitelist: Set<Data>) {
|
||||
var result: CFTypeRef?
|
||||
let ret = SecItemCopyMatching([kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: Bundle.main.bundleIdentifier as Any,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
kSecReturnPersistentRef as String: true] as CFDictionary,
|
||||
&result)
|
||||
if ret != errSecSuccess || result == nil {
|
||||
return
|
||||
}
|
||||
guard let items = result as? [Data] else { return }
|
||||
for item in items {
|
||||
if !whitelist.contains(item) {
|
||||
deleteReference(called: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func verifyReference(called ref: Data) -> Bool {
|
||||
return SecItemCopyMatching([kSecClass as String: kSecClassGenericPassword,
|
||||
kSecValuePersistentRef as String: ref] as CFDictionary,
|
||||
nil) == errSecSuccess
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
public class Logger {
|
||||
enum LoggerError: Error {
|
||||
case openFailure
|
||||
}
|
||||
|
||||
static var global: Logger?
|
||||
|
||||
var log: OpaquePointer
|
||||
|
||||
init(withFilePath filePath: String) throws {
|
||||
guard let log = open_log(filePath) else { throw LoggerError.openFailure }
|
||||
self.log = log
|
||||
}
|
||||
|
||||
deinit {
|
||||
close_log(self.log)
|
||||
}
|
||||
|
||||
func log(message: String) {
|
||||
write_msg_to_log(log, message.trimmingCharacters(in: .newlines))
|
||||
}
|
||||
|
||||
func writeLog(called ourTag: String, mergedWith otherLogFile: String, called otherTag: String, to targetFile: String) -> Bool {
|
||||
guard let other = open_log(otherLogFile) else { return false }
|
||||
let ret = write_logs_to_file(targetFile, log, ourTag, other, otherTag)
|
||||
close_log(other)
|
||||
return ret == 0
|
||||
}
|
||||
|
||||
static func configureGlobal(withFilePath filePath: String?) {
|
||||
if Logger.global != nil {
|
||||
return
|
||||
}
|
||||
guard let filePath = filePath else {
|
||||
os_log("Unable to determine log destination path. Log will not be saved to file.", log: OSLog.default, type: .error)
|
||||
return
|
||||
}
|
||||
guard let logger = try? Logger(withFilePath: filePath) else {
|
||||
os_log("Unable to open log file for writing. Log will not be saved to file.", log: OSLog.default, type: .error)
|
||||
return
|
||||
}
|
||||
Logger.global = logger
|
||||
var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown version"
|
||||
if let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
|
||||
appVersion += " (\(appBuild))"
|
||||
}
|
||||
let goBackendVersion = WIREGUARD_GO_VERSION
|
||||
Logger.global?.log(message: "App version: \(appVersion); Go backend version: \(goBackendVersion)")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func wg_log(_ type: OSLogType, staticMessage msg: StaticString) {
|
||||
os_log(msg, log: OSLog.default, type: type)
|
||||
Logger.global?.log(message: "\(msg)")
|
||||
}
|
||||
|
||||
func wg_log(_ type: OSLogType, message msg: String) {
|
||||
os_log("%{public}s", log: OSLog.default, type: type, msg)
|
||||
Logger.global?.log(message: msg)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <time.h>
|
||||
#include <errno.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/time.h>
|
||||
#include <sys/mman.h>
|
||||
#include "ringlogger.h"
|
||||
|
||||
enum {
|
||||
MAX_LOG_LINE_LENGTH = 512,
|
||||
MAX_LINES = 1024,
|
||||
MAGIC = 0xbeefbabeU
|
||||
};
|
||||
|
||||
struct log_line {
|
||||
struct timeval tv;
|
||||
char line[MAX_LOG_LINE_LENGTH];
|
||||
};
|
||||
|
||||
struct log {
|
||||
struct { uint32_t first, len; } header;
|
||||
struct log_line lines[MAX_LINES];
|
||||
uint32_t magic;
|
||||
};
|
||||
|
||||
void write_msg_to_log(struct log *log, const char *msg)
|
||||
{
|
||||
struct log_line *line = &log->lines[(log->header.first + log->header.len) % MAX_LINES];
|
||||
|
||||
if (log->header.len == MAX_LINES)
|
||||
log->header.first = (log->header.first + 1) % MAX_LINES;
|
||||
else
|
||||
++log->header.len;
|
||||
|
||||
gettimeofday(&line->tv, NULL);
|
||||
strncpy(line->line, msg, MAX_LOG_LINE_LENGTH - 1);
|
||||
line->line[MAX_LOG_LINE_LENGTH - 1] = '\0';
|
||||
|
||||
msync(&log->header, sizeof(log->header), MS_ASYNC);
|
||||
msync(line, sizeof(*line), MS_ASYNC);
|
||||
}
|
||||
|
||||
static bool first_before_second(const struct log_line *line1, const struct log_line *line2)
|
||||
{
|
||||
if (line1->tv.tv_sec <= line2->tv.tv_sec)
|
||||
return true;
|
||||
if (line1->tv.tv_sec == line2->tv.tv_sec)
|
||||
return line1->tv.tv_usec <= line2->tv.tv_usec;
|
||||
return false;
|
||||
}
|
||||
|
||||
int write_logs_to_file(const char *file_name, const struct log *log1, const char *tag1, const struct log *log2, const char *tag2)
|
||||
{
|
||||
uint32_t i1, i2, len1 = log1->header.len, len2 = log2->header.len;
|
||||
FILE *file;
|
||||
int ret;
|
||||
|
||||
if (len1 > MAX_LINES)
|
||||
len1 = MAX_LINES;
|
||||
if (len2 > MAX_LINES)
|
||||
len2 = MAX_LINES;
|
||||
|
||||
file = fopen(file_name, "w");
|
||||
if (!file)
|
||||
return -errno;
|
||||
|
||||
for (i1 = 0, i2 = 0;;) {
|
||||
struct tm tm;
|
||||
char buf[MAX_LOG_LINE_LENGTH];
|
||||
const struct log_line *line1 = &log1->lines[(log1->header.first + i1) % MAX_LINES];
|
||||
const struct log_line *line2 = &log2->lines[(log2->header.first + i2) % MAX_LINES];
|
||||
const struct log_line *line;
|
||||
const char *tag;
|
||||
|
||||
if (i1 < len1 && (i2 >= len2 || first_before_second(line1, line2))) {
|
||||
line = line1;
|
||||
tag = tag1;
|
||||
++i1;
|
||||
} else if (i2 < len2 && (i1 >= len1 || first_before_second(line2, line1))) {
|
||||
line = line2;
|
||||
tag = tag2;
|
||||
++i2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
memcpy(buf, line->line, MAX_LOG_LINE_LENGTH);
|
||||
buf[MAX_LOG_LINE_LENGTH - 1] = '\0';
|
||||
if (!localtime_r(&line->tv.tv_sec, &tm))
|
||||
goto err;
|
||||
if (fprintf(file, "%04d-%02d-%02d %02d:%02d:%02d.%06d: [%s] %s\n",
|
||||
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
|
||||
tm.tm_hour, tm.tm_min, tm.tm_sec, line->tv.tv_usec,
|
||||
tag, buf) < 0)
|
||||
goto err;
|
||||
}
|
||||
errno = 0;
|
||||
|
||||
err:
|
||||
ret = -errno;
|
||||
fclose(file);
|
||||
return ret;
|
||||
}
|
||||
|
||||
struct log *open_log(const char *file_name)
|
||||
{
|
||||
int fd;
|
||||
struct log *log;
|
||||
|
||||
fd = open(file_name, O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
|
||||
if (fd < 0)
|
||||
return NULL;
|
||||
if (ftruncate(fd, sizeof(*log)))
|
||||
goto err;
|
||||
log = mmap(NULL, sizeof(*log), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
|
||||
if (log == MAP_FAILED)
|
||||
goto err;
|
||||
close(fd);
|
||||
|
||||
if (log->magic != MAGIC) {
|
||||
memset(log, 0, sizeof(*log));
|
||||
log->magic = MAGIC;
|
||||
msync(log, sizeof(*log), MS_ASYNC);
|
||||
}
|
||||
|
||||
return log;
|
||||
|
||||
err:
|
||||
close(fd);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void close_log(struct log *log)
|
||||
{
|
||||
munmap(log, sizeof(*log));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* SPDX-License-Identifier: MIT
|
||||
*
|
||||
* Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef RINGLOGGER_H
|
||||
#define RINGLOGGER_H
|
||||
|
||||
struct log;
|
||||
void write_msg_to_log(struct log *log, const char *msg);
|
||||
int write_logs_to_file(const char *file_name, const struct log *log1, const char *tag1, const struct log *log2, const char *tag2);
|
||||
struct log *open_log(const char *file_name);
|
||||
void close_log(struct log *log);
|
||||
|
||||
#endif
|
||||
@@ -1,57 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
@available(OSX 10.14, iOS 12.0, *)
|
||||
final class TunnelConfiguration: Codable {
|
||||
var interface: InterfaceConfiguration
|
||||
let peers: [PeerConfiguration]
|
||||
init(interface: InterfaceConfiguration, peers: [PeerConfiguration]) {
|
||||
self.interface = interface
|
||||
self.peers = peers
|
||||
|
||||
let peerPublicKeysArray = peers.map { $0.publicKey }
|
||||
let peerPublicKeysSet = Set<Data>(peerPublicKeysArray)
|
||||
if (peerPublicKeysArray.count != peerPublicKeysSet.count) {
|
||||
fatalError("Two or more peers cannot have the same public key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(OSX 10.14, iOS 12.0, *)
|
||||
struct InterfaceConfiguration: Codable {
|
||||
var name: String
|
||||
var privateKey: Data
|
||||
var addresses: [IPAddressRange] = []
|
||||
var listenPort: UInt16?
|
||||
var mtu: UInt16?
|
||||
var dns: [DNSServer] = []
|
||||
|
||||
init(name: String, privateKey: Data) {
|
||||
self.name = name
|
||||
self.privateKey = privateKey
|
||||
if (name.isEmpty) { fatalError("Empty name") }
|
||||
if (privateKey.count != 32) { fatalError("Invalid private key") }
|
||||
}
|
||||
}
|
||||
|
||||
@available(OSX 10.14, iOS 12.0, *)
|
||||
struct PeerConfiguration: Codable {
|
||||
var publicKey: Data
|
||||
var preSharedKey: Data? {
|
||||
didSet(value) {
|
||||
if let value = value {
|
||||
if (value.count != 32) { fatalError("Invalid preshared key") }
|
||||
}
|
||||
}
|
||||
}
|
||||
var allowedIPs: [IPAddressRange] = []
|
||||
var endpoint: Endpoint?
|
||||
var persistentKeepAlive: UInt16?
|
||||
|
||||
init(publicKey: Data) {
|
||||
self.publicKey = publicKey
|
||||
if (publicKey.count != 32) { fatalError("Invalid public key") }
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,28 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
@available(OSX 10.14, iOS 12.0, *)
|
||||
struct DNSServer {
|
||||
let address: IPAddress
|
||||
|
||||
init(address: IPAddress) {
|
||||
self.address = address
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Converting to and from String
|
||||
// For use in the UI
|
||||
extension DNSServer: Equatable {
|
||||
static func == (lhs: DNSServer, rhs: DNSServer) -> Bool {
|
||||
return lhs.address.rawValue == rhs.address.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
extension DNSServer {
|
||||
var stringRepresentation: String {
|
||||
return "\(address)"
|
||||
}
|
||||
|
||||
init?(from addressString: String) {
|
||||
if let addr = IPv4Address(addressString) {
|
||||
address = addr
|
||||
@@ -22,36 +32,4 @@ extension DNSServer {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
func stringRepresentation() -> String {
|
||||
return "\(address)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Codable
|
||||
// For serializing to disk
|
||||
|
||||
@available(OSX 10.14, iOS 12.0, *)
|
||||
extension DNSServer: Codable {
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(address.rawValue)
|
||||
}
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
var data = try container.decode(Data.self)
|
||||
let ipAddressFromData: IPAddress? = {
|
||||
switch (data.count) {
|
||||
case 4: return IPv4Address(data)
|
||||
case 16: return IPv6Address(data)
|
||||
default: return nil
|
||||
}
|
||||
}()
|
||||
guard let ipAddress = ipAddressFromData else {
|
||||
throw DecodingError.invalidData
|
||||
}
|
||||
address = ipAddress
|
||||
}
|
||||
enum DecodingError: Error {
|
||||
case invalidData
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Data {
|
||||
func isKey() -> Bool {
|
||||
return self.count == WG_KEY_LEN
|
||||
}
|
||||
|
||||
func hexKey() -> String? {
|
||||
if self.count != WG_KEY_LEN {
|
||||
return nil
|
||||
}
|
||||
var out = Data(repeating: 0, count: Int(WG_KEY_LEN_HEX))
|
||||
out.withUnsafeMutableBytes { outBytes in
|
||||
self.withUnsafeBytes { inBytes in
|
||||
key_to_hex(outBytes, inBytes)
|
||||
}
|
||||
}
|
||||
out.removeLast()
|
||||
return String(data: out, encoding: .ascii)
|
||||
}
|
||||
|
||||
init?(hexKey hexString: String) {
|
||||
self.init(repeating: 0, count: Int(WG_KEY_LEN))
|
||||
|
||||
if !self.withUnsafeMutableBytes { key_from_hex($0, hexString) } {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func base64Key() -> String? {
|
||||
if self.count != WG_KEY_LEN {
|
||||
return nil
|
||||
}
|
||||
var out = Data(repeating: 0, count: Int(WG_KEY_LEN_BASE64))
|
||||
out.withUnsafeMutableBytes { outBytes in
|
||||
self.withUnsafeBytes { inBytes in
|
||||
key_to_base64(outBytes, inBytes)
|
||||
}
|
||||
}
|
||||
out.removeLast()
|
||||
return String(data: out, encoding: .ascii)
|
||||
}
|
||||
|
||||
init?(base64Key base64String: String) {
|
||||
self.init(repeating: 0, count: Int(WG_KEY_LEN))
|
||||
|
||||
if !self.withUnsafeMutableBytes { key_from_base64($0, base64String) } {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,56 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
@available(OSX 10.14, iOS 12.0, *)
|
||||
struct Endpoint {
|
||||
let host: NWEndpoint.Host
|
||||
let port: NWEndpoint.Port
|
||||
|
||||
init(host: NWEndpoint.Host, port: NWEndpoint.Port) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Converting to and from String
|
||||
// For use in the UI
|
||||
extension Endpoint: Equatable {
|
||||
static func == (lhs: Endpoint, rhs: Endpoint) -> Bool {
|
||||
return lhs.host == rhs.host && lhs.port == rhs.port
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoint: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(host)
|
||||
hasher.combine(port)
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoint {
|
||||
var stringRepresentation: String {
|
||||
switch host {
|
||||
case .name(let hostname, _):
|
||||
return "\(hostname):\(port)"
|
||||
case .ipv4(let address):
|
||||
return "\(address):\(port)"
|
||||
case .ipv6(let address):
|
||||
return "[\(address)]:\(port)"
|
||||
}
|
||||
}
|
||||
|
||||
init?(from string: String) {
|
||||
// Separation of host and port is based on 'parse_endpoint' function in
|
||||
// https://git.zx2c4.com/WireGuard/tree/src/tools/config.c
|
||||
guard (!string.isEmpty) else { return nil }
|
||||
guard !string.isEmpty else { return nil }
|
||||
let startOfPort: String.Index
|
||||
let hostString: String
|
||||
if (string.first! == "[") {
|
||||
if string.first! == "[" {
|
||||
// Look for IPv6-style endpoint, like [::1]:80
|
||||
let startOfHost = string.index(after: string.startIndex)
|
||||
guard let endOfHost = string.dropFirst().firstIndex(of: "]") else { return nil }
|
||||
let afterEndOfHost = string.index(after: endOfHost)
|
||||
guard (string[afterEndOfHost] == ":") else { return nil }
|
||||
guard string[afterEndOfHost] == ":" else { return nil }
|
||||
startOfPort = string.index(after: afterEndOfHost)
|
||||
hostString = String(string[startOfHost ..< endOfHost])
|
||||
} else {
|
||||
@@ -35,66 +60,34 @@ extension Endpoint {
|
||||
hostString = String(string[string.startIndex ..< endOfHost])
|
||||
}
|
||||
guard let endpointPort = NWEndpoint.Port(String(string[startOfPort ..< string.endIndex])) else { return nil }
|
||||
let invalidCharacterIndex = hostString.unicodeScalars.firstIndex { (c) -> Bool in
|
||||
return !CharacterSet.urlHostAllowed.contains(c)
|
||||
let invalidCharacterIndex = hostString.unicodeScalars.firstIndex { char in
|
||||
return !CharacterSet.urlHostAllowed.contains(char)
|
||||
}
|
||||
guard (invalidCharacterIndex == nil) else { return nil }
|
||||
guard invalidCharacterIndex == nil else { return nil }
|
||||
host = NWEndpoint.Host(hostString)
|
||||
port = endpointPort
|
||||
}
|
||||
func stringRepresentation() -> String {
|
||||
switch (host) {
|
||||
case .name(let hostname, _):
|
||||
return "\(hostname):\(port)"
|
||||
case .ipv4(let address):
|
||||
return "\(address):\(port)"
|
||||
case .ipv6(let address):
|
||||
return "[\(address)]:\(port)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Codable
|
||||
// For serializing to disk
|
||||
|
||||
@available(OSX 10.14, iOS 12.0, *)
|
||||
extension Endpoint: Codable {
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(self.stringRepresentation())
|
||||
}
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
let endpointString = try container.decode(String.self)
|
||||
guard let endpoint = Endpoint(from: endpointString) else {
|
||||
throw DecodingError.invalidData
|
||||
}
|
||||
self = endpoint
|
||||
}
|
||||
enum DecodingError: Error {
|
||||
case invalidData
|
||||
}
|
||||
}
|
||||
|
||||
extension Endpoint {
|
||||
func hasHostAsIPAddress() -> Bool {
|
||||
switch (host) {
|
||||
case .name(_, _):
|
||||
switch host {
|
||||
case .name:
|
||||
return false
|
||||
case .ipv4(_):
|
||||
case .ipv4:
|
||||
return true
|
||||
case .ipv6(_):
|
||||
case .ipv6:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func hostname() -> String? {
|
||||
switch (host) {
|
||||
switch host {
|
||||
case .name(let hostname, _):
|
||||
return hostname
|
||||
case .ipv4(_):
|
||||
case .ipv4:
|
||||
return nil
|
||||
case .ipv6(_):
|
||||
case .ipv6:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,44 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
@available(OSX 10.14, iOS 12.0, *)
|
||||
struct IPAddressRange {
|
||||
let address: IPAddress
|
||||
var networkPrefixLength: UInt8
|
||||
|
||||
init(address: IPAddress, networkPrefixLength: UInt8) {
|
||||
self.address = address
|
||||
self.networkPrefixLength = networkPrefixLength
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Converting to and from String
|
||||
// For use in the UI
|
||||
extension IPAddressRange: Equatable {
|
||||
static func == (lhs: IPAddressRange, rhs: IPAddressRange) -> Bool {
|
||||
return lhs.address.rawValue == rhs.address.rawValue && lhs.networkPrefixLength == rhs.networkPrefixLength
|
||||
}
|
||||
}
|
||||
|
||||
extension IPAddressRange: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(address.rawValue)
|
||||
hasher.combine(networkPrefixLength)
|
||||
}
|
||||
}
|
||||
|
||||
extension IPAddressRange {
|
||||
var stringRepresentation: String {
|
||||
return "\(address)/\(networkPrefixLength)"
|
||||
}
|
||||
|
||||
init?(from string: String) {
|
||||
guard let parsed = IPAddressRange.parseAddressString(string) else { return nil }
|
||||
address = parsed.0
|
||||
networkPrefixLength = parsed.1
|
||||
}
|
||||
|
||||
private static func parseAddressString(_ string: String) -> (IPAddress, UInt8)? {
|
||||
let endOfIPAddress = string.lastIndex(of: "/") ?? string.endIndex
|
||||
let addressString = String(string[string.startIndex ..< endOfIPAddress])
|
||||
let address: IPAddress
|
||||
@@ -25,62 +49,19 @@ extension IPAddressRange {
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
let maxNetworkPrefixLength: UInt8 = (address is IPv4Address) ? 32 : 128
|
||||
|
||||
let maxNetworkPrefixLength: UInt8 = address is IPv4Address ? 32 : 128
|
||||
var networkPrefixLength: UInt8
|
||||
if (endOfIPAddress < string.endIndex) { // "/" was located
|
||||
if endOfIPAddress < string.endIndex { // "/" was located
|
||||
let indexOfNetworkPrefixLength = string.index(after: endOfIPAddress)
|
||||
guard (indexOfNetworkPrefixLength < string.endIndex) else { return nil }
|
||||
guard indexOfNetworkPrefixLength < string.endIndex else { return nil }
|
||||
let networkPrefixLengthSubstring = string[indexOfNetworkPrefixLength ..< string.endIndex]
|
||||
guard let npl = UInt8(networkPrefixLengthSubstring) else { return nil }
|
||||
networkPrefixLength = min(npl, maxNetworkPrefixLength)
|
||||
} else {
|
||||
networkPrefixLength = maxNetworkPrefixLength
|
||||
}
|
||||
self.address = address
|
||||
self.networkPrefixLength = networkPrefixLength
|
||||
}
|
||||
func stringRepresentation() -> String {
|
||||
return "\(address)/\(networkPrefixLength)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Codable
|
||||
// For serializing to disk
|
||||
|
||||
@available(OSX 10.14, iOS 12.0, *)
|
||||
extension IPAddressRange: Codable {
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
let addressDataLength: Int
|
||||
if address is IPv4Address {
|
||||
addressDataLength = 4
|
||||
} else if address is IPv6Address {
|
||||
addressDataLength = 16
|
||||
} else {
|
||||
fatalError()
|
||||
}
|
||||
var data = Data(capacity: addressDataLength + 1)
|
||||
data.append(address.rawValue)
|
||||
data.append(networkPrefixLength)
|
||||
try container.encode(data)
|
||||
}
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
var data = try container.decode(Data.self)
|
||||
networkPrefixLength = data.removeLast()
|
||||
let ipAddressFromData: IPAddress? = {
|
||||
switch (data.count) {
|
||||
case 4: return IPv4Address(data)
|
||||
case 16: return IPv6Address(data)
|
||||
default: return nil
|
||||
}
|
||||
}()
|
||||
guard let ipAddress = ipAddressFromData else {
|
||||
throw DecodingError.invalidData
|
||||
}
|
||||
address = ipAddress
|
||||
}
|
||||
enum DecodingError: Error {
|
||||
case invalidData
|
||||
|
||||
return (address, networkPrefixLength)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
struct InterfaceConfiguration {
|
||||
var privateKey: Data
|
||||
var addresses = [IPAddressRange]()
|
||||
var listenPort: UInt16?
|
||||
var mtu: UInt16?
|
||||
var dns = [DNSServer]()
|
||||
|
||||
init(privateKey: Data) {
|
||||
if privateKey.count != TunnelConfiguration.keyLength {
|
||||
fatalError("Invalid private key")
|
||||
}
|
||||
self.privateKey = privateKey
|
||||
}
|
||||
}
|
||||
|
||||
extension InterfaceConfiguration: Equatable {
|
||||
static func == (lhs: InterfaceConfiguration, rhs: InterfaceConfiguration) -> Bool {
|
||||
let lhsAddresses = lhs.addresses.filter { $0.address is IPv4Address } + lhs.addresses.filter { $0.address is IPv6Address }
|
||||
let rhsAddresses = rhs.addresses.filter { $0.address is IPv4Address } + rhs.addresses.filter { $0.address is IPv6Address }
|
||||
|
||||
return lhs.privateKey == rhs.privateKey &&
|
||||
lhsAddresses == rhsAddresses &&
|
||||
lhs.listenPort == rhs.listenPort &&
|
||||
lhs.mtu == rhs.mtu &&
|
||||
lhs.dns == rhs.dns
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import NetworkExtension
|
||||
|
||||
enum PacketTunnelProviderError: String, Error {
|
||||
case savedProtocolConfigurationIsInvalid
|
||||
case dnsResolutionFailure
|
||||
case couldNotStartBackend
|
||||
case couldNotDetermineFileDescriptor
|
||||
case couldNotSetNetworkSettings
|
||||
}
|
||||
|
||||
extension NETunnelProviderProtocol {
|
||||
convenience init?(tunnelConfiguration: TunnelConfiguration, previouslyFrom old: NEVPNProtocol? = nil) {
|
||||
self.init()
|
||||
|
||||
guard let name = tunnelConfiguration.name else { return nil }
|
||||
guard let appId = Bundle.main.bundleIdentifier else { return nil }
|
||||
providerBundleIdentifier = "\(appId).network-extension"
|
||||
passwordReference = Keychain.makeReference(containing: tunnelConfiguration.asWgQuickConfig(), called: name, previouslyReferencedBy: old?.passwordReference)
|
||||
if passwordReference == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
let endpoints = tunnelConfiguration.peers.compactMap { $0.endpoint }
|
||||
if endpoints.count == 1 {
|
||||
serverAddress = endpoints[0].stringRepresentation
|
||||
} else if endpoints.isEmpty {
|
||||
serverAddress = "Unspecified"
|
||||
} else {
|
||||
serverAddress = "Multiple endpoints"
|
||||
}
|
||||
}
|
||||
|
||||
func asTunnelConfiguration(called name: String? = nil) -> TunnelConfiguration? {
|
||||
migrateConfigurationIfNeeded(called: name ?? "unknown")
|
||||
//TODO: in the case where migrateConfigurationIfNeeded is called by the network extension,
|
||||
// before the app has started, and when there is, in fact, configuration that needs to be
|
||||
// put into the keychain, this will generate one new keychain item every time it is started,
|
||||
// until finally the app is open. Would it be possible to call saveToPreferences here? Or is
|
||||
// that generally not available to network extensions? In which case, what should our
|
||||
// behavior be?
|
||||
|
||||
guard let passwordReference = passwordReference else { return nil }
|
||||
guard let config = Keychain.openReference(called: passwordReference) else { return nil }
|
||||
return try? TunnelConfiguration(fromWgQuickConfig: config, called: name)
|
||||
}
|
||||
|
||||
func destroyConfigurationReference() {
|
||||
guard let ref = passwordReference else { return }
|
||||
Keychain.deleteReference(called: ref)
|
||||
}
|
||||
|
||||
func verifyConfigurationReference() -> Data? {
|
||||
guard let ref = passwordReference else { return nil }
|
||||
return Keychain.verifyReference(called: ref) ? ref : nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func migrateConfigurationIfNeeded(called name: String) -> Bool {
|
||||
/* This is how we did things before we switched to putting items
|
||||
* in the keychain. But it's still useful to keep the migration
|
||||
* around so that .mobileconfig files are easier.
|
||||
*/
|
||||
guard let oldConfig = providerConfiguration?["WgQuickConfig"] as? String else { return false }
|
||||
providerConfiguration = nil
|
||||
guard passwordReference == nil else { return true }
|
||||
passwordReference = Keychain.makeReference(containing: oldConfig, called: name)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
struct PeerConfiguration {
|
||||
var publicKey: Data
|
||||
var preSharedKey: Data? {
|
||||
didSet(value) {
|
||||
if let value = value {
|
||||
if value.count != TunnelConfiguration.keyLength {
|
||||
fatalError("Invalid preshared key")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var allowedIPs = [IPAddressRange]()
|
||||
var endpoint: Endpoint?
|
||||
var persistentKeepAlive: UInt16?
|
||||
var rxBytes: UInt64?
|
||||
var txBytes: UInt64?
|
||||
var lastHandshakeTime: Date?
|
||||
|
||||
init(publicKey: Data) {
|
||||
self.publicKey = publicKey
|
||||
if publicKey.count != TunnelConfiguration.keyLength {
|
||||
fatalError("Invalid public key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PeerConfiguration: Equatable {
|
||||
static func == (lhs: PeerConfiguration, rhs: PeerConfiguration) -> Bool {
|
||||
return lhs.publicKey == rhs.publicKey &&
|
||||
lhs.preSharedKey == rhs.preSharedKey &&
|
||||
Set(lhs.allowedIPs) == Set(rhs.allowedIPs) &&
|
||||
lhs.endpoint == rhs.endpoint &&
|
||||
lhs.persistentKeepAlive == rhs.persistentKeepAlive
|
||||
}
|
||||
}
|
||||
|
||||
extension PeerConfiguration: Hashable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(publicKey)
|
||||
hasher.combine(preSharedKey)
|
||||
hasher.combine(Set(allowedIPs))
|
||||
hasher.combine(endpoint)
|
||||
hasher.combine(persistentKeepAlive)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
|
||||
func splitToArray(separator: Character = ",", trimmingCharacters: CharacterSet? = nil) -> [String] {
|
||||
return split(separator: separator)
|
||||
.map {
|
||||
if let charSet = trimmingCharacters {
|
||||
return $0.trimmingCharacters(in: charSet)
|
||||
} else {
|
||||
return String($0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Optional where Wrapped == String {
|
||||
|
||||
func splitToArray(separator: Character = ",", trimmingCharacters: CharacterSet? = nil) -> [String] {
|
||||
switch self {
|
||||
case .none:
|
||||
return []
|
||||
case .some(let wrapped):
|
||||
return wrapped.splitToArray(separator: separator, trimmingCharacters: trimmingCharacters)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension TunnelConfiguration {
|
||||
|
||||
enum ParserState {
|
||||
case inInterfaceSection
|
||||
case inPeerSection
|
||||
case notInASection
|
||||
}
|
||||
|
||||
enum ParseError: Error {
|
||||
case invalidLine(String.SubSequence)
|
||||
case noInterface
|
||||
case multipleInterfaces
|
||||
case interfaceHasNoPrivateKey
|
||||
case interfaceHasInvalidPrivateKey(String)
|
||||
case interfaceHasInvalidListenPort(String)
|
||||
case interfaceHasInvalidAddress(String)
|
||||
case interfaceHasInvalidDNS(String)
|
||||
case interfaceHasInvalidMTU(String)
|
||||
case interfaceHasUnrecognizedKey(String)
|
||||
case peerHasNoPublicKey
|
||||
case peerHasInvalidPublicKey(String)
|
||||
case peerHasInvalidPreSharedKey(String)
|
||||
case peerHasInvalidAllowedIP(String)
|
||||
case peerHasInvalidEndpoint(String)
|
||||
case peerHasInvalidPersistentKeepAlive(String)
|
||||
case peerHasInvalidTransferBytes(String)
|
||||
case peerHasInvalidLastHandshakeTime(String)
|
||||
case peerHasUnrecognizedKey(String)
|
||||
case multiplePeersWithSamePublicKey
|
||||
case multipleEntriesForKey(String)
|
||||
}
|
||||
|
||||
convenience init(fromWgQuickConfig wgQuickConfig: String, called name: String? = nil) throws {
|
||||
var interfaceConfiguration: InterfaceConfiguration?
|
||||
var peerConfigurations = [PeerConfiguration]()
|
||||
|
||||
let lines = wgQuickConfig.split(separator: "\n")
|
||||
|
||||
var parserState = ParserState.notInASection
|
||||
var attributes = [String: String]()
|
||||
|
||||
for (lineIndex, line) in lines.enumerated() {
|
||||
var trimmedLine: String
|
||||
if let commentRange = line.range(of: "#") {
|
||||
trimmedLine = String(line[..<commentRange.lowerBound])
|
||||
} else {
|
||||
trimmedLine = String(line)
|
||||
}
|
||||
|
||||
trimmedLine = trimmedLine.trimmingCharacters(in: .whitespaces)
|
||||
let lowercasedLine = trimmedLine.lowercased()
|
||||
|
||||
if !trimmedLine.isEmpty {
|
||||
if let equalsIndex = trimmedLine.firstIndex(of: "=") {
|
||||
// Line contains an attribute
|
||||
let keyWithCase = trimmedLine[..<equalsIndex].trimmingCharacters(in: .whitespaces)
|
||||
let key = keyWithCase.lowercased()
|
||||
let value = trimmedLine[trimmedLine.index(equalsIndex, offsetBy: 1)...].trimmingCharacters(in: .whitespaces)
|
||||
let keysWithMultipleEntriesAllowed: Set<String> = ["address", "allowedips", "dns"]
|
||||
if let presentValue = attributes[key] {
|
||||
if keysWithMultipleEntriesAllowed.contains(key) {
|
||||
attributes[key] = presentValue + "," + value
|
||||
} else {
|
||||
throw ParseError.multipleEntriesForKey(keyWithCase)
|
||||
}
|
||||
} else {
|
||||
attributes[key] = value
|
||||
}
|
||||
let interfaceSectionKeys: Set<String> = ["privatekey", "listenport", "address", "dns", "mtu"]
|
||||
let peerSectionKeys: Set<String> = ["publickey", "presharedkey", "allowedips", "endpoint", "persistentkeepalive"]
|
||||
if parserState == .inInterfaceSection {
|
||||
guard interfaceSectionKeys.contains(key) else {
|
||||
throw ParseError.interfaceHasUnrecognizedKey(keyWithCase)
|
||||
}
|
||||
} else if parserState == .inPeerSection {
|
||||
guard peerSectionKeys.contains(key) else {
|
||||
throw ParseError.peerHasUnrecognizedKey(keyWithCase)
|
||||
}
|
||||
}
|
||||
} else if lowercasedLine != "[interface]" && lowercasedLine != "[peer]" {
|
||||
throw ParseError.invalidLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
let isLastLine = lineIndex == lines.count - 1
|
||||
|
||||
if isLastLine || lowercasedLine == "[interface]" || lowercasedLine == "[peer]" {
|
||||
// Previous section has ended; process the attributes collected so far
|
||||
if parserState == .inInterfaceSection {
|
||||
let interface = try TunnelConfiguration.collate(interfaceAttributes: attributes)
|
||||
guard interfaceConfiguration == nil else { throw ParseError.multipleInterfaces }
|
||||
interfaceConfiguration = interface
|
||||
} else if parserState == .inPeerSection {
|
||||
let peer = try TunnelConfiguration.collate(peerAttributes: attributes)
|
||||
peerConfigurations.append(peer)
|
||||
}
|
||||
}
|
||||
|
||||
if lowercasedLine == "[interface]" {
|
||||
parserState = .inInterfaceSection
|
||||
attributes.removeAll()
|
||||
} else if lowercasedLine == "[peer]" {
|
||||
parserState = .inPeerSection
|
||||
attributes.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
let peerPublicKeysArray = peerConfigurations.map { $0.publicKey }
|
||||
let peerPublicKeysSet = Set<Data>(peerPublicKeysArray)
|
||||
if peerPublicKeysArray.count != peerPublicKeysSet.count {
|
||||
throw ParseError.multiplePeersWithSamePublicKey
|
||||
}
|
||||
|
||||
if let interfaceConfiguration = interfaceConfiguration {
|
||||
self.init(name: name, interface: interfaceConfiguration, peers: peerConfigurations)
|
||||
} else {
|
||||
throw ParseError.noInterface
|
||||
}
|
||||
}
|
||||
|
||||
func asWgQuickConfig() -> String {
|
||||
var output = "[Interface]\n"
|
||||
if let privateKey = interface.privateKey.base64Key() {
|
||||
output.append("PrivateKey = \(privateKey)\n")
|
||||
}
|
||||
if let listenPort = interface.listenPort {
|
||||
output.append("ListenPort = \(listenPort)\n")
|
||||
}
|
||||
if !interface.addresses.isEmpty {
|
||||
let addressString = interface.addresses.map { $0.stringRepresentation }.joined(separator: ", ")
|
||||
output.append("Address = \(addressString)\n")
|
||||
}
|
||||
if !interface.dns.isEmpty {
|
||||
let dnsString = interface.dns.map { $0.stringRepresentation }.joined(separator: ", ")
|
||||
output.append("DNS = \(dnsString)\n")
|
||||
}
|
||||
if let mtu = interface.mtu {
|
||||
output.append("MTU = \(mtu)\n")
|
||||
}
|
||||
|
||||
for peer in peers {
|
||||
output.append("\n[Peer]\n")
|
||||
if let publicKey = peer.publicKey.base64Key() {
|
||||
output.append("PublicKey = \(publicKey)\n")
|
||||
}
|
||||
if let preSharedKey = peer.preSharedKey?.base64Key() {
|
||||
output.append("PresharedKey = \(preSharedKey)\n")
|
||||
}
|
||||
if !peer.allowedIPs.isEmpty {
|
||||
let allowedIPsString = peer.allowedIPs.map { $0.stringRepresentation }.joined(separator: ", ")
|
||||
output.append("AllowedIPs = \(allowedIPsString)\n")
|
||||
}
|
||||
if let endpoint = peer.endpoint {
|
||||
output.append("Endpoint = \(endpoint.stringRepresentation)\n")
|
||||
}
|
||||
if let persistentKeepAlive = peer.persistentKeepAlive {
|
||||
output.append("PersistentKeepalive = \(persistentKeepAlive)\n")
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
private static func collate(interfaceAttributes attributes: [String: String]) throws -> InterfaceConfiguration {
|
||||
guard let privateKeyString = attributes["privatekey"] else {
|
||||
throw ParseError.interfaceHasNoPrivateKey
|
||||
}
|
||||
guard let privateKey = Data(base64Key: privateKeyString), privateKey.count == TunnelConfiguration.keyLength else {
|
||||
throw ParseError.interfaceHasInvalidPrivateKey(privateKeyString)
|
||||
}
|
||||
var interface = InterfaceConfiguration(privateKey: privateKey)
|
||||
if let listenPortString = attributes["listenport"] {
|
||||
guard let listenPort = UInt16(listenPortString) else {
|
||||
throw ParseError.interfaceHasInvalidListenPort(listenPortString)
|
||||
}
|
||||
interface.listenPort = listenPort
|
||||
}
|
||||
if let addressesString = attributes["address"] {
|
||||
var addresses = [IPAddressRange]()
|
||||
for addressString in addressesString.splitToArray(trimmingCharacters: .whitespaces) {
|
||||
guard let address = IPAddressRange(from: addressString) else {
|
||||
throw ParseError.interfaceHasInvalidAddress(addressString)
|
||||
}
|
||||
addresses.append(address)
|
||||
}
|
||||
interface.addresses = addresses
|
||||
}
|
||||
if let dnsString = attributes["dns"] {
|
||||
var dnsServers = [DNSServer]()
|
||||
for dnsServerString in dnsString.splitToArray(trimmingCharacters: .whitespaces) {
|
||||
guard let dnsServer = DNSServer(from: dnsServerString) else {
|
||||
throw ParseError.interfaceHasInvalidDNS(dnsServerString)
|
||||
}
|
||||
dnsServers.append(dnsServer)
|
||||
}
|
||||
interface.dns = dnsServers
|
||||
}
|
||||
if let mtuString = attributes["mtu"] {
|
||||
guard let mtu = UInt16(mtuString) else {
|
||||
throw ParseError.interfaceHasInvalidMTU(mtuString)
|
||||
}
|
||||
interface.mtu = mtu
|
||||
}
|
||||
return interface
|
||||
}
|
||||
|
||||
private static func collate(peerAttributes attributes: [String: String]) throws -> PeerConfiguration {
|
||||
guard let publicKeyString = attributes["publickey"] else {
|
||||
throw ParseError.peerHasNoPublicKey
|
||||
}
|
||||
guard let publicKey = Data(base64Key: publicKeyString), publicKey.count == TunnelConfiguration.keyLength else {
|
||||
throw ParseError.peerHasInvalidPublicKey(publicKeyString)
|
||||
}
|
||||
var peer = PeerConfiguration(publicKey: publicKey)
|
||||
if let preSharedKeyString = attributes["presharedkey"] {
|
||||
guard let preSharedKey = Data(base64Key: preSharedKeyString), preSharedKey.count == TunnelConfiguration.keyLength else {
|
||||
throw ParseError.peerHasInvalidPreSharedKey(preSharedKeyString)
|
||||
}
|
||||
peer.preSharedKey = preSharedKey
|
||||
}
|
||||
if let allowedIPsString = attributes["allowedips"] {
|
||||
var allowedIPs = [IPAddressRange]()
|
||||
for allowedIPString in allowedIPsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
|
||||
guard let allowedIP = IPAddressRange(from: allowedIPString) else {
|
||||
throw ParseError.peerHasInvalidAllowedIP(allowedIPString)
|
||||
}
|
||||
allowedIPs.append(allowedIP)
|
||||
}
|
||||
peer.allowedIPs = allowedIPs
|
||||
}
|
||||
if let endpointString = attributes["endpoint"] {
|
||||
guard let endpoint = Endpoint(from: endpointString) else {
|
||||
throw ParseError.peerHasInvalidEndpoint(endpointString)
|
||||
}
|
||||
peer.endpoint = endpoint
|
||||
}
|
||||
if let persistentKeepAliveString = attributes["persistentkeepalive"] {
|
||||
guard let persistentKeepAlive = UInt16(persistentKeepAliveString) else {
|
||||
throw ParseError.peerHasInvalidPersistentKeepAlive(persistentKeepAliveString)
|
||||
}
|
||||
peer.persistentKeepAlive = persistentKeepAlive
|
||||
}
|
||||
return peer
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
final class TunnelConfiguration {
|
||||
var name: String?
|
||||
var interface: InterfaceConfiguration
|
||||
let peers: [PeerConfiguration]
|
||||
|
||||
static let keyLength = 32
|
||||
|
||||
init(name: String?, interface: InterfaceConfiguration, peers: [PeerConfiguration]) {
|
||||
self.interface = interface
|
||||
self.peers = peers
|
||||
self.name = name
|
||||
|
||||
let peerPublicKeysArray = peers.map { $0.publicKey }
|
||||
let peerPublicKeysSet = Set<Data>(peerPublicKeysArray)
|
||||
if peerPublicKeysArray.count != peerPublicKeysSet.count {
|
||||
fatalError("Two or more peers cannot have the same public key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TunnelConfiguration: Equatable {
|
||||
static func == (lhs: TunnelConfiguration, rhs: TunnelConfiguration) -> Bool {
|
||||
return lhs.name == rhs.name &&
|
||||
lhs.interface == rhs.interface &&
|
||||
Set(lhs.peers) == Set(rhs.peers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// SPDX-License-Identifier: GPL-2.0
|
||||
/*
|
||||
* Copyright (C) 2015-2019 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
|
||||
*
|
||||
* This is a specialized constant-time base64/hex implementation that resists side-channel attacks.
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
#include "key.h"
|
||||
|
||||
static inline void encode_base64(char dest[static 4], const uint8_t src[static 3])
|
||||
{
|
||||
const uint8_t input[] = { (src[0] >> 2) & 63, ((src[0] << 4) | (src[1] >> 4)) & 63, ((src[1] << 2) | (src[2] >> 6)) & 63, src[2] & 63 };
|
||||
|
||||
for (unsigned int i = 0; i < 4; ++i)
|
||||
dest[i] = input[i] + 'A'
|
||||
+ (((25 - input[i]) >> 8) & 6)
|
||||
- (((51 - input[i]) >> 8) & 75)
|
||||
- (((61 - input[i]) >> 8) & 15)
|
||||
+ (((62 - input[i]) >> 8) & 3);
|
||||
|
||||
}
|
||||
|
||||
void key_to_base64(char base64[static WG_KEY_LEN_BASE64], const uint8_t key[static WG_KEY_LEN])
|
||||
{
|
||||
unsigned int i;
|
||||
|
||||
for (i = 0; i < WG_KEY_LEN / 3; ++i)
|
||||
encode_base64(&base64[i * 4], &key[i * 3]);
|
||||
encode_base64(&base64[i * 4], (const uint8_t[]){ key[i * 3 + 0], key[i * 3 + 1], 0 });
|
||||
base64[WG_KEY_LEN_BASE64 - 2] = '=';
|
||||
base64[WG_KEY_LEN_BASE64 - 1] = '\0';
|
||||
}
|
||||
|
||||
static inline int decode_base64(const char src[static 4])
|
||||
{
|
||||
int val = 0;
|
||||
|
||||
for (unsigned int i = 0; i < 4; ++i)
|
||||
val |= (-1
|
||||
+ ((((('A' - 1) - src[i]) & (src[i] - ('Z' + 1))) >> 8) & (src[i] - 64))
|
||||
+ ((((('a' - 1) - src[i]) & (src[i] - ('z' + 1))) >> 8) & (src[i] - 70))
|
||||
+ ((((('0' - 1) - src[i]) & (src[i] - ('9' + 1))) >> 8) & (src[i] + 5))
|
||||
+ ((((('+' - 1) - src[i]) & (src[i] - ('+' + 1))) >> 8) & 63)
|
||||
+ ((((('/' - 1) - src[i]) & (src[i] - ('/' + 1))) >> 8) & 64)
|
||||
) << (18 - 6 * i);
|
||||
return val;
|
||||
}
|
||||
|
||||
bool key_from_base64(uint8_t key[static WG_KEY_LEN], const char *base64)
|
||||
{
|
||||
unsigned int i;
|
||||
volatile uint8_t ret = 0;
|
||||
int val;
|
||||
|
||||
if (strlen(base64) != WG_KEY_LEN_BASE64 - 1 || base64[WG_KEY_LEN_BASE64 - 2] != '=')
|
||||
return false;
|
||||
|
||||
for (i = 0; i < WG_KEY_LEN / 3; ++i) {
|
||||
val = decode_base64(&base64[i * 4]);
|
||||
ret |= (uint32_t)val >> 31;
|
||||
key[i * 3 + 0] = (val >> 16) & 0xff;
|
||||
key[i * 3 + 1] = (val >> 8) & 0xff;
|
||||
key[i * 3 + 2] = val & 0xff;
|
||||
}
|
||||
val = decode_base64((const char[]){ base64[i * 4 + 0], base64[i * 4 + 1], base64[i * 4 + 2], 'A' });
|
||||
ret |= ((uint32_t)val >> 31) | (val & 0xff);
|
||||
key[i * 3 + 0] = (val >> 16) & 0xff;
|
||||
key[i * 3 + 1] = (val >> 8) & 0xff;
|
||||
|
||||
return 1 & ((ret - 1) >> 8);
|
||||
}
|
||||
|
||||
void key_to_hex(char hex[static WG_KEY_LEN_HEX], const uint8_t key[static WG_KEY_LEN])
|
||||
{
|
||||
unsigned int i;
|
||||
|
||||
for (i = 0; i < WG_KEY_LEN; ++i) {
|
||||
hex[i * 2] = 87U + (key[i] >> 4) + ((((key[i] >> 4) - 10U) >> 8) & ~38U);
|
||||
hex[i * 2 + 1] = 87U + (key[i] & 0xf) + ((((key[i] & 0xf) - 10U) >> 8) & ~38U);
|
||||
}
|
||||
hex[i * 2] = '\0';
|
||||
}
|
||||
|
||||
bool key_from_hex(uint8_t key[static WG_KEY_LEN], const char *hex)
|
||||
{
|
||||
uint8_t c, c_acc, c_alpha0, c_alpha, c_num0, c_num, c_val;
|
||||
volatile uint8_t ret = 0;
|
||||
|
||||
if (strlen(hex) != WG_KEY_LEN_HEX - 1)
|
||||
return false;
|
||||
|
||||
for (unsigned int i = 0; i < WG_KEY_LEN_HEX - 1; i += 2) {
|
||||
c = (uint8_t)hex[i];
|
||||
c_num = c ^ 48U;
|
||||
c_num0 = (c_num - 10U) >> 8;
|
||||
c_alpha = (c & ~32U) - 55U;
|
||||
c_alpha0 = ((c_alpha - 10U) ^ (c_alpha - 16U)) >> 8;
|
||||
ret |= ((c_num0 | c_alpha0) - 1) >> 8;
|
||||
c_val = (c_num0 & c_num) | (c_alpha0 & c_alpha);
|
||||
c_acc = c_val * 16U;
|
||||
|
||||
c = (uint8_t)hex[i + 1];
|
||||
c_num = c ^ 48U;
|
||||
c_num0 = (c_num - 10U) >> 8;
|
||||
c_alpha = (c & ~32U) - 55U;
|
||||
c_alpha0 = ((c_alpha - 10U) ^ (c_alpha - 16U)) >> 8;
|
||||
ret |= ((c_num0 | c_alpha0) - 1) >> 8;
|
||||
c_val = (c_num0 & c_num) | (c_alpha0 & c_alpha);
|
||||
key[i / 2] = c_acc | c_val;
|
||||
}
|
||||
|
||||
return 1 & ((ret - 1) >> 8);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0 */
|
||||
/*
|
||||
* Copyright (C) 2015-2019 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
|
||||
*/
|
||||
|
||||
#ifndef KEY_H
|
||||
#define KEY_H
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define WG_KEY_LEN (32)
|
||||
#define WG_KEY_LEN_BASE64 (45)
|
||||
#define WG_KEY_LEN_HEX (65)
|
||||
|
||||
void key_to_base64(char base64[static WG_KEY_LEN_BASE64], const uint8_t key[static WG_KEY_LEN]);
|
||||
bool key_from_base64(uint8_t key[static WG_KEY_LEN], const char *base64);
|
||||
|
||||
void key_to_hex(char hex[static WG_KEY_LEN_HEX], const uint8_t key[static WG_KEY_LEN]);
|
||||
bool key_from_hex(uint8_t key[static WG_KEY_LEN], const char *hex);
|
||||
|
||||
#endif
|
||||
@@ -1,35 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import NetworkExtension
|
||||
|
||||
extension NETunnelProviderProtocol {
|
||||
convenience init?(tunnelConfiguration: TunnelConfiguration) {
|
||||
assert(!tunnelConfiguration.interface.name.isEmpty)
|
||||
guard let serializedTunnelConfiguration = try? JSONEncoder().encode(tunnelConfiguration) else { return nil }
|
||||
|
||||
self.init()
|
||||
|
||||
let appId = Bundle.main.bundleIdentifier!
|
||||
providerBundleIdentifier = "\(appId).network-extension"
|
||||
providerConfiguration = [
|
||||
"tunnelConfiguration": serializedTunnelConfiguration,
|
||||
"tunnelConfigurationVersion": 1
|
||||
]
|
||||
|
||||
let endpoints = tunnelConfiguration.peers.compactMap({$0.endpoint})
|
||||
if endpoints.count == 1 {
|
||||
serverAddress = endpoints.first!.stringRepresentation()
|
||||
} else if endpoints.isEmpty {
|
||||
serverAddress = "Unspecified"
|
||||
} else {
|
||||
serverAddress = "Multiple endpoints"
|
||||
}
|
||||
username = tunnelConfiguration.interface.name
|
||||
}
|
||||
|
||||
func tunnelConfiguration() -> TunnelConfiguration? {
|
||||
guard let serializedTunnelConfiguration = providerConfiguration?["tunnelConfiguration"] as? Data else { return nil }
|
||||
return try? JSONDecoder().decode(TunnelConfiguration.self, from: serializedTunnelConfiguration)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,6 @@
|
||||
<dict>
|
||||
<key>FILEHEADER</key>
|
||||
<string> SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.</string>
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
// Generic alert action names
|
||||
|
||||
"actionOK" = "OK";
|
||||
"actionCancel" = "Cancel";
|
||||
"actionSave" = "Save";
|
||||
|
||||
// Tunnels list UI
|
||||
|
||||
"tunnelsListTitle" = "WireGuard";
|
||||
"tunnelsListSettingsButtonTitle" = "Settings";
|
||||
"tunnelsListCenteredAddTunnelButtonTitle" = "Add a tunnel";
|
||||
"tunnelsListSwipeDeleteButtonTitle" = "Delete";
|
||||
|
||||
// Tunnels list menu
|
||||
|
||||
"addTunnelMenuHeader" = "Add a new WireGuard tunnel";
|
||||
"addTunnelMenuImportFile" = "Create from file or archive";
|
||||
"addTunnelMenuQRCode" = "Create from QR code";
|
||||
"addTunnelMenuFromScratch" = "Create from scratch";
|
||||
|
||||
// Tunnels list alerts
|
||||
|
||||
"alertImportedFromZipTitle (%d)" = "Created %d tunnels";
|
||||
"alertImportedFromZipMessage (%1$d of %2$d)" = "Created %1$d of %2$d tunnels from zip archive";
|
||||
|
||||
"alertUnableToImportTitle" = "Unable to import tunnel";
|
||||
"alertUnableToImportMessage" = "An error occured when importing the tunnel configuration.";
|
||||
|
||||
// Tunnel detail and edit UI
|
||||
|
||||
"newTunnelViewTitle" = "New configuration";
|
||||
"editTunnelViewTitle" = "Edit configuration";
|
||||
|
||||
"tunnelSectionTitleStatus" = "Status";
|
||||
|
||||
"tunnelStatusInactive" = "Inactive";
|
||||
"tunnelStatusActivating" = "Activating";
|
||||
"tunnelStatusActive" = "Active";
|
||||
"tunnelStatusDeactivating" = "Deactivating";
|
||||
"tunnelStatusReasserting" = "Reactivating";
|
||||
"tunnelStatusRestarting" = "Restarting";
|
||||
"tunnelStatusWaiting" = "Waiting";
|
||||
|
||||
"tunnelSectionTitleInterface" = "Interface";
|
||||
|
||||
"tunnelInterfaceName" = "Name";
|
||||
"tunnelInterfacePrivateKey" = "Private key";
|
||||
"tunnelInterfacePublicKey" = "Public key";
|
||||
"tunnelInterfaceGenerateKeypair" = "Generate keypair";
|
||||
"tunnelInterfaceAddresses" = "Addresses";
|
||||
"tunnelInterfaceListenPort" = "Listen port";
|
||||
"tunnelInterfaceMTU" = "MTU";
|
||||
"tunnelInterfaceDNS" = "DNS servers";
|
||||
|
||||
"tunnelSectionTitlePeer" = "Peer";
|
||||
|
||||
"tunnelPeerPublicKey" = "Public key";
|
||||
"tunnelPeerPreSharedKey" = "Preshared key";
|
||||
"tunnelPeerEndpoint" = "Endpoint";
|
||||
"tunnelPeerPersistentKeepalive" = "Persistent keepalive";
|
||||
"tunnelPeerAllowedIPs" = "Allowed IPs";
|
||||
"tunnelPeerRxBytes" = "Data received";
|
||||
"tunnelPeerTxBytes" = "Data sent";
|
||||
"tunnelPeerLastHandshakeTime" = "Latest handshake";
|
||||
"tunnelPeerExcludePrivateIPs" = "Exclude private IPs";
|
||||
|
||||
"tunnelSectionTitleOnDemand" = "On-Demand Activation";
|
||||
|
||||
"tunnelOnDemandKey" = "Activate on demand";
|
||||
"tunnelOnDemandOptionOff" = "Off";
|
||||
"tunnelOnDemandOptionWiFiOnly" = "Wi-Fi only";
|
||||
"tunnelOnDemandOptionWiFiOrCellular" = "Wi-Fi or cellular";
|
||||
"tunnelOnDemandOptionCellularOnly" = "Cellular only";
|
||||
"tunnelOnDemandOptionWiFiOrEthernet" = "Wi-Fi or ethernet";
|
||||
"tunnelOnDemandOptionEthernetOnly" = "Ethernet only";
|
||||
|
||||
"addPeerButtonTitle" = "Add peer";
|
||||
|
||||
"deletePeerButtonTitle" = "Delete peer";
|
||||
"deletePeerConfirmationAlertButtonTitle" = "Delete";
|
||||
"deletePeerConfirmationAlertMessage" = "Delete this peer?";
|
||||
|
||||
"deleteTunnelButtonTitle" = "Delete tunnel";
|
||||
"deleteTunnelConfirmationAlertButtonTitle" = "Delete";
|
||||
"deleteTunnelConfirmationAlertMessage" = "Delete this tunnel?";
|
||||
|
||||
"tunnelEditPlaceholderTextRequired" = "Required";
|
||||
"tunnelEditPlaceholderTextOptional" = "Optional";
|
||||
"tunnelEditPlaceholderTextAutomatic" = "Automatic";
|
||||
"tunnelEditPlaceholderTextStronglyRecommended" = "Strongly recommended";
|
||||
"tunnelEditPlaceholderTextOff" = "Off";
|
||||
|
||||
"tunnelPeerPersistentKeepaliveValue (%@)" = "every %@ seconds";
|
||||
"tunnelHandshakeTimestampNow" = "Now";
|
||||
"tunnelHandshakeTimestampSystemClockBackward" = "(System clock wound backwards)";
|
||||
"tunnelHandshakeTimestampAgo (%@)" = "%@ ago";
|
||||
"tunnelHandshakeTimestampYear (%d)" = "%d year";
|
||||
"tunnelHandshakeTimestampYears (%d)" = "%d years";
|
||||
"tunnelHandshakeTimestampDay (%d)" = "%d day";
|
||||
"tunnelHandshakeTimestampDays (%d)" = "%d days";
|
||||
"tunnelHandshakeTimestampHour (%d)" = "%d hour";
|
||||
"tunnelHandshakeTimestampHours (%d)" = "%d hours";
|
||||
"tunnelHandshakeTimestampMinute (%d)" = "%d minute";
|
||||
"tunnelHandshakeTimestampMinutes (%d)" = "%d minutes";
|
||||
"tunnelHandshakeTimestampSecond (%d)" = "%d second";
|
||||
"tunnelHandshakeTimestampSeconds (%d)" = "%d seconds";
|
||||
|
||||
"tunnelHandshakeTimestampHours hh:mm:ss (%@)" = "%@ hours";
|
||||
"tunnelHandshakeTimestampMinutes mm:ss (%@)" = "%@ minutes";
|
||||
|
||||
// Error alerts while creating / editing a tunnel configuration
|
||||
|
||||
/* Alert title for error in the interface data */
|
||||
"alertInvalidInterfaceTitle" = "Invalid interface";
|
||||
|
||||
/* Any one of the following alert messages can go with the above title */
|
||||
"alertInvalidInterfaceMessageNameRequired" = "Interface name is required";
|
||||
"alertInvalidInterfaceMessagePrivateKeyRequired" = "Interface’s private key is required";
|
||||
"alertInvalidInterfaceMessagePrivateKeyInvalid" = "Interface’s private key must be a 32-byte key in base64 encoding";
|
||||
"alertInvalidInterfaceMessageAddressInvalid" = "Interface addresses must be a list of comma-separated IP addresses, optionally in CIDR notation";
|
||||
"alertInvalidInterfaceMessageListenPortInvalid" = "Interface’s listen port must be between 0 and 65535, or unspecified";
|
||||
"alertInvalidInterfaceMessageMTUInvalid" = "Interface’s MTU must be between 576 and 65535, or unspecified";
|
||||
"alertInvalidInterfaceMessageDNSInvalid" = "Interface’s DNS servers must be a list of comma-separated IP addresses";
|
||||
|
||||
/* Alert title for error in the peer data */
|
||||
"alertInvalidPeerTitle" = "Invalid peer";
|
||||
|
||||
/* Any one of the following alert messages can go with the above title */
|
||||
"alertInvalidPeerMessagePublicKeyRequired" = "Peer’s public key is required";
|
||||
"alertInvalidPeerMessagePublicKeyInvalid" = "Peer’s public key must be a 32-byte key in base64 encoding";
|
||||
"alertInvalidPeerMessagePreSharedKeyInvalid" = "Peer’s preshared key must be a 32-byte key in base64 encoding";
|
||||
"alertInvalidPeerMessageAllowedIPsInvalid" = "Peer’s allowed IPs must be a list of comma-separated IP addresses, optionally in CIDR notation";
|
||||
"alertInvalidPeerMessageEndpointInvalid" = "Peer’s endpoint must be of the form ‘host:port’ or ‘[host]:port’";
|
||||
"alertInvalidPeerMessagePersistentKeepaliveInvalid" = "Peer’s persistent keepalive must be between 0 to 65535, or unspecified";
|
||||
"alertInvalidPeerMessagePublicKeyDuplicated" = "Two or more peers cannot have the same public key";
|
||||
|
||||
// Scanning QR code UI
|
||||
|
||||
"scanQRCodeViewTitle" = "Scan QR code";
|
||||
"scanQRCodeTipText" = "Tip: Generate with `qrencode -t ansiutf8 < tunnel.conf`";
|
||||
|
||||
// Scanning QR code alerts
|
||||
|
||||
"alertScanQRCodeCameraUnsupportedTitle" = "Camera Unsupported";
|
||||
"alertScanQRCodeCameraUnsupportedMessage" = "This device is not able to scan QR codes";
|
||||
|
||||
"alertScanQRCodeInvalidQRCodeTitle" = "Invalid QR Code";
|
||||
"alertScanQRCodeInvalidQRCodeMessage" = "The scanned QR code is not a valid WireGuard configuration";
|
||||
|
||||
"alertScanQRCodeUnreadableQRCodeTitle" = "Invalid Code";
|
||||
"alertScanQRCodeUnreadableQRCodeMessage" = "The scanned code could not be read";
|
||||
|
||||
"alertScanQRCodeNamePromptTitle" = "Please name the scanned tunnel";
|
||||
|
||||
// Settings UI
|
||||
|
||||
"settingsViewTitle" = "Settings";
|
||||
|
||||
"settingsSectionTitleAbout" = "About";
|
||||
"settingsVersionKeyWireGuardForIOS" = "WireGuard for iOS";
|
||||
"settingsVersionKeyWireGuardGoBackend" = "WireGuard Go Backend";
|
||||
|
||||
"settingsSectionTitleExportConfigurations" = "Export configurations";
|
||||
"settingsExportZipButtonTitle" = "Export zip archive";
|
||||
|
||||
"settingsSectionTitleTunnelLog" = "Tunnel log";
|
||||
"settingsExportLogFileButtonTitle" = "Export log file";
|
||||
|
||||
// Settings alerts
|
||||
|
||||
"alertUnableToRemovePreviousLogTitle" = "Log export failed";
|
||||
"alertUnableToRemovePreviousLogMessage" = "The pre-existing log could not be cleared";
|
||||
|
||||
"alertUnableToFindExtensionLogPathTitle" = "Log export failed";
|
||||
"alertUnableToFindExtensionLogPathMessage" = "Unable to determine extension log path";
|
||||
|
||||
"alertUnableToWriteLogTitle" = "Log export failed";
|
||||
"alertUnableToWriteLogMessage" = "Unable to write logs to file";
|
||||
|
||||
// Zip import / export error alerts
|
||||
|
||||
"alertCantOpenInputZipFileTitle" = "Unable to read zip archive";
|
||||
"alertCantOpenInputZipFileMessage" = "The zip archive could not be read.";
|
||||
|
||||
"alertCantOpenOutputZipFileForWritingTitle" = "Unable to create zip archive";
|
||||
"alertCantOpenOutputZipFileForWritingMessage" = "Could not open zip file for writing.";
|
||||
|
||||
"alertBadArchiveTitle" = "Unable to read zip archive";
|
||||
"alertBadArchiveMessage" = "Bad or corrupt zip archive.";
|
||||
|
||||
"alertNoTunnelsToExportTitle" = "Nothing to export";
|
||||
"alertNoTunnelsToExportMessage" = "There are no tunnels to export";
|
||||
|
||||
"alertNoTunnelsInImportedZipArchiveTitle" = "No tunnels in zip archive";
|
||||
"alertNoTunnelsInImportedZipArchiveMessage" = "No .conf tunnel files were found inside the zip archive.";
|
||||
|
||||
// Tunnel management error alerts
|
||||
|
||||
"alertTunnelActivationFailureTitle" = "Activation failure";
|
||||
"alertTunnelActivationFailureMessage" = "The tunnel could not be activated. Please ensure that you are connected to the Internet.";
|
||||
"alertTunnelActivationSavedConfigFailureMessage" = "Unable to retrieve tunnel information from the saved configuration.";
|
||||
"alertTunnelActivationBackendFailureMessage" = "Unable to turn on Go backend library.";
|
||||
"alertTunnelActivationFileDescriptorFailureMessage" = "Unable to determine TUN device file descriptor.";
|
||||
"alertTunnelActivationSetNetworkSettingsMessage" = "Unable to apply network settings to tunnel object.";
|
||||
|
||||
"alertTunnelActivationFailureOnDemandAddendum" = " This tunnel has Activate On Demand enabled, so this tunnel might be re-activated automatically by the OS. You may turn off Activate On Demand in this app by editing the tunnel configuration.";
|
||||
|
||||
"alertTunnelDNSFailureTitle" = "DNS resolution failure";
|
||||
"alertTunnelDNSFailureMessage" = "One or more endpoint domains could not be resolved.";
|
||||
|
||||
"alertTunnelNameEmptyTitle" = "No name provided";
|
||||
"alertTunnelNameEmptyMessage" = "Cannot create tunnel with an empty name";
|
||||
|
||||
"alertTunnelAlreadyExistsWithThatNameTitle" = "Name already exists";
|
||||
"alertTunnelAlreadyExistsWithThatNameMessage" = "A tunnel with that name already exists";
|
||||
|
||||
"alertTunnelActivationErrorTunnelIsNotInactiveTitle" = "Activation in progress";
|
||||
"alertTunnelActivationErrorTunnelIsNotInactiveMessage" = "The tunnel is already active or in the process of being activated";
|
||||
|
||||
// Tunnel management error alerts on system error
|
||||
|
||||
/* The alert message that goes with the following titles would be
|
||||
one of the alertSystemErrorMessage* listed further down */
|
||||
"alertSystemErrorOnListingTunnelsTitle" = "Unable to list tunnels";
|
||||
"alertSystemErrorOnAddTunnelTitle" = "Unable to create tunnel";
|
||||
"alertSystemErrorOnModifyTunnelTitle" = "Unable to modify tunnel";
|
||||
"alertSystemErrorOnRemoveTunnelTitle" = "Unable to remove tunnel";
|
||||
|
||||
/* The alert message for this alert shall include
|
||||
one of the alertSystemErrorMessage* listed further down */
|
||||
"alertTunnelActivationSystemErrorTitle" = "Activation failure";
|
||||
"alertTunnelActivationSystemErrorMessage (%@)" = "The tunnel could not be activated. %@";
|
||||
|
||||
/* alertSystemErrorMessage* messages */
|
||||
"alertSystemErrorMessageTunnelConfigurationInvalid" = "The configuration is invalid.";
|
||||
"alertSystemErrorMessageTunnelConfigurationDisabled" = "The configuration is disabled.";
|
||||
"alertSystemErrorMessageTunnelConnectionFailed" = "The connection failed.";
|
||||
"alertSystemErrorMessageTunnelConfigurationStale" = "The configuration is stale.";
|
||||
"alertSystemErrorMessageTunnelConfigurationReadWriteFailed" = "Reading or writing the configuration failed.";
|
||||
"alertSystemErrorMessageTunnelConfigurationUnknown" = "Unknown system error.";
|
||||
|
||||
// Mac status bar menu / pulldown menu
|
||||
|
||||
"macMenuNetworks (%@)" = "Networks: %@";
|
||||
"macMenuNetworksNone" = "Networks: None";
|
||||
|
||||
"macMenuTitle" = "WireGuard";
|
||||
"macMenuManageTunnels" = "Manage tunnels";
|
||||
"macMenuImportTunnels" = "Import tunnel(s) from file…";
|
||||
"macMenuAddEmptyTunnel" = "Add empty tunnel…";
|
||||
"macMenuExportLog" = "Export log to file…";
|
||||
"macMenuExportTunnels" = "Export tunnels to zip…";
|
||||
"macMenuAbout" = "About WireGuard";
|
||||
"macMenuQuit" = "Quit";
|
||||
|
||||
// Mac manage tunnels window
|
||||
|
||||
"macWindowTitleManageTunnels" = "Manage WireGuard Tunnels";
|
||||
|
||||
"macDeleteTunnelConfirmationAlertMessage (%@)" = "Are you sure you want to delete ‘%@’?";
|
||||
"macDeleteTunnelConfirmationAlertInfo" = "You cannot undo this action.";
|
||||
"macDeleteTunnelConfirmationAlertButtonTitleDelete" = "Delete";
|
||||
"macDeleteTunnelConfirmationAlertButtonTitleCancel" = "Cancel";
|
||||
|
||||
"macButtonImportTunnels" = "Import tunnel(s) from file";
|
||||
"macSheetButtonImport" = "Import";
|
||||
|
||||
"macNameFieldExportLog" = "Export log to";
|
||||
"macSheetButtonExportLog" = "Save";
|
||||
|
||||
"macNameFieldExportZip" = "Export tunnels to";
|
||||
"macSheetButtonExportZip" = "Save";
|
||||
|
||||
// Mac detail/edit view fields
|
||||
|
||||
"macFieldKey (%@)" = "%@:";
|
||||
"macFieldOnDemand" = "On-Demand:";
|
||||
|
||||
// Mac status display
|
||||
|
||||
"macStatus (%@)" = "Status: %@";
|
||||
|
||||
// Mac editing config
|
||||
|
||||
"macEditDiscard" = "Discard";
|
||||
"macEditSave" = "Save";
|
||||
|
||||
"macAlertNameIsEmpty" = "Name is required";
|
||||
"macAlertDuplicateName (%@)" = "Another tunnel already exists with the name ‘%@’.";
|
||||
|
||||
"macAlertInvalidLine (%@)" = "Invalid line: ‘%@’.";
|
||||
|
||||
"macAlertNoInterface" = "Configuration must have an ‘Interface’ section.";
|
||||
"macAlertMultipleInterfaces" = "Configuration must have only one ‘Interface’ section.";
|
||||
"macAlertPrivateKeyInvalid" = "Private key is invalid.";
|
||||
"macAlertListenPortInvalid (%@)" = "Listen port ‘%@’ is invalid.";
|
||||
"macAlertAddressInvalid (%@)" = "Address ‘%@’ is invalid.";
|
||||
"macAlertDNSInvalid (%@)" = "DNS ‘%@’ is invalid.";
|
||||
"macAlertMTUInvalid (%@)" = "MTU ‘%@’ is invalid.";
|
||||
|
||||
"macAlertUnrecognizedInterfaceKey (%@)" = "Interface contains unrecognized key ‘%@’";
|
||||
"macAlertInfoUnrecognizedInterfaceKey" = "Valid keys are: ‘PrivateKey’, ‘ListenPort’, ‘Address’, ‘DNS’ and ‘MTU’.";
|
||||
|
||||
"macAlertPublicKeyInvalid" = "Public key is invalid";
|
||||
"macAlertPreSharedKeyInvalid" = "Preshared key is invalid";
|
||||
"macAlertAllowedIPInvalid (%@)" = "Allowed IP ‘%@’ is invalid";
|
||||
"macAlertEndpointInvalid (%@)" = "Endpoint ‘%@’ is invalid";
|
||||
"macAlertPersistentKeepliveInvalid (%@)" = "Persistent keepalive value ‘%@’ is invalid";
|
||||
|
||||
"macAlertUnrecognizedPeerKey (%@)" = "Peer contains unrecognized key ‘%@’";
|
||||
"macAlertInfoUnrecognizedPeerKey" = "Valid keys are: ‘PublicKey’, ‘PresharedKey’, ‘AllowedIPs’, ‘Endpoint’ and ‘PersistentKeepalive’";
|
||||
|
||||
"macAlertMultipleEntriesForKey (%@)" = "There should be only one entry per section for key ‘%@’";
|
||||
|
||||
// Mac about dialog
|
||||
|
||||
"macAppVersion (%@)" = "App version: %@";
|
||||
"macGoBackendVersion (%@)" = "Go backend version: %@";
|
||||
|
||||
// Privacy
|
||||
|
||||
"macExportPrivateData" = "export tunnel private keys";
|
||||
"macViewPrivateData" = "view tunnel private keys";
|
||||
"iosExportPrivateData" = "Authenticate to export tunnel private keys.";
|
||||
"iosViewPrivateData" = "Authenticate to view tunnel private keys.";
|
||||
|
||||
// Mac alert
|
||||
|
||||
"macAppExitingWithActiveTunnelMessage" = "WireGuard is exiting with an active tunnel";
|
||||
"macAppExitingWithActiveTunnelInfo" = "The tunnel will remain active after exiting. You may disable it by reopening this application or through the Network panel in System Preferences.";
|
||||
@@ -3,7 +3,8 @@
|
||||
// You Apple developer account's Team ID
|
||||
DEVELOPMENT_TEAM = <team_id>
|
||||
|
||||
// The bundle identifier of this app.
|
||||
// The bundle identifier of the apps.
|
||||
// Should be an app id created at developer.apple.com
|
||||
// with Network Extensions capabilty.
|
||||
APP_ID = <app_id>
|
||||
APP_ID_IOS = <app_id>
|
||||
APP_ID_MACOS = <app_id>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
VERSION_NAME = 0.0.20181104
|
||||
VERSION_ID = 5
|
||||
VERSION_NAME = 0.0.20190207
|
||||
VERSION_ID = 2
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
class WgQuickConfigFileParser {
|
||||
|
||||
enum ParserState {
|
||||
case inInterfaceSection
|
||||
case inPeerSection
|
||||
case notInASection
|
||||
}
|
||||
|
||||
enum ParseError: Error {
|
||||
case invalidLine(_ line: String.SubSequence)
|
||||
case noInterface
|
||||
case invalidInterface
|
||||
case multipleInterfaces
|
||||
case multiplePeersWithSamePublicKey
|
||||
case invalidPeer
|
||||
}
|
||||
|
||||
static func parse(_ text: String, name: String) throws -> TunnelConfiguration {
|
||||
|
||||
assert(!name.isEmpty)
|
||||
|
||||
func collate(interfaceAttributes attributes: [String: String]) -> InterfaceConfiguration? {
|
||||
// required wg fields
|
||||
guard let privateKeyString = attributes["privatekey"] else { return nil }
|
||||
guard let privateKey = Data(base64Encoded: privateKeyString), privateKey.count == 32 else { return nil }
|
||||
var interface = InterfaceConfiguration(name: name, privateKey: privateKey)
|
||||
// other wg fields
|
||||
if let listenPortString = attributes["listenport"] {
|
||||
guard let listenPort = UInt16(listenPortString) else { return nil }
|
||||
interface.listenPort = listenPort
|
||||
}
|
||||
// wg-quick fields
|
||||
if let addressesString = attributes["address"] {
|
||||
var addresses: [IPAddressRange] = []
|
||||
for addressString in addressesString.split(separator: ",") {
|
||||
let trimmedString = addressString.trimmingCharacters(in: .whitespaces)
|
||||
guard let address = IPAddressRange(from: trimmedString) else { return nil }
|
||||
addresses.append(address)
|
||||
}
|
||||
interface.addresses = addresses
|
||||
}
|
||||
if let dnsString = attributes["dns"] {
|
||||
var dnsServers: [DNSServer] = []
|
||||
for dnsServerString in dnsString.split(separator: ",") {
|
||||
let trimmedString = dnsServerString.trimmingCharacters(in: .whitespaces)
|
||||
guard let dnsServer = DNSServer(from: trimmedString) else { return nil }
|
||||
dnsServers.append(dnsServer)
|
||||
}
|
||||
interface.dns = dnsServers
|
||||
}
|
||||
if let mtuString = attributes["mtu"] {
|
||||
guard let mtu = UInt16(mtuString) else { return nil }
|
||||
interface.mtu = mtu
|
||||
}
|
||||
return interface
|
||||
}
|
||||
|
||||
func collate(peerAttributes attributes: [String: String]) -> PeerConfiguration? {
|
||||
// required wg fields
|
||||
guard let publicKeyString = attributes["publickey"] else { return nil }
|
||||
guard let publicKey = Data(base64Encoded: publicKeyString), publicKey.count == 32 else { return nil }
|
||||
var peer = PeerConfiguration(publicKey: publicKey)
|
||||
// wg fields
|
||||
if let preSharedKeyString = attributes["presharedkey"] {
|
||||
guard let preSharedKey = Data(base64Encoded: preSharedKeyString), preSharedKey.count == 32 else { return nil }
|
||||
peer.preSharedKey = preSharedKey
|
||||
}
|
||||
if let allowedIPsString = attributes["allowedips"] {
|
||||
var allowedIPs: [IPAddressRange] = []
|
||||
for allowedIPString in allowedIPsString.split(separator: ",") {
|
||||
let trimmedString = allowedIPString.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
guard let allowedIP = IPAddressRange(from: trimmedString) else { return nil }
|
||||
allowedIPs.append(allowedIP)
|
||||
}
|
||||
peer.allowedIPs = allowedIPs
|
||||
}
|
||||
if let endpointString = attributes["endpoint"] {
|
||||
guard let endpoint = Endpoint(from: endpointString) else { return nil }
|
||||
peer.endpoint = endpoint
|
||||
}
|
||||
if let persistentKeepAliveString = attributes["persistentkeepalive"] {
|
||||
guard let persistentKeepAlive = UInt16(persistentKeepAliveString) else { return nil }
|
||||
peer.persistentKeepAlive = persistentKeepAlive
|
||||
}
|
||||
return peer
|
||||
}
|
||||
|
||||
var interfaceConfiguration: InterfaceConfiguration?
|
||||
var peerConfigurations: [PeerConfiguration] = []
|
||||
|
||||
let lines = text.split(separator: "\n")
|
||||
|
||||
var parserState: ParserState = .notInASection
|
||||
var attributes: [String: String] = [:]
|
||||
|
||||
for (lineIndex, line) in lines.enumerated() {
|
||||
var trimmedLine: String
|
||||
if let commentRange = line.range(of: "#") {
|
||||
trimmedLine = String(line[..<commentRange.lowerBound])
|
||||
} else {
|
||||
trimmedLine = String(line)
|
||||
}
|
||||
|
||||
trimmedLine = trimmedLine.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
guard trimmedLine.count > 0 else { continue }
|
||||
let lowercasedLine = line.lowercased()
|
||||
|
||||
if let equalsIndex = line.firstIndex(of: "=") {
|
||||
// Line contains an attribute
|
||||
let key = line[..<equalsIndex].trimmingCharacters(in: .whitespaces).lowercased()
|
||||
let value = line[line.index(equalsIndex, offsetBy: 1)...].trimmingCharacters(in: .whitespaces)
|
||||
let keysWithMultipleEntriesAllowed: Set<String> = ["address", "allowedips", "dns"]
|
||||
if let presentValue = attributes[key], keysWithMultipleEntriesAllowed.contains(key) {
|
||||
attributes[key] = presentValue + "," + value
|
||||
} else {
|
||||
attributes[key] = value
|
||||
}
|
||||
} else {
|
||||
if (lowercasedLine != "[interface]" && lowercasedLine != "[peer]") {
|
||||
throw ParseError.invalidLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
let isLastLine: Bool = (lineIndex == lines.count - 1)
|
||||
|
||||
if (isLastLine || lowercasedLine == "[interface]" || lowercasedLine == "[peer]") {
|
||||
// Previous section has ended; process the attributes collected so far
|
||||
if (parserState == .inInterfaceSection) {
|
||||
guard let interface = collate(interfaceAttributes: attributes) else { throw ParseError.invalidInterface }
|
||||
guard (interfaceConfiguration == nil) else { throw ParseError.multipleInterfaces }
|
||||
interfaceConfiguration = interface
|
||||
} else if (parserState == .inPeerSection) {
|
||||
guard let peer = collate(peerAttributes: attributes) else { throw ParseError.invalidPeer }
|
||||
peerConfigurations.append(peer)
|
||||
}
|
||||
}
|
||||
|
||||
if (lowercasedLine == "[interface]") {
|
||||
parserState = .inInterfaceSection
|
||||
attributes.removeAll()
|
||||
} else if (lowercasedLine == "[peer]") {
|
||||
parserState = .inPeerSection
|
||||
attributes.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
let peerPublicKeysArray = peerConfigurations.map { $0.publicKey }
|
||||
let peerPublicKeysSet = Set<Data>(peerPublicKeysArray)
|
||||
if (peerPublicKeysArray.count != peerPublicKeysSet.count) {
|
||||
throw ParseError.multiplePeersWithSamePublicKey
|
||||
}
|
||||
|
||||
if let interfaceConfiguration = interfaceConfiguration {
|
||||
let tunnelConfiguration = TunnelConfiguration(interface: interfaceConfiguration, peers: peerConfigurations)
|
||||
return tunnelConfiguration
|
||||
} else {
|
||||
throw ParseError.noInterface
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class WgQuickConfigFileWriter {
|
||||
static func writeConfigFile(from tc: TunnelConfiguration) -> Data? {
|
||||
let interface = tc.interface
|
||||
var output = "[Interface]\n"
|
||||
output.append("PrivateKey = \(interface.privateKey.base64EncodedString())\n")
|
||||
if let listenPort = interface.listenPort {
|
||||
output.append("ListenPort = \(listenPort)\n")
|
||||
}
|
||||
if (!interface.addresses.isEmpty) {
|
||||
let addressString = interface.addresses.map { $0.stringRepresentation() }.joined(separator: ", ")
|
||||
output.append("Address = \(addressString)\n")
|
||||
}
|
||||
if (!interface.dns.isEmpty) {
|
||||
let dnsString = interface.dns.map { $0.stringRepresentation() }.joined(separator: ", ")
|
||||
output.append("DNS = \(dnsString)\n")
|
||||
}
|
||||
if let mtu = interface.mtu {
|
||||
output.append("MTU = \(mtu)\n")
|
||||
}
|
||||
|
||||
for peer in tc.peers {
|
||||
output.append("\n[Peer]\n")
|
||||
output.append("PublicKey = \(peer.publicKey.base64EncodedString())\n")
|
||||
if let preSharedKey = peer.preSharedKey {
|
||||
output.append("PresharedKey = \(preSharedKey.base64EncodedString())\n")
|
||||
}
|
||||
if (!peer.allowedIPs.isEmpty) {
|
||||
let allowedIPsString = peer.allowedIPs.map { $0.stringRepresentation() }.joined(separator: ", ")
|
||||
output.append("AllowedIPs = \(allowedIPsString)\n")
|
||||
}
|
||||
if let endpoint = peer.endpoint {
|
||||
output.append("Endpoint = \(endpoint.stringRepresentation())\n")
|
||||
}
|
||||
if let persistentKeepAlive = peer.persistentKeepAlive {
|
||||
output.append("PersistentKeepalive = \(persistentKeepAlive)\n")
|
||||
}
|
||||
}
|
||||
|
||||
return output.data(using: .utf8)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,30 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
import Foundation
|
||||
|
||||
struct Curve25519 {
|
||||
|
||||
static let keyLength: Int = 32
|
||||
|
||||
static func generatePrivateKey() -> Data {
|
||||
var privateKey = Data(repeating: 0, count: 32)
|
||||
privateKey.withUnsafeMutableBytes { (bytes: UnsafeMutablePointer<UInt8>) in
|
||||
var privateKey = Data(repeating: 0, count: TunnelConfiguration.keyLength)
|
||||
privateKey.withUnsafeMutableBytes { bytes in
|
||||
curve25519_generate_private_key(bytes)
|
||||
}
|
||||
assert(privateKey.count == 32)
|
||||
assert(privateKey.count == TunnelConfiguration.keyLength)
|
||||
return privateKey
|
||||
}
|
||||
|
||||
static func generatePublicKey(fromPrivateKey privateKey: Data) -> Data {
|
||||
assert(privateKey.count == 32)
|
||||
var publicKey = Data(repeating: 0, count: 32)
|
||||
privateKey.withUnsafeBytes { (privateKeyBytes: UnsafePointer<UInt8>) in
|
||||
publicKey.withUnsafeMutableBytes { (bytes: UnsafeMutablePointer<UInt8>) in
|
||||
assert(privateKey.count == TunnelConfiguration.keyLength)
|
||||
var publicKey = Data(repeating: 0, count: TunnelConfiguration.keyLength)
|
||||
privateKey.withUnsafeBytes { privateKeyBytes in
|
||||
publicKey.withUnsafeMutableBytes { bytes in
|
||||
curve25519_derive_public_key(bytes, privateKeyBytes)
|
||||
}
|
||||
}
|
||||
assert(publicKey.count == 32)
|
||||
assert(publicKey.count == TunnelConfiguration.keyLength)
|
||||
return publicKey
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0+
|
||||
*
|
||||
* Copyright (C) 2015-2018 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
|
||||
* Copyright (C) 2015-2019 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
|
||||
*
|
||||
* Curve25519 ECDH functions, based on TweetNaCl but cleaned up.
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <assert.h>
|
||||
#include <CommonCrypto/CommonRandom.h>
|
||||
|
||||
#include "x25519.h"
|
||||
@@ -171,7 +172,7 @@ void curve25519_derive_public_key(uint8_t public_key[32], const uint8_t private_
|
||||
|
||||
void curve25519_generate_private_key(uint8_t private_key[32])
|
||||
{
|
||||
CCRandomGenerateBytes(private_key, 32);
|
||||
assert(CCRandomGenerateBytes(private_key, 32) == kCCSuccess);
|
||||
private_key[31] = (private_key[31] & 127) | 64;
|
||||
private_key[0] &= 248;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
func tr(_ key: String) -> String {
|
||||
return NSLocalizedString(key, comment: "")
|
||||
}
|
||||
|
||||
func tr(format: String, _ arguments: CVarArg...) -> String {
|
||||
return String(format: NSLocalizedString(format, comment: ""), arguments: arguments)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import NetworkExtension
|
||||
|
||||
@@ -10,9 +10,16 @@ struct ActivateOnDemandSetting {
|
||||
|
||||
enum ActivateOnDemandOption {
|
||||
case none // Valid only when isActivateOnDemandEnabled is false
|
||||
case useOnDemandOverWiFiOrCellular
|
||||
case useOnDemandOverWiFiOnly
|
||||
#if os(iOS)
|
||||
case useOnDemandOverWiFiOrCellular
|
||||
case useOnDemandOverCellularOnly
|
||||
#elseif os(macOS)
|
||||
case useOnDemandOverWiFiOrEthernet
|
||||
case useOnDemandOverEthernetOnly
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
}
|
||||
|
||||
extension ActivateOnDemandSetting {
|
||||
@@ -21,9 +28,10 @@ extension ActivateOnDemandSetting {
|
||||
let rules: [NEOnDemandRule]?
|
||||
let connectRule = NEOnDemandRuleConnect()
|
||||
let disconnectRule = NEOnDemandRuleDisconnect()
|
||||
switch (activateOnDemandOption) {
|
||||
switch activateOnDemandOption {
|
||||
case .none:
|
||||
rules = nil
|
||||
#if os(iOS)
|
||||
case .useOnDemandOverWiFiOrCellular:
|
||||
rules = [connectRule]
|
||||
case .useOnDemandOverWiFiOnly:
|
||||
@@ -34,38 +42,64 @@ extension ActivateOnDemandSetting {
|
||||
connectRule.interfaceTypeMatch = .cellular
|
||||
disconnectRule.interfaceTypeMatch = .wiFi
|
||||
rules = [connectRule, disconnectRule]
|
||||
#elseif os(macOS)
|
||||
case .useOnDemandOverWiFiOrEthernet:
|
||||
rules = [connectRule]
|
||||
case .useOnDemandOverWiFiOnly:
|
||||
connectRule.interfaceTypeMatch = .wiFi
|
||||
disconnectRule.interfaceTypeMatch = .ethernet
|
||||
rules = [connectRule, disconnectRule]
|
||||
case .useOnDemandOverEthernetOnly:
|
||||
connectRule.interfaceTypeMatch = .ethernet
|
||||
disconnectRule.interfaceTypeMatch = .wiFi
|
||||
rules = [connectRule, disconnectRule]
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
}
|
||||
tunnelProviderManager.onDemandRules = rules
|
||||
}
|
||||
|
||||
init(from tunnelProviderManager: NETunnelProviderManager) {
|
||||
let rules = tunnelProviderManager.onDemandRules ?? []
|
||||
#if os(iOS)
|
||||
let otherInterfaceType: NEOnDemandRuleInterfaceType = .cellular
|
||||
let useWiFiOrOtherOption: ActivateOnDemandOption = .useOnDemandOverWiFiOrCellular
|
||||
let useOtherOnlyOption: ActivateOnDemandOption = .useOnDemandOverCellularOnly
|
||||
#elseif os(macOS)
|
||||
let otherInterfaceType: NEOnDemandRuleInterfaceType = .ethernet
|
||||
let useWiFiOrOtherOption: ActivateOnDemandOption = .useOnDemandOverWiFiOrEthernet
|
||||
let useOtherOnlyOption: ActivateOnDemandOption = .useOnDemandOverEthernetOnly
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
let activateOnDemandOption: ActivateOnDemandOption
|
||||
switch (rules.count) {
|
||||
switch rules.count {
|
||||
case 0:
|
||||
activateOnDemandOption = .none
|
||||
case 1:
|
||||
let rule = rules[0]
|
||||
precondition(rule.action == .connect)
|
||||
activateOnDemandOption = .useOnDemandOverWiFiOrCellular
|
||||
activateOnDemandOption = useWiFiOrOtherOption
|
||||
case 2:
|
||||
let connectRule = rules.first(where: { $0.action == .connect })!
|
||||
let disconnectRule = rules.first(where: { $0.action == .disconnect })!
|
||||
if (connectRule.interfaceTypeMatch == .wiFi && disconnectRule.interfaceTypeMatch == .cellular) {
|
||||
if connectRule.interfaceTypeMatch == .wiFi && disconnectRule.interfaceTypeMatch == otherInterfaceType {
|
||||
activateOnDemandOption = .useOnDemandOverWiFiOnly
|
||||
} else if (connectRule.interfaceTypeMatch == .cellular && disconnectRule.interfaceTypeMatch == .wiFi) {
|
||||
activateOnDemandOption = .useOnDemandOverCellularOnly
|
||||
} else if connectRule.interfaceTypeMatch == otherInterfaceType && disconnectRule.interfaceTypeMatch == .wiFi {
|
||||
activateOnDemandOption = useOtherOnlyOption
|
||||
} else {
|
||||
fatalError("Unexpected onDemandRules set on tunnel provider manager")
|
||||
}
|
||||
default:
|
||||
fatalError("Unexpected number of onDemandRules set on tunnel provider manager")
|
||||
}
|
||||
|
||||
self.activateOnDemandOption = activateOnDemandOption
|
||||
if (activateOnDemandOption == .none) {
|
||||
self.isActivateOnDemandEnabled = false
|
||||
if activateOnDemandOption == .none {
|
||||
isActivateOnDemandEnabled = false
|
||||
} else {
|
||||
self.isActivateOnDemandEnabled = tunnelProviderManager.isOnDemandEnabled
|
||||
isActivateOnDemandEnabled = tunnelProviderManager.isOnDemandEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import NetworkExtension
|
||||
|
||||
// Creates mock tunnels for the iOS Simulator.
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
class MockTunnels {
|
||||
static let tunnelNames = [
|
||||
"demo",
|
||||
"edgesecurity",
|
||||
"home",
|
||||
"office",
|
||||
"infra-fr",
|
||||
"infra-us",
|
||||
"krantz",
|
||||
"metheny",
|
||||
"frisell"
|
||||
]
|
||||
static let address = "192.168.%d.%d/32"
|
||||
static let dnsServers = ["8.8.8.8", "8.8.4.4"]
|
||||
static let endpoint = "demo.wireguard.com:51820"
|
||||
static let allowedIPs = "0.0.0.0/0"
|
||||
|
||||
static func createMockTunnels() -> [NETunnelProviderManager] {
|
||||
return tunnelNames.map { tunnelName -> NETunnelProviderManager in
|
||||
|
||||
var interface = InterfaceConfiguration(privateKey: Curve25519.generatePrivateKey())
|
||||
interface.addresses = [IPAddressRange(from: String(format: address, Int.random(in: 1 ... 10), Int.random(in: 1 ... 254)))!]
|
||||
interface.dns = dnsServers.map { DNSServer(from: $0)! }
|
||||
|
||||
var peer = PeerConfiguration(publicKey: Curve25519.generatePublicKey(fromPrivateKey: Curve25519.generatePrivateKey()))
|
||||
peer.endpoint = Endpoint(from: endpoint)
|
||||
peer.allowedIPs = [IPAddressRange(from: allowedIPs)!]
|
||||
|
||||
let tunnelConfiguration = TunnelConfiguration(name: tunnelName, interface: interface, peers: [peer])
|
||||
|
||||
let tunnelProviderManager = NETunnelProviderManager()
|
||||
tunnelProviderManager.protocolConfiguration = NETunnelProviderProtocol(tunnelConfiguration: tunnelConfiguration)
|
||||
tunnelProviderManager.localizedDescription = tunnelConfiguration.name
|
||||
tunnelProviderManager.isEnabled = true
|
||||
|
||||
return tunnelProviderManager
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,184 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension TunnelConfiguration {
|
||||
convenience init(fromUapiConfig uapiConfig: String, basedOn base: TunnelConfiguration? = nil) throws {
|
||||
var interfaceConfiguration: InterfaceConfiguration?
|
||||
var peerConfigurations = [PeerConfiguration]()
|
||||
|
||||
var lines = uapiConfig.split(separator: "\n")
|
||||
lines.append("")
|
||||
|
||||
var parserState = ParserState.inInterfaceSection
|
||||
var attributes = [String: String]()
|
||||
|
||||
for line in lines {
|
||||
var key = ""
|
||||
var value = ""
|
||||
|
||||
if !line.isEmpty {
|
||||
guard let equalsIndex = line.firstIndex(of: "=") else { throw ParseError.invalidLine(line) }
|
||||
key = String(line[..<equalsIndex])
|
||||
value = String(line[line.index(equalsIndex, offsetBy: 1)...])
|
||||
}
|
||||
|
||||
if line.isEmpty || key == "public_key" {
|
||||
// Previous section has ended; process the attributes collected so far
|
||||
if parserState == .inInterfaceSection {
|
||||
let interface = try TunnelConfiguration.collate(interfaceAttributes: attributes)
|
||||
guard interfaceConfiguration == nil else { throw ParseError.multipleInterfaces }
|
||||
interfaceConfiguration = interface
|
||||
parserState = .inPeerSection
|
||||
} else if parserState == .inPeerSection {
|
||||
let peer = try TunnelConfiguration.collate(peerAttributes: attributes)
|
||||
peerConfigurations.append(peer)
|
||||
}
|
||||
attributes.removeAll()
|
||||
if line.isEmpty {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if let presentValue = attributes[key] {
|
||||
if key == "allowed_ip" {
|
||||
attributes[key] = presentValue + "," + value
|
||||
} else {
|
||||
throw ParseError.multipleEntriesForKey(key)
|
||||
}
|
||||
} else {
|
||||
attributes[key] = value
|
||||
}
|
||||
|
||||
let interfaceSectionKeys: Set<String> = ["private_key", "listen_port", "fwmark"]
|
||||
let peerSectionKeys: Set<String> = ["public_key", "preshared_key", "allowed_ip", "endpoint", "persistent_keepalive_interval", "last_handshake_time_sec", "last_handshake_time_nsec", "rx_bytes", "tx_bytes", "protocol_version"]
|
||||
|
||||
if parserState == .inInterfaceSection {
|
||||
guard interfaceSectionKeys.contains(key) else {
|
||||
throw ParseError.interfaceHasUnrecognizedKey(key)
|
||||
}
|
||||
}
|
||||
if parserState == .inPeerSection {
|
||||
guard peerSectionKeys.contains(key) else {
|
||||
throw ParseError.peerHasUnrecognizedKey(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let peerPublicKeysArray = peerConfigurations.map { $0.publicKey }
|
||||
let peerPublicKeysSet = Set<Data>(peerPublicKeysArray)
|
||||
if peerPublicKeysArray.count != peerPublicKeysSet.count {
|
||||
throw ParseError.multiplePeersWithSamePublicKey
|
||||
}
|
||||
|
||||
interfaceConfiguration?.addresses = base?.interface.addresses ?? []
|
||||
interfaceConfiguration?.dns = base?.interface.dns ?? []
|
||||
interfaceConfiguration?.mtu = base?.interface.mtu
|
||||
|
||||
if let interfaceConfiguration = interfaceConfiguration {
|
||||
self.init(name: base?.name, interface: interfaceConfiguration, peers: peerConfigurations)
|
||||
} else {
|
||||
throw ParseError.noInterface
|
||||
}
|
||||
}
|
||||
|
||||
private static func collate(interfaceAttributes attributes: [String: String]) throws -> InterfaceConfiguration {
|
||||
guard let privateKeyString = attributes["private_key"] else {
|
||||
throw ParseError.interfaceHasNoPrivateKey
|
||||
}
|
||||
guard let privateKey = Data(hexKey: privateKeyString), privateKey.count == TunnelConfiguration.keyLength else {
|
||||
throw ParseError.interfaceHasInvalidPrivateKey(privateKeyString)
|
||||
}
|
||||
var interface = InterfaceConfiguration(privateKey: privateKey)
|
||||
if let listenPortString = attributes["listen_port"] {
|
||||
guard let listenPort = UInt16(listenPortString) else {
|
||||
throw ParseError.interfaceHasInvalidListenPort(listenPortString)
|
||||
}
|
||||
if listenPort != 0 {
|
||||
interface.listenPort = listenPort
|
||||
}
|
||||
}
|
||||
return interface
|
||||
}
|
||||
|
||||
private static func collate(peerAttributes attributes: [String: String]) throws -> PeerConfiguration {
|
||||
guard let publicKeyString = attributes["public_key"] else {
|
||||
throw ParseError.peerHasNoPublicKey
|
||||
}
|
||||
guard let publicKey = Data(hexKey: publicKeyString), publicKey.count == TunnelConfiguration.keyLength else {
|
||||
throw ParseError.peerHasInvalidPublicKey(publicKeyString)
|
||||
}
|
||||
var peer = PeerConfiguration(publicKey: publicKey)
|
||||
if let preSharedKeyString = attributes["preshared_key"] {
|
||||
guard let preSharedKey = Data(hexKey: preSharedKeyString), preSharedKey.count == TunnelConfiguration.keyLength else {
|
||||
throw ParseError.peerHasInvalidPreSharedKey(preSharedKeyString)
|
||||
}
|
||||
// TODO(zx2c4): does the compiler optimize this away?
|
||||
var accumulator: UInt8 = 0
|
||||
for index in 0..<preSharedKey.count {
|
||||
accumulator |= preSharedKey[index]
|
||||
}
|
||||
if accumulator != 0 {
|
||||
peer.preSharedKey = preSharedKey
|
||||
}
|
||||
}
|
||||
if let allowedIPsString = attributes["allowed_ip"] {
|
||||
var allowedIPs = [IPAddressRange]()
|
||||
for allowedIPString in allowedIPsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
|
||||
guard let allowedIP = IPAddressRange(from: allowedIPString) else {
|
||||
throw ParseError.peerHasInvalidAllowedIP(allowedIPString)
|
||||
}
|
||||
allowedIPs.append(allowedIP)
|
||||
}
|
||||
peer.allowedIPs = allowedIPs
|
||||
}
|
||||
if let endpointString = attributes["endpoint"] {
|
||||
guard let endpoint = Endpoint(from: endpointString) else {
|
||||
throw ParseError.peerHasInvalidEndpoint(endpointString)
|
||||
}
|
||||
peer.endpoint = endpoint
|
||||
}
|
||||
if let persistentKeepAliveString = attributes["persistent_keepalive_interval"] {
|
||||
guard let persistentKeepAlive = UInt16(persistentKeepAliveString) else {
|
||||
throw ParseError.peerHasInvalidPersistentKeepAlive(persistentKeepAliveString)
|
||||
}
|
||||
if persistentKeepAlive != 0 {
|
||||
peer.persistentKeepAlive = persistentKeepAlive
|
||||
}
|
||||
}
|
||||
if let rxBytesString = attributes["rx_bytes"] {
|
||||
guard let rxBytes = UInt64(rxBytesString) else {
|
||||
throw ParseError.peerHasInvalidTransferBytes(rxBytesString)
|
||||
}
|
||||
if rxBytes != 0 {
|
||||
peer.rxBytes = rxBytes
|
||||
}
|
||||
}
|
||||
if let txBytesString = attributes["tx_bytes"] {
|
||||
guard let txBytes = UInt64(txBytesString) else {
|
||||
throw ParseError.peerHasInvalidTransferBytes(txBytesString)
|
||||
}
|
||||
if txBytes != 0 {
|
||||
peer.txBytes = txBytes
|
||||
}
|
||||
}
|
||||
if let lastHandshakeTimeSecString = attributes["last_handshake_time_sec"] {
|
||||
var lastHandshakeTimeSince1970: TimeInterval = 0
|
||||
guard let lastHandshakeTimeSec = UInt64(lastHandshakeTimeSecString) else {
|
||||
throw ParseError.peerHasInvalidLastHandshakeTime(lastHandshakeTimeSecString)
|
||||
}
|
||||
if lastHandshakeTimeSec != 0 {
|
||||
lastHandshakeTimeSince1970 += Double(lastHandshakeTimeSec)
|
||||
if let lastHandshakeTimeNsecString = attributes["last_handshake_time_nsec"] {
|
||||
guard let lastHandshakeTimeNsec = UInt64(lastHandshakeTimeNsecString) else {
|
||||
throw ParseError.peerHasInvalidLastHandshakeTime(lastHandshakeTimeNsecString)
|
||||
}
|
||||
lastHandshakeTimeSince1970 += Double(lastHandshakeTimeNsec) / 1000000000.0
|
||||
}
|
||||
peer.lastHandshakeTime = Date(timeIntervalSince1970: lastHandshakeTimeSince1970)
|
||||
}
|
||||
}
|
||||
return peer
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import NetworkExtension
|
||||
|
||||
enum TunnelsManagerError: WireGuardAppError {
|
||||
case tunnelNameEmpty
|
||||
case tunnelAlreadyExistsWithThatName
|
||||
case systemErrorOnListingTunnels(systemError: Error)
|
||||
case systemErrorOnAddTunnel(systemError: Error)
|
||||
case systemErrorOnModifyTunnel(systemError: Error)
|
||||
case systemErrorOnRemoveTunnel(systemError: Error)
|
||||
|
||||
var alertText: AlertText {
|
||||
switch self {
|
||||
case .tunnelNameEmpty:
|
||||
return (tr("alertTunnelNameEmptyTitle"), tr("alertTunnelNameEmptyMessage"))
|
||||
case .tunnelAlreadyExistsWithThatName:
|
||||
return (tr("alertTunnelAlreadyExistsWithThatNameTitle"), tr("alertTunnelAlreadyExistsWithThatNameMessage"))
|
||||
case .systemErrorOnListingTunnels(let systemError):
|
||||
return (tr("alertSystemErrorOnListingTunnelsTitle"), systemError.localizedUIString)
|
||||
case .systemErrorOnAddTunnel(let systemError):
|
||||
return (tr("alertSystemErrorOnAddTunnelTitle"), systemError.localizedUIString)
|
||||
case .systemErrorOnModifyTunnel(let systemError):
|
||||
return (tr("alertSystemErrorOnModifyTunnelTitle"), systemError.localizedUIString)
|
||||
case .systemErrorOnRemoveTunnel(let systemError):
|
||||
return (tr("alertSystemErrorOnRemoveTunnelTitle"), systemError.localizedUIString)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TunnelsManagerActivationAttemptError: WireGuardAppError {
|
||||
case tunnelIsNotInactive
|
||||
case failedWhileStarting(systemError: Error) // startTunnel() throwed
|
||||
case failedWhileSaving(systemError: Error) // save config after re-enabling throwed
|
||||
case failedWhileLoading(systemError: Error) // reloading config throwed
|
||||
case failedBecauseOfTooManyErrors(lastSystemError: Error) // recursion limit reached
|
||||
|
||||
var alertText: AlertText {
|
||||
switch self {
|
||||
case .tunnelIsNotInactive:
|
||||
return (tr("alertTunnelActivationErrorTunnelIsNotInactiveTitle"), tr("alertTunnelActivationErrorTunnelIsNotInactiveMessage"))
|
||||
case .failedWhileStarting(let systemError),
|
||||
.failedWhileSaving(let systemError),
|
||||
.failedWhileLoading(let systemError),
|
||||
.failedBecauseOfTooManyErrors(let systemError):
|
||||
return (tr("alertTunnelActivationSystemErrorTitle"),
|
||||
tr(format: "alertTunnelActivationSystemErrorMessage (%@)", systemError.localizedUIString))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TunnelsManagerActivationError: WireGuardAppError {
|
||||
case activationFailed(wasOnDemandEnabled: Bool)
|
||||
case activationFailedWithExtensionError(title: String, message: String, wasOnDemandEnabled: Bool)
|
||||
|
||||
var alertText: AlertText {
|
||||
switch self {
|
||||
case .activationFailed(let wasOnDemandEnabled):
|
||||
return (tr("alertTunnelActivationFailureTitle"), tr("alertTunnelActivationFailureMessage") + (wasOnDemandEnabled ? tr("alertTunnelActivationFailureOnDemandAddendum") : ""))
|
||||
case .activationFailedWithExtensionError(let title, let message, let wasOnDemandEnabled):
|
||||
return (title, message + (wasOnDemandEnabled ? tr("alertTunnelActivationFailureOnDemandAddendum") : ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PacketTunnelProviderError: WireGuardAppError {
|
||||
var alertText: AlertText {
|
||||
switch self {
|
||||
case .savedProtocolConfigurationIsInvalid:
|
||||
return (tr("alertTunnelActivationFailureTitle"), tr("alertTunnelActivationSavedConfigFailureMessage"))
|
||||
case .dnsResolutionFailure:
|
||||
return (tr("alertTunnelDNSFailureTitle"), tr("alertTunnelDNSFailureMessage"))
|
||||
case .couldNotStartBackend:
|
||||
return (tr("alertTunnelActivationFailureTitle"), tr("alertTunnelActivationBackendFailureMessage"))
|
||||
case .couldNotDetermineFileDescriptor:
|
||||
return (tr("alertTunnelActivationFailureTitle"), tr("alertTunnelActivationFileDescriptorFailureMessage"))
|
||||
case .couldNotSetNetworkSettings:
|
||||
return (tr("alertTunnelActivationFailureTitle"), tr("alertTunnelActivationSetNetworkSettingsMessage"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Error {
|
||||
var localizedUIString: String {
|
||||
if let systemError = self as? NEVPNError {
|
||||
switch systemError {
|
||||
case NEVPNError.configurationInvalid:
|
||||
return tr("alertSystemErrorMessageTunnelConfigurationInvalid")
|
||||
case NEVPNError.configurationDisabled:
|
||||
return tr("alertSystemErrorMessageTunnelConfigurationDisabled")
|
||||
case NEVPNError.connectionFailed:
|
||||
return tr("alertSystemErrorMessageTunnelConnectionFailed")
|
||||
case NEVPNError.configurationStale:
|
||||
return tr("alertSystemErrorMessageTunnelConfigurationStale")
|
||||
case NEVPNError.configurationReadWriteFailed:
|
||||
return tr("alertSystemErrorMessageTunnelConfigurationReadWriteFailed")
|
||||
case NEVPNError.configurationUnknown:
|
||||
return tr("alertSystemErrorMessageTunnelConfigurationUnknown")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
} else {
|
||||
return localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import NetworkExtension
|
||||
|
||||
@objc enum TunnelStatus: Int {
|
||||
case inactive
|
||||
case activating
|
||||
case active
|
||||
case deactivating
|
||||
case reasserting // Not a possible state at present
|
||||
case restarting // Restarting tunnel (done after saving modifications to an active tunnel)
|
||||
case waiting // Waiting for another tunnel to be brought down
|
||||
|
||||
init(from systemStatus: NEVPNStatus) {
|
||||
switch systemStatus {
|
||||
case .connected:
|
||||
self = .active
|
||||
case .connecting:
|
||||
self = .activating
|
||||
case .disconnected:
|
||||
self = .inactive
|
||||
case .disconnecting:
|
||||
self = .deactivating
|
||||
case .reasserting:
|
||||
self = .reasserting
|
||||
case .invalid:
|
||||
self = .inactive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TunnelStatus: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
switch self {
|
||||
case .inactive: return "inactive"
|
||||
case .activating: return "activating"
|
||||
case .active: return "active"
|
||||
case .deactivating: return "deactivating"
|
||||
case .reasserting: return "reasserting"
|
||||
case .restarting: return "restarting"
|
||||
case .waiting: return "waiting"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NEVPNStatus: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
switch self {
|
||||
case .connected: return "connected"
|
||||
case .connecting: return "connecting"
|
||||
case .disconnected: return "disconnected"
|
||||
case .disconnecting: return "disconnecting"
|
||||
case .reasserting: return "reasserting"
|
||||
case .invalid: return "invalid"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
import NetworkExtension
|
||||
import os.log
|
||||
|
||||
protocol TunnelsManagerListDelegate: class {
|
||||
func tunnelAdded(at index: Int)
|
||||
func tunnelModified(at index: Int)
|
||||
func tunnelMoved(from oldIndex: Int, to newIndex: Int)
|
||||
func tunnelRemoved(at index: Int, tunnel: TunnelContainer)
|
||||
}
|
||||
|
||||
protocol TunnelsManagerActivationDelegate: class {
|
||||
func tunnelActivationAttemptFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationAttemptError) // startTunnel wasn't called or failed
|
||||
func tunnelActivationAttemptSucceeded(tunnel: TunnelContainer) // startTunnel succeeded
|
||||
func tunnelActivationFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationError) // status didn't change to connected
|
||||
func tunnelActivationSucceeded(tunnel: TunnelContainer) // status changed to connected
|
||||
}
|
||||
|
||||
class TunnelsManager {
|
||||
private var tunnels: [TunnelContainer]
|
||||
weak var tunnelsListDelegate: TunnelsManagerListDelegate?
|
||||
weak var activationDelegate: TunnelsManagerActivationDelegate?
|
||||
private var statusObservationToken: AnyObject?
|
||||
private var waiteeObservationToken: AnyObject?
|
||||
private var configurationsObservationToken: AnyObject?
|
||||
|
||||
init(tunnelProviders: [NETunnelProviderManager]) {
|
||||
tunnels = tunnelProviders.map { TunnelContainer(tunnel: $0) }.sorted { $0.name < $1.name }
|
||||
startObservingTunnelStatuses()
|
||||
startObservingTunnelConfigurations()
|
||||
}
|
||||
|
||||
static func create(completionHandler: @escaping (WireGuardResult<TunnelsManager>) -> Void) {
|
||||
#if targetEnvironment(simulator)
|
||||
completionHandler(.success(TunnelsManager(tunnelProviders: MockTunnels.createMockTunnels())))
|
||||
#else
|
||||
NETunnelProviderManager.loadAllFromPreferences { managers, error in
|
||||
if let error = error {
|
||||
wg_log(.error, message: "Failed to load tunnel provider managers: \(error)")
|
||||
completionHandler(.failure(TunnelsManagerError.systemErrorOnListingTunnels(systemError: error)))
|
||||
return
|
||||
}
|
||||
|
||||
var tunnelManagers = managers ?? []
|
||||
var refs: Set<Data> = []
|
||||
for (index, tunnelManager) in tunnelManagers.enumerated().reversed() {
|
||||
let proto = tunnelManager.protocolConfiguration as? NETunnelProviderProtocol
|
||||
if proto?.migrateConfigurationIfNeeded(called: tunnelManager.localizedDescription ?? "unknown") ?? false {
|
||||
tunnelManager.saveToPreferences { _ in }
|
||||
}
|
||||
if let ref = proto?.verifyConfigurationReference() {
|
||||
refs.insert(ref)
|
||||
} else {
|
||||
tunnelManager.removeFromPreferences { _ in }
|
||||
tunnelManagers.remove(at: index)
|
||||
}
|
||||
}
|
||||
Keychain.deleteReferences(except: refs)
|
||||
completionHandler(.success(TunnelsManager(tunnelProviders: tunnelManagers)))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
func reload() {
|
||||
NETunnelProviderManager.loadAllFromPreferences { [weak self] managers, _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
let loadedTunnelProviders = managers ?? []
|
||||
|
||||
for (index, currentTunnel) in self.tunnels.enumerated().reversed() {
|
||||
if !loadedTunnelProviders.contains(where: { $0.tunnelConfiguration == currentTunnel.tunnelConfiguration }) {
|
||||
// Tunnel was deleted outside the app
|
||||
self.tunnels.remove(at: index)
|
||||
self.tunnelsListDelegate?.tunnelRemoved(at: index, tunnel: currentTunnel)
|
||||
}
|
||||
}
|
||||
for loadedTunnelProvider in loadedTunnelProviders {
|
||||
if let matchingTunnel = self.tunnels.first(where: { $0.tunnelConfiguration == loadedTunnelProvider.tunnelConfiguration }) {
|
||||
matchingTunnel.tunnelProvider = loadedTunnelProvider
|
||||
matchingTunnel.refreshStatus()
|
||||
} else {
|
||||
// Tunnel was added outside the app
|
||||
let tunnel = TunnelContainer(tunnel: loadedTunnelProvider)
|
||||
self.tunnels.append(tunnel)
|
||||
self.tunnels.sort { $0.name < $1.name }
|
||||
self.tunnelsListDelegate?.tunnelAdded(at: self.tunnels.firstIndex(of: tunnel)!)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func add(tunnelConfiguration: TunnelConfiguration, activateOnDemandSetting: ActivateOnDemandSetting = ActivateOnDemandSetting.defaultSetting, completionHandler: @escaping (WireGuardResult<TunnelContainer>) -> Void) {
|
||||
let tunnelName = tunnelConfiguration.name ?? ""
|
||||
if tunnelName.isEmpty {
|
||||
completionHandler(.failure(TunnelsManagerError.tunnelNameEmpty))
|
||||
return
|
||||
}
|
||||
|
||||
if tunnels.contains(where: { $0.name == tunnelName }) {
|
||||
completionHandler(.failure(TunnelsManagerError.tunnelAlreadyExistsWithThatName))
|
||||
return
|
||||
}
|
||||
|
||||
let tunnelProviderManager = NETunnelProviderManager()
|
||||
tunnelProviderManager.setTunnelConfiguration(tunnelConfiguration)
|
||||
tunnelProviderManager.isEnabled = true
|
||||
|
||||
activateOnDemandSetting.apply(on: tunnelProviderManager)
|
||||
|
||||
tunnelProviderManager.saveToPreferences { [weak self] error in
|
||||
guard error == nil else {
|
||||
wg_log(.error, message: "Add: Saving configuration failed: \(error!)")
|
||||
(tunnelProviderManager.protocolConfiguration as? NETunnelProviderProtocol)?.destroyConfigurationReference()
|
||||
completionHandler(.failure(TunnelsManagerError.systemErrorOnAddTunnel(systemError: error!)))
|
||||
return
|
||||
}
|
||||
|
||||
guard let self = self else { return }
|
||||
|
||||
let tunnel = TunnelContainer(tunnel: tunnelProviderManager)
|
||||
self.tunnels.append(tunnel)
|
||||
self.tunnels.sort { $0.name < $1.name }
|
||||
self.tunnelsListDelegate?.tunnelAdded(at: self.tunnels.firstIndex(of: tunnel)!)
|
||||
completionHandler(.success(tunnel))
|
||||
}
|
||||
}
|
||||
|
||||
func addMultiple(tunnelConfigurations: [TunnelConfiguration], completionHandler: @escaping (UInt) -> Void) {
|
||||
addMultiple(tunnelConfigurations: ArraySlice(tunnelConfigurations), numberSuccessful: 0, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
private func addMultiple(tunnelConfigurations: ArraySlice<TunnelConfiguration>, numberSuccessful: UInt, completionHandler: @escaping (UInt) -> Void) {
|
||||
guard let head = tunnelConfigurations.first else {
|
||||
completionHandler(numberSuccessful)
|
||||
return
|
||||
}
|
||||
let tail = tunnelConfigurations.dropFirst()
|
||||
add(tunnelConfiguration: head) { [weak self, tail] result in
|
||||
DispatchQueue.main.async {
|
||||
self?.addMultiple(tunnelConfigurations: tail, numberSuccessful: numberSuccessful + (result.isSuccess ? 1 : 0), completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func modify(tunnel: TunnelContainer, tunnelConfiguration: TunnelConfiguration, activateOnDemandSetting: ActivateOnDemandSetting, completionHandler: @escaping (TunnelsManagerError?) -> Void) {
|
||||
let tunnelName = tunnelConfiguration.name ?? ""
|
||||
if tunnelName.isEmpty {
|
||||
completionHandler(TunnelsManagerError.tunnelNameEmpty)
|
||||
return
|
||||
}
|
||||
|
||||
let tunnelProviderManager = tunnel.tunnelProvider
|
||||
let isNameChanged = tunnelName != tunnelProviderManager.localizedDescription
|
||||
if isNameChanged {
|
||||
guard !tunnels.contains(where: { $0.name == tunnelName }) else {
|
||||
completionHandler(TunnelsManagerError.tunnelAlreadyExistsWithThatName)
|
||||
return
|
||||
}
|
||||
tunnel.name = tunnelName
|
||||
}
|
||||
|
||||
tunnelProviderManager.setTunnelConfiguration(tunnelConfiguration)
|
||||
tunnelProviderManager.isEnabled = true
|
||||
|
||||
let isActivatingOnDemand = !tunnelProviderManager.isOnDemandEnabled && activateOnDemandSetting.isActivateOnDemandEnabled
|
||||
activateOnDemandSetting.apply(on: tunnelProviderManager)
|
||||
|
||||
tunnelProviderManager.saveToPreferences { [weak self] error in
|
||||
guard error == nil else {
|
||||
//TODO: the passwordReference for the old one has already been removed at this point and we can't easily roll back!
|
||||
wg_log(.error, message: "Modify: Saving configuration failed: \(error!)")
|
||||
completionHandler(TunnelsManagerError.systemErrorOnModifyTunnel(systemError: error!))
|
||||
return
|
||||
}
|
||||
guard let self = self else { return }
|
||||
if isNameChanged {
|
||||
let oldIndex = self.tunnels.firstIndex(of: tunnel)!
|
||||
self.tunnels.sort { $0.name < $1.name }
|
||||
let newIndex = self.tunnels.firstIndex(of: tunnel)!
|
||||
self.tunnelsListDelegate?.tunnelMoved(from: oldIndex, to: newIndex)
|
||||
}
|
||||
self.tunnelsListDelegate?.tunnelModified(at: self.tunnels.firstIndex(of: tunnel)!)
|
||||
|
||||
if tunnel.status == .active || tunnel.status == .activating || tunnel.status == .reasserting {
|
||||
// Turn off the tunnel, and then turn it back on, so the changes are made effective
|
||||
tunnel.status = .restarting
|
||||
(tunnel.tunnelProvider.connection as? NETunnelProviderSession)?.stopTunnel()
|
||||
}
|
||||
|
||||
if isActivatingOnDemand {
|
||||
// Reload tunnel after saving.
|
||||
// Without this, the tunnel stopes getting updates on the tunnel status from iOS.
|
||||
tunnelProviderManager.loadFromPreferences { error in
|
||||
tunnel.isActivateOnDemandEnabled = tunnelProviderManager.isOnDemandEnabled
|
||||
guard error == nil else {
|
||||
wg_log(.error, message: "Modify: Re-loading after saving configuration failed: \(error!)")
|
||||
completionHandler(TunnelsManagerError.systemErrorOnModifyTunnel(systemError: error!))
|
||||
return
|
||||
}
|
||||
completionHandler(nil)
|
||||
}
|
||||
} else {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func remove(tunnel: TunnelContainer, completionHandler: @escaping (TunnelsManagerError?) -> Void) {
|
||||
let tunnelProviderManager = tunnel.tunnelProvider
|
||||
(tunnelProviderManager.protocolConfiguration as? NETunnelProviderProtocol)?.destroyConfigurationReference()
|
||||
|
||||
tunnelProviderManager.removeFromPreferences { [weak self] error in
|
||||
guard error == nil else {
|
||||
wg_log(.error, message: "Remove: Saving configuration failed: \(error!)")
|
||||
completionHandler(TunnelsManagerError.systemErrorOnRemoveTunnel(systemError: error!))
|
||||
return
|
||||
}
|
||||
if let self = self {
|
||||
let index = self.tunnels.firstIndex(of: tunnel)!
|
||||
self.tunnels.remove(at: index)
|
||||
self.tunnelsListDelegate?.tunnelRemoved(at: index, tunnel: tunnel)
|
||||
}
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func numberOfTunnels() -> Int {
|
||||
return tunnels.count
|
||||
}
|
||||
|
||||
func tunnel(at index: Int) -> TunnelContainer {
|
||||
return tunnels[index]
|
||||
}
|
||||
|
||||
func index(of tunnel: TunnelContainer) -> Int? {
|
||||
return tunnels.firstIndex(of: tunnel)
|
||||
}
|
||||
|
||||
func tunnel(named tunnelName: String) -> TunnelContainer? {
|
||||
return tunnels.first { $0.name == tunnelName }
|
||||
}
|
||||
|
||||
func waitingTunnel() -> TunnelContainer? {
|
||||
return tunnels.first { $0.status == .waiting }
|
||||
}
|
||||
|
||||
func tunnelInOperation() -> TunnelContainer? {
|
||||
if let waitingTunnelObject = waitingTunnel() {
|
||||
return waitingTunnelObject
|
||||
}
|
||||
return tunnels.first { $0.status != .inactive }
|
||||
}
|
||||
|
||||
func startActivation(of tunnel: TunnelContainer) {
|
||||
guard tunnels.contains(tunnel) else { return } // Ensure it's not deleted
|
||||
guard tunnel.status == .inactive else {
|
||||
activationDelegate?.tunnelActivationAttemptFailed(tunnel: tunnel, error: .tunnelIsNotInactive)
|
||||
return
|
||||
}
|
||||
|
||||
if let alreadyWaitingTunnel = tunnels.first(where: { $0.status == .waiting }) {
|
||||
alreadyWaitingTunnel.status = .inactive
|
||||
}
|
||||
|
||||
if let tunnelInOperation = tunnels.first(where: { $0.status != .inactive }) {
|
||||
wg_log(.info, message: "Tunnel '\(tunnel.name)' waiting for deactivation of '\(tunnelInOperation.name)'")
|
||||
tunnel.status = .waiting
|
||||
activateWaitingTunnelOnDeactivation(of: tunnelInOperation)
|
||||
if tunnelInOperation.status != .deactivating {
|
||||
startDeactivation(of: tunnelInOperation)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
tunnel.status = .active
|
||||
#else
|
||||
tunnel.startActivation(activationDelegate: activationDelegate)
|
||||
#endif
|
||||
}
|
||||
|
||||
func startDeactivation(of tunnel: TunnelContainer) {
|
||||
tunnel.isAttemptingActivation = false
|
||||
guard tunnel.status != .inactive && tunnel.status != .deactivating else { return }
|
||||
#if targetEnvironment(simulator)
|
||||
tunnel.status = .inactive
|
||||
#else
|
||||
tunnel.startDeactivation()
|
||||
#endif
|
||||
}
|
||||
|
||||
func refreshStatuses() {
|
||||
tunnels.forEach { $0.refreshStatus() }
|
||||
}
|
||||
|
||||
private func activateWaitingTunnelOnDeactivation(of tunnel: TunnelContainer) {
|
||||
waiteeObservationToken = tunnel.observe(\.status) { [weak self] tunnel, _ in
|
||||
guard let self = self else { return }
|
||||
if tunnel.status == .inactive {
|
||||
if let waitingTunnel = self.tunnels.first(where: { $0.status == .waiting }) {
|
||||
waitingTunnel.startActivation(activationDelegate: self.activationDelegate)
|
||||
}
|
||||
self.waiteeObservationToken = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startObservingTunnelStatuses() {
|
||||
statusObservationToken = NotificationCenter.default.addObserver(forName: .NEVPNStatusDidChange, object: nil, queue: OperationQueue.main) { [weak self] statusChangeNotification in
|
||||
guard let self = self,
|
||||
let session = statusChangeNotification.object as? NETunnelProviderSession,
|
||||
let tunnelProvider = session.manager as? NETunnelProviderManager,
|
||||
let tunnelConfiguration = tunnelProvider.tunnelConfiguration,
|
||||
let tunnel = self.tunnels.first(where: { $0.tunnelConfiguration == tunnelConfiguration }) else { return }
|
||||
if tunnel.tunnelProvider != tunnelProvider {
|
||||
return
|
||||
}
|
||||
|
||||
wg_log(.debug, message: "Tunnel '\(tunnel.name)' connection status changed to '\(tunnel.tunnelProvider.connection.status)'")
|
||||
|
||||
if tunnel.isAttemptingActivation {
|
||||
if session.status == .connected {
|
||||
tunnel.isAttemptingActivation = false
|
||||
self.activationDelegate?.tunnelActivationSucceeded(tunnel: tunnel)
|
||||
} else if session.status == .disconnected {
|
||||
tunnel.isAttemptingActivation = false
|
||||
if let (title, message) = lastErrorTextFromNetworkExtension(for: tunnel) {
|
||||
self.activationDelegate?.tunnelActivationFailed(tunnel: tunnel, error: .activationFailedWithExtensionError(title: title, message: message, wasOnDemandEnabled: tunnelProvider.isOnDemandEnabled))
|
||||
} else {
|
||||
self.activationDelegate?.tunnelActivationFailed(tunnel: tunnel, error: .activationFailed(wasOnDemandEnabled: tunnelProvider.isOnDemandEnabled))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if tunnel.status == .restarting && session.status == .disconnected {
|
||||
tunnel.startActivation(activationDelegate: self.activationDelegate)
|
||||
return
|
||||
}
|
||||
|
||||
tunnel.refreshStatus()
|
||||
}
|
||||
}
|
||||
|
||||
func startObservingTunnelConfigurations() {
|
||||
configurationsObservationToken = NotificationCenter.default.addObserver(forName: .NEVPNConfigurationChange, object: nil, queue: OperationQueue.main) { [weak self] _ in
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
// We schedule reload() in a subsequent runloop to ensure that the completion handler of loadAllFromPreferences
|
||||
// (reload() calls loadAllFromPreferences) is called after the completion handler of the saveToPreferences or
|
||||
// removeFromPreferences call, if any, that caused this notification to fire. This notification can also fire
|
||||
// as a result of a tunnel getting added or removed outside of the app.
|
||||
self?.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private func lastErrorTextFromNetworkExtension(for tunnel: TunnelContainer) -> (title: String, message: String)? {
|
||||
guard let lastErrorFileURL = FileManager.networkExtensionLastErrorFileURL else { return nil }
|
||||
guard let lastErrorData = try? Data(contentsOf: lastErrorFileURL) else { return nil }
|
||||
guard let lastErrorStrings = String(data: lastErrorData, encoding: .utf8)?.splitToArray(separator: "\n") else { return nil }
|
||||
guard lastErrorStrings.count == 2 && tunnel.activationAttemptId == lastErrorStrings[0] else { return nil }
|
||||
|
||||
if let extensionError = PacketTunnelProviderError(rawValue: lastErrorStrings[1]) {
|
||||
return extensionError.alertText
|
||||
}
|
||||
|
||||
return (tr("alertTunnelActivationFailureTitle"), tr("alertTunnelActivationFailureMessage"))
|
||||
}
|
||||
|
||||
class TunnelContainer: NSObject {
|
||||
@objc dynamic var name: String
|
||||
@objc dynamic var status: TunnelStatus
|
||||
|
||||
@objc dynamic var isActivateOnDemandEnabled: Bool
|
||||
|
||||
var isAttemptingActivation = false {
|
||||
didSet {
|
||||
if isAttemptingActivation {
|
||||
self.activationTimer?.invalidate()
|
||||
let activationTimer = Timer(timeInterval: 5 /* seconds */, repeats: true) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
wg_log(.debug, message: "Status update notification timeout for tunnel '\(self.name)'. Tunnel status is now '\(self.tunnelProvider.connection.status)'.")
|
||||
switch self.tunnelProvider.connection.status {
|
||||
case .connected, .disconnected, .invalid:
|
||||
self.activationTimer?.invalidate()
|
||||
self.activationTimer = nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
self.refreshStatus()
|
||||
}
|
||||
self.activationTimer = activationTimer
|
||||
RunLoop.main.add(activationTimer, forMode: .common)
|
||||
}
|
||||
}
|
||||
}
|
||||
var activationAttemptId: String?
|
||||
var activationTimer: Timer?
|
||||
var deactivationTimer: Timer?
|
||||
|
||||
fileprivate var tunnelProvider: NETunnelProviderManager
|
||||
|
||||
var tunnelConfiguration: TunnelConfiguration? {
|
||||
return tunnelProvider.tunnelConfiguration
|
||||
}
|
||||
|
||||
var activateOnDemandSetting: ActivateOnDemandSetting {
|
||||
return ActivateOnDemandSetting(from: tunnelProvider)
|
||||
}
|
||||
|
||||
init(tunnel: NETunnelProviderManager) {
|
||||
name = tunnel.localizedDescription ?? "Unnamed"
|
||||
let status = TunnelStatus(from: tunnel.connection.status)
|
||||
self.status = status
|
||||
isActivateOnDemandEnabled = tunnel.isOnDemandEnabled
|
||||
tunnelProvider = tunnel
|
||||
super.init()
|
||||
}
|
||||
|
||||
func getRuntimeTunnelConfiguration(completionHandler: @escaping ((TunnelConfiguration?) -> Void)) {
|
||||
guard status != .inactive, let session = tunnelProvider.connection as? NETunnelProviderSession else {
|
||||
completionHandler(tunnelConfiguration)
|
||||
return
|
||||
}
|
||||
guard nil != (try? session.sendProviderMessage(Data(bytes: [ 0 ]), responseHandler: {
|
||||
guard self.status != .inactive, let data = $0, let base = self.tunnelConfiguration, let settings = String(data: data, encoding: .utf8) else {
|
||||
completionHandler(self.tunnelConfiguration)
|
||||
return
|
||||
}
|
||||
completionHandler((try? TunnelConfiguration(fromUapiConfig: settings, basedOn: base)) ?? self.tunnelConfiguration)
|
||||
})) else {
|
||||
completionHandler(tunnelConfiguration)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func refreshStatus() {
|
||||
if (status == .restarting) && (tunnelProvider.connection.status == .disconnected || tunnelProvider.connection.status == .disconnecting) {
|
||||
return
|
||||
}
|
||||
status = TunnelStatus(from: tunnelProvider.connection.status)
|
||||
isActivateOnDemandEnabled = tunnelProvider.isOnDemandEnabled
|
||||
}
|
||||
|
||||
fileprivate func startActivation(recursionCount: UInt = 0, lastError: Error? = nil, activationDelegate: TunnelsManagerActivationDelegate?) {
|
||||
if recursionCount >= 8 {
|
||||
wg_log(.error, message: "startActivation: Failed after 8 attempts. Giving up with \(lastError!)")
|
||||
activationDelegate?.tunnelActivationAttemptFailed(tunnel: self, error: .failedBecauseOfTooManyErrors(lastSystemError: lastError!))
|
||||
return
|
||||
}
|
||||
|
||||
wg_log(.debug, message: "startActivation: Entering (tunnel: \(name))")
|
||||
|
||||
status = .activating // Ensure that no other tunnel can attempt activation until this tunnel is done trying
|
||||
|
||||
guard tunnelProvider.isEnabled else {
|
||||
// In case the tunnel had gotten disabled, re-enable and save it,
|
||||
// then call this function again.
|
||||
wg_log(.debug, staticMessage: "startActivation: Tunnel is disabled. Re-enabling and saving")
|
||||
tunnelProvider.isEnabled = true
|
||||
tunnelProvider.saveToPreferences { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
if error != nil {
|
||||
wg_log(.error, message: "Error saving tunnel after re-enabling: \(error!)")
|
||||
activationDelegate?.tunnelActivationAttemptFailed(tunnel: self, error: .failedWhileSaving(systemError: error!))
|
||||
return
|
||||
}
|
||||
wg_log(.debug, staticMessage: "startActivation: Tunnel saved after re-enabling, invoking startActivation")
|
||||
self.startActivation(recursionCount: recursionCount + 1, lastError: NEVPNError(NEVPNError.configurationUnknown), activationDelegate: activationDelegate)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Start the tunnel
|
||||
do {
|
||||
wg_log(.debug, staticMessage: "startActivation: Starting tunnel")
|
||||
isAttemptingActivation = true
|
||||
let activationAttemptId = UUID().uuidString
|
||||
self.activationAttemptId = activationAttemptId
|
||||
try (tunnelProvider.connection as? NETunnelProviderSession)?.startTunnel(options: ["activationAttemptId": activationAttemptId])
|
||||
wg_log(.debug, staticMessage: "startActivation: Success")
|
||||
activationDelegate?.tunnelActivationAttemptSucceeded(tunnel: self)
|
||||
} catch let error {
|
||||
isAttemptingActivation = false
|
||||
guard let systemError = error as? NEVPNError else {
|
||||
wg_log(.error, message: "Failed to activate tunnel: Error: \(error)")
|
||||
status = .inactive
|
||||
activationDelegate?.tunnelActivationAttemptFailed(tunnel: self, error: .failedWhileStarting(systemError: error))
|
||||
return
|
||||
}
|
||||
guard systemError.code == NEVPNError.configurationInvalid || systemError.code == NEVPNError.configurationStale else {
|
||||
wg_log(.error, message: "Failed to activate tunnel: VPN Error: \(error)")
|
||||
status = .inactive
|
||||
activationDelegate?.tunnelActivationAttemptFailed(tunnel: self, error: .failedWhileStarting(systemError: systemError))
|
||||
return
|
||||
}
|
||||
wg_log(.debug, staticMessage: "startActivation: Will reload tunnel and then try to start it.")
|
||||
tunnelProvider.loadFromPreferences { [weak self] error in
|
||||
guard let self = self else { return }
|
||||
if error != nil {
|
||||
wg_log(.error, message: "startActivation: Error reloading tunnel: \(error!)")
|
||||
self.status = .inactive
|
||||
activationDelegate?.tunnelActivationAttemptFailed(tunnel: self, error: .failedWhileLoading(systemError: systemError))
|
||||
return
|
||||
}
|
||||
wg_log(.debug, staticMessage: "startActivation: Tunnel reloaded, invoking startActivation")
|
||||
self.startActivation(recursionCount: recursionCount + 1, lastError: systemError, activationDelegate: activationDelegate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func startDeactivation() {
|
||||
wg_log(.debug, message: "startDeactivation: Tunnel: \(name)")
|
||||
(tunnelProvider.connection as? NETunnelProviderSession)?.stopTunnel()
|
||||
}
|
||||
}
|
||||
|
||||
extension NETunnelProviderManager {
|
||||
private static var cachedConfigKey: UInt8 = 0
|
||||
var tunnelConfiguration: TunnelConfiguration? {
|
||||
if let cached = objc_getAssociatedObject(self, &NETunnelProviderManager.cachedConfigKey) as? TunnelConfiguration {
|
||||
return cached
|
||||
}
|
||||
let config = (protocolConfiguration as? NETunnelProviderProtocol)?.asTunnelConfiguration(called: localizedDescription)
|
||||
if config != nil {
|
||||
objc_setAssociatedObject(self, &NETunnelProviderManager.cachedConfigKey, config, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func setTunnelConfiguration(_ tunnelConfiguration: TunnelConfiguration) {
|
||||
protocolConfiguration = NETunnelProviderProtocol(tunnelConfiguration: tunnelConfiguration, previouslyFrom: protocolConfiguration)
|
||||
localizedDescription = tunnelConfiguration.name
|
||||
objc_setAssociatedObject(self, &NETunnelProviderManager.cachedConfigKey, tunnelConfiguration, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
}
|
||||
}
|
||||
@@ -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,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,43 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
class TunnelImporter {
|
||||
static func importFromFile(url: URL, into tunnelsManager: TunnelsManager, sourceVC: AnyObject?, errorPresenterType: ErrorPresenterProtocol.Type, completionHandler: (() -> Void)? = nil) {
|
||||
if url.pathExtension == "zip" {
|
||||
ZipImporter.importConfigFiles(from: url) { result in
|
||||
if let error = result.error {
|
||||
errorPresenterType.showErrorAlert(error: error, from: sourceVC)
|
||||
return
|
||||
}
|
||||
let configs = result.value!
|
||||
tunnelsManager.addMultiple(tunnelConfigurations: configs.compactMap { $0 }) { numberSuccessful in
|
||||
if numberSuccessful == configs.count {
|
||||
completionHandler?()
|
||||
return
|
||||
}
|
||||
let title = tr(format: "alertImportedFromZipTitle (%d)", numberSuccessful)
|
||||
let message = tr(format: "alertImportedFromZipMessage (%1$d of %2$d)", numberSuccessful, configs.count)
|
||||
errorPresenterType.showErrorAlert(title: title, message: message, from: sourceVC, onPresented: completionHandler)
|
||||
}
|
||||
}
|
||||
} else /* if (url.pathExtension == "conf") -- we assume everything else is a conf */ {
|
||||
let fileBaseName = url.deletingPathExtension().lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let fileContents = try? String(contentsOf: url),
|
||||
let tunnelConfiguration = try? TunnelConfiguration(fromWgQuickConfig: fileContents, called: fileBaseName) {
|
||||
tunnelsManager.add(tunnelConfiguration: tunnelConfiguration) { result in
|
||||
if let error = result.error {
|
||||
errorPresenterType.showErrorAlert(error: error, from: sourceVC, onPresented: completionHandler)
|
||||
} else {
|
||||
completionHandler?()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
errorPresenterType.showErrorAlert(title: tr("alertUnableToImportTitle"), message: tr("alertUnableToImportMessage"),
|
||||
from: sourceVC, onPresented: completionHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,33 +1,64 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
import Foundation
|
||||
|
||||
class TunnelViewModel {
|
||||
|
||||
enum InterfaceField: String {
|
||||
case name = "Name"
|
||||
case privateKey = "Private key"
|
||||
case publicKey = "Public key"
|
||||
case generateKeyPair = "Generate keypair"
|
||||
case addresses = "Addresses"
|
||||
case listenPort = "Listen port"
|
||||
case mtu = "MTU"
|
||||
case dns = "DNS servers"
|
||||
enum InterfaceField: CaseIterable {
|
||||
case name
|
||||
case privateKey
|
||||
case publicKey
|
||||
case generateKeyPair
|
||||
case addresses
|
||||
case listenPort
|
||||
case mtu
|
||||
case dns
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static let interfaceFieldsWithControl: Set<InterfaceField> = [
|
||||
.generateKeyPair
|
||||
]
|
||||
|
||||
enum PeerField: String {
|
||||
case publicKey = "Public key"
|
||||
case preSharedKey = "Preshared key"
|
||||
case endpoint = "Endpoint"
|
||||
case persistentKeepAlive = "Persistent keepalive"
|
||||
case allowedIPs = "Allowed IPs"
|
||||
case excludePrivateIPs = "Exclude private IPs"
|
||||
case deletePeer = "Delete peer"
|
||||
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> = [
|
||||
@@ -36,38 +67,47 @@ class TunnelViewModel {
|
||||
|
||||
static let keyLengthInBase64 = 44
|
||||
|
||||
struct ChangeHandlers {
|
||||
enum FieldChange {
|
||||
case added
|
||||
case removed
|
||||
case modified
|
||||
}
|
||||
|
||||
var interfaceChanged: ([InterfaceField: FieldChange]) -> Void
|
||||
var peerChangedAt: (Int, [PeerField: FieldChange]) -> Void
|
||||
var peersRemovedAt: ([Int]) -> Void
|
||||
var peersInsertedAt: ([Int]) -> Void
|
||||
}
|
||||
|
||||
class InterfaceData {
|
||||
var scratchpad: [InterfaceField: String] = [:]
|
||||
var fieldsWithError: Set<InterfaceField> = []
|
||||
var scratchpad = [InterfaceField: String]()
|
||||
var fieldsWithError = Set<InterfaceField>()
|
||||
var validatedConfiguration: InterfaceConfiguration?
|
||||
var validatedName: String?
|
||||
|
||||
subscript(field: InterfaceField) -> String {
|
||||
get {
|
||||
if (scratchpad.isEmpty) {
|
||||
// When starting to read a config, setup the scratchpad.
|
||||
// The scratchpad shall serve as a cache of what we want to show in the UI.
|
||||
if scratchpad.isEmpty {
|
||||
populateScratchpad()
|
||||
}
|
||||
return scratchpad[field] ?? ""
|
||||
}
|
||||
set(stringValue) {
|
||||
if (scratchpad.isEmpty) {
|
||||
// When starting to edit a config, setup the scratchpad and remove the configuration.
|
||||
// The scratchpad shall be the sole source of the being-edited configuration.
|
||||
if scratchpad.isEmpty {
|
||||
populateScratchpad()
|
||||
}
|
||||
validatedConfiguration = nil
|
||||
if (stringValue.isEmpty) {
|
||||
validatedName = nil
|
||||
if stringValue.isEmpty {
|
||||
scratchpad.removeValue(forKey: field)
|
||||
} else {
|
||||
scratchpad[field] = stringValue
|
||||
}
|
||||
if (field == .privateKey) {
|
||||
if (stringValue.count == TunnelViewModel.keyLengthInBase64),
|
||||
let privateKey = Data(base64Encoded: stringValue),
|
||||
privateKey.count == 32 {
|
||||
let publicKey = Curve25519.generatePublicKey(fromPrivateKey: privateKey)
|
||||
scratchpad[.publicKey] = publicKey.base64EncodedString()
|
||||
if field == .privateKey {
|
||||
if stringValue.count == TunnelViewModel.keyLengthInBase64, let privateKey = Data(base64Key: stringValue), privateKey.count == TunnelConfiguration.keyLength {
|
||||
let publicKey = Curve25519.generatePublicKey(fromPrivateKey: privateKey).base64Key() ?? ""
|
||||
scratchpad[.publicKey] = publicKey
|
||||
} else {
|
||||
scratchpad.removeValue(forKey: .publicKey)
|
||||
}
|
||||
@@ -76,13 +116,18 @@ class TunnelViewModel {
|
||||
}
|
||||
|
||||
func populateScratchpad() {
|
||||
// Populate the scratchpad from the configuration object
|
||||
guard let config = validatedConfiguration else { return }
|
||||
scratchpad[.name] = config.name
|
||||
scratchpad[.privateKey] = config.privateKey.base64EncodedString()
|
||||
scratchpad[.publicKey] = config.publicKey.base64EncodedString()
|
||||
if (!config.addresses.isEmpty) {
|
||||
scratchpad[.addresses] = config.addresses.map { $0.stringRepresentation() }.joined(separator: ", ")
|
||||
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.publicKey.base64Key() ?? ""
|
||||
if !config.addresses.isEmpty {
|
||||
scratchpad[.addresses] = config.addresses.map { $0.stringRepresentation }.joined(separator: ", ")
|
||||
}
|
||||
if let listenPort = config.listenPort {
|
||||
scratchpad[.listenPort] = String(listenPort)
|
||||
@@ -90,40 +135,39 @@ class TunnelViewModel {
|
||||
if let mtu = config.mtu {
|
||||
scratchpad[.mtu] = String(mtu)
|
||||
}
|
||||
if (!config.dns.isEmpty) {
|
||||
scratchpad[.dns] = config.dns.map { $0.stringRepresentation() }.joined(separator: ", ")
|
||||
if !config.dns.isEmpty {
|
||||
scratchpad[.dns] = config.dns.map { $0.stringRepresentation }.joined(separator: ", ")
|
||||
}
|
||||
return scratchpad
|
||||
}
|
||||
|
||||
func save() -> SaveResult<InterfaceConfiguration> {
|
||||
if let validatedConfiguration = validatedConfiguration {
|
||||
// It's already validated and saved
|
||||
return .saved(validatedConfiguration)
|
||||
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("Interface name is required")
|
||||
return .error(tr("alertInvalidInterfaceMessageNameRequired"))
|
||||
}
|
||||
guard let privateKeyString = scratchpad[.privateKey] else {
|
||||
fieldsWithError.insert(.privateKey)
|
||||
return .error("Interface's private key is required")
|
||||
return .error(tr("alertInvalidInterfaceMessagePrivateKeyRequired"))
|
||||
}
|
||||
guard let privateKey = Data(base64Encoded: privateKeyString), privateKey.count == 32 else {
|
||||
guard let privateKey = Data(base64Key: privateKeyString), privateKey.count == TunnelConfiguration.keyLength else {
|
||||
fieldsWithError.insert(.privateKey)
|
||||
return .error("Interface's private key must be a 32-byte key in base64 encoding")
|
||||
return .error(tr("alertInvalidInterfaceMessagePrivateKeyInvalid"))
|
||||
}
|
||||
var config = InterfaceConfiguration(name: name, privateKey: privateKey)
|
||||
var errorMessages: [String] = []
|
||||
var config = InterfaceConfiguration(privateKey: privateKey)
|
||||
var errorMessages = [String]()
|
||||
if let addressesString = scratchpad[.addresses] {
|
||||
var addresses: [IPAddressRange] = []
|
||||
for addressString in addressesString.split(separator: ",") {
|
||||
let trimmedString = addressString.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
if let address = IPAddressRange(from: trimmedString) {
|
||||
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("Interface addresses must be a list of comma-separated IP addresses, optionally in CIDR notation")
|
||||
errorMessages.append(tr("alertInvalidInterfaceMessageAddressInvalid"))
|
||||
}
|
||||
}
|
||||
config.addresses = addresses
|
||||
@@ -133,7 +177,7 @@ class TunnelViewModel {
|
||||
config.listenPort = listenPort
|
||||
} else {
|
||||
fieldsWithError.insert(.listenPort)
|
||||
errorMessages.append("Interface's listen port must be between 0 and 65535, or unspecified")
|
||||
errorMessages.append(tr("alertInvalidInterfaceMessageListenPortInvalid"))
|
||||
}
|
||||
}
|
||||
if let mtuString = scratchpad[.mtu] {
|
||||
@@ -141,51 +185,84 @@ class TunnelViewModel {
|
||||
config.mtu = mtu
|
||||
} else {
|
||||
fieldsWithError.insert(.mtu)
|
||||
errorMessages.append("Interface's MTU must be between 576 and 65535, or unspecified")
|
||||
errorMessages.append(tr("alertInvalidInterfaceMessageMTUInvalid"))
|
||||
}
|
||||
}
|
||||
if let dnsString = scratchpad[.dns] {
|
||||
var dnsServers: [DNSServer] = []
|
||||
for dnsServerString in dnsString.split(separator: ",") {
|
||||
let trimmedString = dnsServerString.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
if let dnsServer = DNSServer(from: trimmedString) {
|
||||
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("Interface's DNS servers must be a list of comma-separated IP addresses")
|
||||
errorMessages.append(tr("alertInvalidInterfaceMessageDNSInvalid"))
|
||||
}
|
||||
}
|
||||
config.dns = dnsServers
|
||||
}
|
||||
|
||||
guard (errorMessages.isEmpty) else {
|
||||
return .error(errorMessages.first!)
|
||||
}
|
||||
guard errorMessages.isEmpty else { return .error(errorMessages.first!) }
|
||||
|
||||
validatedConfiguration = config
|
||||
return .saved(config)
|
||||
validatedName = name
|
||||
return .saved((name, config))
|
||||
}
|
||||
|
||||
func filterFieldsWithValueOrControl(interfaceFields: [InterfaceField]) -> [InterfaceField] {
|
||||
return interfaceFields.filter { (field) -> Bool in
|
||||
if (TunnelViewModel.interfaceFieldsWithControl.contains(field)) {
|
||||
return interfaceFields.filter { field in
|
||||
if TunnelViewModel.interfaceFieldsWithControl.contains(field) {
|
||||
return true
|
||||
}
|
||||
return (!self[field].isEmpty)
|
||||
return !self[field].isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
func applyConfiguration(other: InterfaceConfiguration, otherName: String, changeHandler: ([InterfaceField: ChangeHandlers.FieldChange]) -> Void) {
|
||||
if scratchpad.isEmpty {
|
||||
populateScratchpad()
|
||||
}
|
||||
let otherScratchPad = InterfaceData.createScratchPad(from: other, name: otherName)
|
||||
var changes = [InterfaceField: ChangeHandlers.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
|
||||
}
|
||||
}
|
||||
}
|
||||
scratchpad = otherScratchPad
|
||||
if !changes.isEmpty {
|
||||
changeHandler(changes)
|
||||
}
|
||||
// TODO: Cache this to avoid recomputing
|
||||
}
|
||||
}
|
||||
|
||||
class PeerData {
|
||||
var index: Int
|
||||
var scratchpad: [PeerField: String] = [:]
|
||||
var fieldsWithError: Set<PeerField> = []
|
||||
var scratchpad = [PeerField: String]()
|
||||
var fieldsWithError = Set<PeerField>()
|
||||
var validatedConfiguration: PeerConfiguration?
|
||||
var publicKey: Data? {
|
||||
if let validatedConfiguration = validatedConfiguration {
|
||||
return validatedConfiguration.publicKey
|
||||
}
|
||||
if let scratchPadPublicKey = scratchpad[.publicKey] {
|
||||
return Data(base64Key: scratchPadPublicKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// For exclude private IPs
|
||||
var shouldAllowExcludePrivateIPsControl: Bool = false /* Read-only from the VC's point of view */
|
||||
var excludePrivateIPsValue: Bool = false /* Read-only from the VC's point of view */
|
||||
fileprivate var numberOfPeers: Int = 0
|
||||
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
|
||||
@@ -193,83 +270,93 @@ class TunnelViewModel {
|
||||
|
||||
subscript(field: PeerField) -> String {
|
||||
get {
|
||||
if (scratchpad.isEmpty) {
|
||||
// When starting to read a config, setup the scratchpad.
|
||||
// The scratchpad shall serve as a cache of what we want to show in the UI.
|
||||
if scratchpad.isEmpty {
|
||||
populateScratchpad()
|
||||
}
|
||||
return scratchpad[field] ?? ""
|
||||
}
|
||||
set(stringValue) {
|
||||
if (scratchpad.isEmpty) {
|
||||
// When starting to edit a config, setup the scratchpad and remove the configuration.
|
||||
// The scratchpad shall be the sole source of the being-edited configuration.
|
||||
if scratchpad.isEmpty {
|
||||
populateScratchpad()
|
||||
}
|
||||
validatedConfiguration = nil
|
||||
if (stringValue.isEmpty) {
|
||||
if stringValue.isEmpty {
|
||||
scratchpad.removeValue(forKey: field)
|
||||
} else {
|
||||
scratchpad[field] = stringValue
|
||||
}
|
||||
if (field == .allowedIPs) {
|
||||
if field == .allowedIPs {
|
||||
updateExcludePrivateIPsFieldState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func populateScratchpad() {
|
||||
// Populate the scratchpad from the configuration object
|
||||
guard let config = validatedConfiguration else { return }
|
||||
scratchpad[.publicKey] = config.publicKey.base64EncodedString()
|
||||
if let preSharedKey = config.preSharedKey {
|
||||
scratchpad[.preSharedKey] = preSharedKey.base64EncodedString()
|
||||
scratchpad = TunnelViewModel.PeerData.createScratchPad(from: config)
|
||||
updateExcludePrivateIPsFieldState()
|
||||
}
|
||||
|
||||
private static func createScratchPad(from config: PeerConfiguration) -> [PeerField: String] {
|
||||
var scratchpad = [PeerField: String]()
|
||||
if let publicKey = config.publicKey.base64Key() {
|
||||
scratchpad[.publicKey] = publicKey
|
||||
}
|
||||
if (!config.allowedIPs.isEmpty) {
|
||||
scratchpad[.allowedIPs] = config.allowedIPs.map { $0.stringRepresentation() }.joined(separator: ", ")
|
||||
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()
|
||||
scratchpad[.endpoint] = endpoint.stringRepresentation
|
||||
}
|
||||
if let persistentKeepAlive = config.persistentKeepAlive {
|
||||
scratchpad[.persistentKeepAlive] = String(persistentKeepAlive)
|
||||
}
|
||||
updateExcludePrivateIPsFieldState()
|
||||
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 {
|
||||
// It's already validated and saved
|
||||
return .saved(validatedConfiguration)
|
||||
}
|
||||
fieldsWithError.removeAll()
|
||||
guard let publicKeyString = scratchpad[.publicKey] else {
|
||||
fieldsWithError.insert(.publicKey)
|
||||
return .error("Peer's public key is required")
|
||||
return .error(tr("alertInvalidPeerMessagePublicKeyRequired"))
|
||||
}
|
||||
guard let publicKey = Data(base64Encoded: publicKeyString), publicKey.count == 32 else {
|
||||
guard let publicKey = Data(base64Key: publicKeyString), publicKey.count == TunnelConfiguration.keyLength else {
|
||||
fieldsWithError.insert(.publicKey)
|
||||
return .error("Peer's public key must be a 32-byte key in base64 encoding")
|
||||
return .error(tr("alertInvalidPeerMessagePublicKeyInvalid"))
|
||||
}
|
||||
var config = PeerConfiguration(publicKey: publicKey)
|
||||
var errorMessages: [String] = []
|
||||
var errorMessages = [String]()
|
||||
if let preSharedKeyString = scratchpad[.preSharedKey] {
|
||||
if let preSharedKey = Data(base64Encoded: preSharedKeyString), preSharedKey.count == 32 {
|
||||
if let preSharedKey = Data(base64Key: preSharedKeyString), preSharedKey.count == TunnelConfiguration.keyLength {
|
||||
config.preSharedKey = preSharedKey
|
||||
} else {
|
||||
fieldsWithError.insert(.preSharedKey)
|
||||
errorMessages.append("Peer's preshared key must be a 32-byte key in base64 encoding")
|
||||
errorMessages.append(tr("alertInvalidPeerMessagePreSharedKeyInvalid"))
|
||||
}
|
||||
}
|
||||
if let allowedIPsString = scratchpad[.allowedIPs] {
|
||||
var allowedIPs: [IPAddressRange] = []
|
||||
for allowedIPString in allowedIPsString.split(separator: ",") {
|
||||
let trimmedString = allowedIPString.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||
if let allowedIP = IPAddressRange(from: trimmedString) {
|
||||
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("Peer's allowed IPs must be a list of comma-separated IP addresses, optionally in CIDR notation")
|
||||
errorMessages.append(tr("alertInvalidPeerMessageAllowedIPsInvalid"))
|
||||
}
|
||||
}
|
||||
config.allowedIPs = allowedIPs
|
||||
@@ -279,7 +366,7 @@ class TunnelViewModel {
|
||||
config.endpoint = endpoint
|
||||
} else {
|
||||
fieldsWithError.insert(.endpoint)
|
||||
errorMessages.append("Peer's endpoint must be of the form 'host:port' or '[host]:port'")
|
||||
errorMessages.append(tr("alertInvalidPeerMessageEndpointInvalid"))
|
||||
}
|
||||
}
|
||||
if let persistentKeepAliveString = scratchpad[.persistentKeepAlive] {
|
||||
@@ -287,25 +374,23 @@ class TunnelViewModel {
|
||||
config.persistentKeepAlive = persistentKeepAlive
|
||||
} else {
|
||||
fieldsWithError.insert(.persistentKeepAlive)
|
||||
errorMessages.append("Peer's persistent keepalive must be between 0 to 65535, or unspecified")
|
||||
errorMessages.append(tr("alertInvalidPeerMessagePersistentKeepaliveInvalid"))
|
||||
}
|
||||
}
|
||||
|
||||
guard (errorMessages.isEmpty) else {
|
||||
return .error(errorMessages.first!)
|
||||
}
|
||||
guard errorMessages.isEmpty else { return .error(errorMessages.first!) }
|
||||
|
||||
validatedConfiguration = config
|
||||
return .saved(config)
|
||||
}
|
||||
|
||||
func filterFieldsWithValueOrControl(peerFields: [PeerField]) -> [PeerField] {
|
||||
return peerFields.filter { (field) -> Bool in
|
||||
if (TunnelViewModel.peerFieldsWithControl.contains(field)) {
|
||||
return peerFields.filter { field in
|
||||
if TunnelViewModel.peerFieldsWithControl.contains(field) {
|
||||
return true
|
||||
}
|
||||
return (!self[field].isEmpty)
|
||||
}
|
||||
// TODO: Cache this to avoid recomputing
|
||||
}
|
||||
|
||||
static let ipv4DefaultRouteString = "0.0.0.0/0"
|
||||
@@ -319,23 +404,20 @@ class TunnelViewModel {
|
||||
]
|
||||
|
||||
func updateExcludePrivateIPsFieldState() {
|
||||
guard (numberOfPeers == 1) else {
|
||||
if scratchpad.isEmpty {
|
||||
populateScratchpad()
|
||||
}
|
||||
let allowedIPStrings = Set<String>(scratchpad[.allowedIPs].splitToArray(trimmingCharacters: .whitespacesAndNewlines))
|
||||
shouldStronglyRecommendDNS = allowedIPStrings.contains(TunnelViewModel.PeerData.ipv4DefaultRouteString) || allowedIPStrings.isSuperset(of: TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String)
|
||||
guard numberOfPeers == 1 else {
|
||||
shouldAllowExcludePrivateIPsControl = false
|
||||
excludePrivateIPsValue = false
|
||||
return
|
||||
}
|
||||
if (scratchpad.isEmpty) {
|
||||
populateScratchpad()
|
||||
}
|
||||
let allowedIPStrings = Set<String>(
|
||||
(scratchpad[.allowedIPs] ?? "")
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) }
|
||||
)
|
||||
if (allowedIPStrings.contains(TunnelViewModel.PeerData.ipv4DefaultRouteString)) {
|
||||
if allowedIPStrings.contains(TunnelViewModel.PeerData.ipv4DefaultRouteString) {
|
||||
shouldAllowExcludePrivateIPsControl = true
|
||||
excludePrivateIPsValue = false
|
||||
} else if (allowedIPStrings.isSuperset(of: TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String)) {
|
||||
} else if allowedIPStrings.isSuperset(of: TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String) {
|
||||
shouldAllowExcludePrivateIPsControl = true
|
||||
excludePrivateIPsValue = true
|
||||
} else {
|
||||
@@ -345,42 +427,63 @@ class TunnelViewModel {
|
||||
}
|
||||
|
||||
func excludePrivateIPsValueChanged(isOn: Bool, dnsServers: String) {
|
||||
let allowedIPStrings = (scratchpad[.allowedIPs] ?? "")
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) }
|
||||
let dnsServerStrings = dnsServers
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) }
|
||||
let allowedIPStrings = scratchpad[.allowedIPs].splitToArray(trimmingCharacters: .whitespacesAndNewlines)
|
||||
let dnsServerStrings = dnsServers.splitToArray(trimmingCharacters: .whitespacesAndNewlines)
|
||||
let ipv6Addresses = allowedIPStrings.filter { $0.contains(":") }
|
||||
let modifiedAllowedIPStrings: [String]
|
||||
if (isOn) {
|
||||
modifiedAllowedIPStrings = ipv6Addresses +
|
||||
TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String + dnsServerStrings
|
||||
if isOn {
|
||||
modifiedAllowedIPStrings = ipv6Addresses + TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String + dnsServerStrings
|
||||
} else {
|
||||
modifiedAllowedIPStrings = ipv6Addresses +
|
||||
[TunnelViewModel.PeerData.ipv4DefaultRouteString]
|
||||
modifiedAllowedIPStrings = ipv6Addresses + [TunnelViewModel.PeerData.ipv4DefaultRouteString]
|
||||
}
|
||||
scratchpad[.allowedIPs] = modifiedAllowedIPStrings.joined(separator: ", ")
|
||||
validatedConfiguration = nil // The configuration has been modified, and needs to be saved
|
||||
validatedConfiguration = nil
|
||||
excludePrivateIPsValue = isOn
|
||||
}
|
||||
|
||||
func applyConfiguration(other: PeerConfiguration, peerIndex: Int, changeHandler: (Int, [PeerField: ChangeHandlers.FieldChange]) -> Void) {
|
||||
if scratchpad.isEmpty {
|
||||
populateScratchpad()
|
||||
}
|
||||
let otherScratchPad = PeerData.createScratchPad(from: other)
|
||||
var changes = [PeerField: ChangeHandlers.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
|
||||
}
|
||||
}
|
||||
}
|
||||
scratchpad = otherScratchPad
|
||||
if !changes.isEmpty {
|
||||
changeHandler(peerIndex, changes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SaveResult<Configuration> {
|
||||
case saved(Configuration)
|
||||
case error(String) // TODO: Localize error messages
|
||||
case error(String)
|
||||
}
|
||||
|
||||
var interfaceData: InterfaceData
|
||||
var peersData: [PeerData]
|
||||
private(set) var interfaceData: InterfaceData
|
||||
private(set) var peersData: [PeerData]
|
||||
|
||||
init(tunnelConfiguration: TunnelConfiguration?) {
|
||||
let interfaceData: InterfaceData = InterfaceData()
|
||||
var peersData: [PeerData] = []
|
||||
let interfaceData = InterfaceData()
|
||||
var peersData = [PeerData]()
|
||||
if let tunnelConfiguration = tunnelConfiguration {
|
||||
interfaceData.validatedConfiguration = tunnelConfiguration.interface
|
||||
for (i, peerConfiguration) in tunnelConfiguration.peers.enumerated() {
|
||||
let peerData = PeerData(index: i)
|
||||
interfaceData.validatedName = tunnelConfiguration.name
|
||||
for (index, peerConfiguration) in tunnelConfiguration.peers.enumerated() {
|
||||
let peerData = PeerData(index: index)
|
||||
peerData.validatedConfiguration = peerConfiguration
|
||||
peersData.append(peerData)
|
||||
}
|
||||
@@ -397,38 +500,36 @@ class TunnelViewModel {
|
||||
func appendEmptyPeer() {
|
||||
let peer = PeerData(index: peersData.count)
|
||||
peersData.append(peer)
|
||||
for p in peersData {
|
||||
p.numberOfPeers = peersData.count
|
||||
p.updateExcludePrivateIPsFieldState()
|
||||
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 p in peersData[peer.index ..< peersData.count] {
|
||||
assert(p.index > 0)
|
||||
p.index = p.index - 1
|
||||
for peer in peersData[peer.index ..< peersData.count] {
|
||||
assert(peer.index > 0)
|
||||
peer.index -= 1
|
||||
}
|
||||
for p in peersData {
|
||||
p.numberOfPeers = peersData.count
|
||||
p.updateExcludePrivateIPsFieldState()
|
||||
for peer in peersData {
|
||||
peer.numberOfPeers = peersData.count
|
||||
peer.updateExcludePrivateIPsFieldState()
|
||||
}
|
||||
}
|
||||
|
||||
func save() -> SaveResult<TunnelConfiguration> {
|
||||
// Attempt to save the interface and all peers, so that all erroring fields are collected
|
||||
let interfaceSaveResult = interfaceData.save()
|
||||
let peerSaveResults = peersData.map { $0.save() }
|
||||
// Collate the results
|
||||
switch (interfaceSaveResult) {
|
||||
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] = []
|
||||
var peerConfigurations = [PeerConfiguration]()
|
||||
peerConfigurations.reserveCapacity(peerSaveResults.count)
|
||||
for peerSaveResult in peerSaveResults {
|
||||
switch (peerSaveResult) {
|
||||
switch peerSaveResult {
|
||||
case .error(let errorMessage):
|
||||
return .error(errorMessage)
|
||||
case .saved(let peerConfiguration):
|
||||
@@ -438,35 +539,91 @@ class TunnelViewModel {
|
||||
|
||||
let peerPublicKeysArray = peerConfigurations.map { $0.publicKey }
|
||||
let peerPublicKeysSet = Set<Data>(peerPublicKeysArray)
|
||||
if (peerPublicKeysArray.count != peerPublicKeysSet.count) {
|
||||
return .error("Two or more peers cannot have the same public key")
|
||||
if peerPublicKeysArray.count != peerPublicKeysSet.count {
|
||||
return .error(tr("alertInvalidPeerMessagePublicKeyDuplicated"))
|
||||
}
|
||||
|
||||
let tunnelConfiguration = TunnelConfiguration(interface: interfaceConfiguration, peers: peerConfigurations)
|
||||
let tunnelConfiguration = TunnelConfiguration(name: interfaceConfiguration.0, interface: interfaceConfiguration.1, peers: peerConfigurations)
|
||||
return .saved(tunnelConfiguration)
|
||||
}
|
||||
}
|
||||
|
||||
func applyConfiguration(other: TunnelConfiguration, changeHandlers: ChangeHandlers) {
|
||||
// Replaces current data with data from other TunnelConfiguration, ignoring any changes in peer ordering.
|
||||
// Change handler callbacks are processed in the following order, which is designed to work with both the
|
||||
// UITableView way (modify - delete - insert) and the NSTableView way (indices are based on past operations):
|
||||
// - interfaceChanged
|
||||
// - peerChangedAt
|
||||
// - peersRemovedAt
|
||||
// - peersInsertedAt
|
||||
|
||||
interfaceData.applyConfiguration(other: other.interface, otherName: other.name ?? "", changeHandler: changeHandlers.interfaceChanged)
|
||||
|
||||
for otherPeer in other.peers {
|
||||
if let peersDataIndex = peersData.firstIndex(where: { $0.publicKey == otherPeer.publicKey }) {
|
||||
let peerData = peersData[peersDataIndex]
|
||||
peerData.applyConfiguration(other: otherPeer, peerIndex: peersDataIndex, changeHandler: changeHandlers.peerChangedAt)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
if !removedPeerIndices.isEmpty {
|
||||
changeHandlers.peersRemovedAt(removedPeerIndices)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
if !addedPeerIndices.isEmpty {
|
||||
changeHandlers.peersInsertedAt(addedPeerIndices)
|
||||
}
|
||||
|
||||
for (index, peer) in peersData.enumerated() {
|
||||
peer.index = index
|
||||
peer.numberOfPeers = peersData.count
|
||||
peer.updateExcludePrivateIPsFieldState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Activate on demand
|
||||
|
||||
extension TunnelViewModel {
|
||||
static func activateOnDemandOptionText(for activateOnDemandOption: ActivateOnDemandOption) -> String {
|
||||
switch (activateOnDemandOption) {
|
||||
switch activateOnDemandOption {
|
||||
case .none:
|
||||
return "Off"
|
||||
case .useOnDemandOverWiFiOrCellular:
|
||||
return "Wi-Fi or cellular"
|
||||
return tr("tunnelOnDemandOptionOff")
|
||||
case .useOnDemandOverWiFiOnly:
|
||||
return "Wi-Fi only"
|
||||
return tr("tunnelOnDemandOptionWiFiOnly")
|
||||
#if os(iOS)
|
||||
case .useOnDemandOverWiFiOrCellular:
|
||||
return tr("tunnelOnDemandOptionWiFiOrCellular")
|
||||
case .useOnDemandOverCellularOnly:
|
||||
return "Cellular only"
|
||||
return tr("tunnelOnDemandOptionCellularOnly")
|
||||
#elseif os(macOS)
|
||||
case .useOnDemandOverWiFiOrEthernet:
|
||||
return tr("tunnelOnDemandOptionWiFiOrEthernet")
|
||||
case .useOnDemandOverEthernetOnly:
|
||||
return tr("tunnelOnDemandOptionEthernetOnly")
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
static func activateOnDemandDetailText(for activateOnDemandSetting: ActivateOnDemandSetting?) -> String {
|
||||
if let activateOnDemandSetting = activateOnDemandSetting {
|
||||
if (activateOnDemandSetting.isActivateOnDemandEnabled) {
|
||||
if activateOnDemandSetting.isActivateOnDemandEnabled {
|
||||
return TunnelViewModel.activateOnDemandOptionText(for: activateOnDemandSetting.activateOnDemandOption)
|
||||
} else {
|
||||
return TunnelViewModel.activateOnDemandOptionText(for: .none)
|
||||
@@ -477,7 +634,86 @@ extension TunnelViewModel {
|
||||
}
|
||||
|
||||
static func defaultActivateOnDemandOption() -> ActivateOnDemandOption {
|
||||
#if os(iOS)
|
||||
return .useOnDemandOverWiFiOrCellular
|
||||
#elseif os(macOS)
|
||||
return .useOnDemandOverWiFiOrEthernet
|
||||
#else
|
||||
#error("Unimplemented")
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
import os.log
|
||||
@@ -10,11 +10,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
var window: UIWindow?
|
||||
var mainVC: MainViewController?
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||
Logger.configureGlobal(withFilePath: FileManager.appLogFileURL?.path)
|
||||
|
||||
let window = UIWindow(frame: UIScreen.main.bounds)
|
||||
window.backgroundColor = UIColor.white
|
||||
window.backgroundColor = .white
|
||||
self.window = window
|
||||
|
||||
let mainVC = MainViewController()
|
||||
@@ -27,8 +27,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
|
||||
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
|
||||
mainVC?.tunnelsListVC?.importFromFile(url: url)
|
||||
_ = FileManager.deleteFile(at: url)
|
||||
guard let tunnelsManager = mainVC?.tunnelsManager else { return true }
|
||||
TunnelImporter.importFromFile(url: url, into: tunnelsManager, sourceVC: mainVC, errorPresenterType: ErrorPresenter.self) {
|
||||
_ = FileManager.deleteFile(at: url)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -37,8 +39,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: State restoration
|
||||
|
||||
extension AppDelegate {
|
||||
func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool {
|
||||
return true
|
||||
@@ -48,15 +48,9 @@ extension AppDelegate {
|
||||
return true
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
viewControllerWithRestorationIdentifierPath identifierComponents: [String],
|
||||
coder: NSCoder) -> UIViewController? {
|
||||
func application(_ application: UIApplication, viewControllerWithRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? {
|
||||
guard let vcIdentifier = identifierComponents.last else { return nil }
|
||||
if (vcIdentifier == "MainVC") {
|
||||
return MainViewController()
|
||||
} else if (vcIdentifier == "TunnelsListVC") {
|
||||
return TunnelsListTableViewController()
|
||||
} else if (vcIdentifier.hasPrefix("TunnelDetailVC:")) {
|
||||
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) {
|
||||
@@ -66,7 +60,6 @@ extension AppDelegate {
|
||||
// Show it when tunnelsManager is available
|
||||
mainVC?.showTunnelDetailForTunnel(named: tunnelName, animated: false)
|
||||
}
|
||||
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 865 B After Width: | Height: | Size: 865 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
@@ -1,10 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14313.18" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="dyO-pm-zxZ">
|
||||
<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="14283.14"/>
|
||||
<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>
|
||||
@@ -23,7 +23,7 @@
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="XJD-RE-ATd" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
<!--WireGuard-->
|
||||
<!--Table View Controller-->
|
||||
<scene sceneID="pXL-Um-fWR">
|
||||
<objects>
|
||||
<tableViewController clearsSelectionOnViewWillAppear="NO" id="9s0-Gz-xEQ" sceneMemberID="viewController">
|
||||
@@ -46,10 +46,7 @@
|
||||
<outlet property="delegate" destination="9s0-Gz-xEQ" id="Frz-TZ-bD1"/>
|
||||
</connections>
|
||||
</tableView>
|
||||
<navigationItem key="navigationItem" title="WireGuard" id="0ZE-eq-OPY">
|
||||
<barButtonItem key="leftBarButtonItem" title="Settings" id="a9Y-E3-hac"/>
|
||||
<barButtonItem key="rightBarButtonItem" systemItem="add" id="ZSm-4E-cJx"/>
|
||||
</navigationItem>
|
||||
<navigationItem key="navigationItem" id="0ZE-eq-OPY"/>
|
||||
</tableViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="YJ1-t3-sLe" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
@@ -1,55 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class CopyableLabelTableViewCell: UITableViewCell {
|
||||
var copyableGesture = true
|
||||
|
||||
var textToCopy: String? {
|
||||
fatalError("textToCopy must be implemented by subclass")
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:)))
|
||||
self.addGestureRecognizer(gestureRecognizer)
|
||||
self.isUserInteractionEnabled = true
|
||||
}
|
||||
|
||||
// MARK: - UIGestureRecognizer
|
||||
@objc func handleTapGesture(_ recognizer: UIGestureRecognizer) {
|
||||
if !self.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(self.detailTextLabel?.frame ?? recognizerView.frame, in: self.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 = textToCopy
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
self.copyableGesture = true
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
import os.log
|
||||
|
||||
class ErrorPresenter {
|
||||
static func showErrorAlert(error: WireGuardAppError, from sourceVC: UIViewController?,
|
||||
onDismissal: (() -> Void)? = nil, onPresented: (() -> Void)? = nil) {
|
||||
guard let sourceVC = sourceVC else { return }
|
||||
let (title, message) = error.alertText()
|
||||
let okAction = UIAlertAction(title: "OK", style: .default) { (_) in
|
||||
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)
|
||||
@@ -17,15 +16,4 @@ class ErrorPresenter {
|
||||
|
||||
sourceVC.present(alert, animated: true, completion: onPresented)
|
||||
}
|
||||
|
||||
static func showErrorAlert(title: String, message: String, from sourceVC: UIViewController?, onDismissal: (() -> Void)? = nil) {
|
||||
guard let sourceVC = sourceVC 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,9 @@
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<string>Face ID is used for authenticating viewing and exporting of private keys</string>
|
||||
<key>com.wireguard.ios.app_group_id</key>
|
||||
<string>group.$(APP_ID)</string>
|
||||
<string>group.$(APP_ID_IOS)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,50 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
class ScrollableLabel: UIScrollView {
|
||||
var text: String {
|
||||
get { return label.text ?? "" }
|
||||
set(value) { label.text = value }
|
||||
}
|
||||
var textColor: UIColor {
|
||||
get { return label.textColor }
|
||||
set(value) { label.textColor = value }
|
||||
}
|
||||
|
||||
private let label: UILabel
|
||||
|
||||
init() {
|
||||
let label = UILabel()
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
label.textAlignment = .right
|
||||
self.label = label
|
||||
|
||||
super.init(frame: CGRect.zero)
|
||||
|
||||
self.isDirectionalLockEnabled = true
|
||||
self.showsHorizontalScrollIndicator = false
|
||||
self.showsVerticalScrollIndicator = false
|
||||
|
||||
addSubview(label)
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
label.leftAnchor.constraint(equalTo: self.contentLayoutGuide.leftAnchor),
|
||||
label.topAnchor.constraint(equalTo: self.contentLayoutGuide.topAnchor),
|
||||
label.bottomAnchor.constraint(equalTo: self.contentLayoutGuide.bottomAnchor),
|
||||
label.rightAnchor.constraint(equalTo: self.contentLayoutGuide.rightAnchor),
|
||||
label.heightAnchor.constraint(equalTo: self.heightAnchor),
|
||||
])
|
||||
// If label has less content, it should expand to fit the scrollView,
|
||||
// so that right-alignment works in the label.
|
||||
let expandToFitValueLabelConstraint = NSLayoutConstraint(item: label, attribute: .width, relatedBy: .equal,
|
||||
toItem: self, attribute: .width, multiplier: 1, constant: 0)
|
||||
expandToFitValueLabelConstraint.priority = .defaultLow + 1
|
||||
expandToFitValueLabelConstraint.isActive = true
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
import os.log
|
||||
|
||||
class SettingsTableViewController: UITableViewController {
|
||||
|
||||
enum SettingsFields: String {
|
||||
case iosAppVersion = "WireGuard for iOS"
|
||||
case goBackendVersion = "WireGuard Go Backend"
|
||||
case exportZipArchive = "Export zip archive"
|
||||
case exportLogFile = "Export log file"
|
||||
}
|
||||
|
||||
let settingsFieldsBySection: [[SettingsFields]] = [
|
||||
[.iosAppVersion, .goBackendVersion],
|
||||
[.exportZipArchive],
|
||||
[.exportLogFile]
|
||||
]
|
||||
|
||||
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()
|
||||
self.title = "Settings"
|
||||
self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneTapped))
|
||||
|
||||
self.tableView.rowHeight = 44
|
||||
self.tableView.allowsSelection = false
|
||||
|
||||
self.tableView.register(TunnelSettingsTableViewKeyValueCell.self, forCellReuseIdentifier: TunnelSettingsTableViewKeyValueCell.id)
|
||||
self.tableView.register(TunnelSettingsTableViewButtonCell.self, forCellReuseIdentifier: TunnelSettingsTableViewButtonCell.id)
|
||||
|
||||
let logo = UIImageView(image: UIImage(named: "wireguard.pdf", in: Bundle.main, compatibleWith: nil)!)
|
||||
logo.contentMode = .scaleAspectFit
|
||||
let height = self.tableView.rowHeight * 1.5
|
||||
let width = height * logo.image!.size.width / logo.image!.size.height
|
||||
logo.frame = CGRect(x: 0, y: 0, width: width, height: height)
|
||||
logo.bounds = logo.frame.insetBy(dx: 2, dy: 2)
|
||||
self.tableView.tableFooterView = logo
|
||||
}
|
||||
|
||||
override func viewDidLayoutSubviews() {
|
||||
super.viewDidLayoutSubviews()
|
||||
guard let logo = self.tableView.tableFooterView else { return }
|
||||
let bottomPadding = max(self.tableView.layoutMargins.bottom, CGFloat(10))
|
||||
let fullHeight = max(self.tableView.contentSize.height, self.tableView.bounds.size.height - self.tableView.layoutMargins.top - bottomPadding)
|
||||
let e = logo.frame
|
||||
logo.frame = CGRect(x: e.minX, y: fullHeight - e.height, width: e.width, height: e.height)
|
||||
}
|
||||
|
||||
@objc func doneTapped() {
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func exportConfigurationsAsZipFile(sourceView: UIView) {
|
||||
guard let tunnelsManager = 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 exportLogForLastActivatedTunnel(sourceView: UIView) {
|
||||
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: "No log available", message: "The pre-existing log could not be cleared", from: self)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard let networkExtensionLogFileURL = FileManager.networkExtensionLogFileURL,
|
||||
FileManager.default.fileExists(atPath: networkExtensionLogFileURL.path) else {
|
||||
ErrorPresenter.showErrorAlert(title: "No log available", message: "Please activate a tunnel and then export the log", from: self)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.copyItem(at: networkExtensionLogFileURL, to: destinationURL)
|
||||
} catch {
|
||||
os_log("Failed to copy file: %{public}@ to %{public}@: %{public}@", log: OSLog.default, type: .error, networkExtensionLogFileURL.absoluteString, destinationURL.absoluteString, error.localizedDescription)
|
||||
ErrorPresenter.showErrorAlert(title: "Log export failed", message: "The log could not be copied", from: self)
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let activityVC = UIActivityViewController(activityItems: [destinationURL], applicationActivities: nil)
|
||||
// popoverPresentationController shall be non-nil on the iPad
|
||||
activityVC.popoverPresentationController?.sourceView = sourceView
|
||||
activityVC.popoverPresentationController?.sourceRect = sourceView.bounds
|
||||
activityVC.completionWithItemsHandler = { (_, _, _, _) in
|
||||
// Remove the exported log file after the activity has completed
|
||||
_ = FileManager.deleteFile(at: destinationURL)
|
||||
}
|
||||
self.present(activityVC, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UITableViewDataSource
|
||||
|
||||
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 "About"
|
||||
case 1:
|
||||
return "Export configurations"
|
||||
case 2:
|
||||
return "Tunnel log"
|
||||
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 = tableView.dequeueReusableCell(withIdentifier: TunnelSettingsTableViewKeyValueCell.id, for: indexPath) as! TunnelSettingsTableViewKeyValueCell
|
||||
cell.key = field.rawValue
|
||||
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 = WIREGUARD_GO_VERSION
|
||||
}
|
||||
return cell
|
||||
} else if (field == .exportZipArchive) {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelSettingsTableViewButtonCell.id, for: indexPath) as! TunnelSettingsTableViewButtonCell
|
||||
cell.buttonText = field.rawValue
|
||||
cell.onTapped = { [weak self] in
|
||||
self?.exportConfigurationsAsZipFile(sourceView: cell.button)
|
||||
}
|
||||
return cell
|
||||
} else {
|
||||
assert(field == .exportLogFile)
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelSettingsTableViewButtonCell.id, for: indexPath) as! TunnelSettingsTableViewButtonCell
|
||||
cell.buttonText = field.rawValue
|
||||
cell.onTapped = { [weak self] in
|
||||
self?.exportLogForLastActivatedTunnel(sourceView: cell.button)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TunnelSettingsTableViewKeyValueCell: UITableViewCell {
|
||||
static let id: String = "TunnelSettingsTableViewKeyValueCell"
|
||||
var key: String {
|
||||
get { return textLabel?.text ?? "" }
|
||||
set(value) { textLabel?.text = value }
|
||||
}
|
||||
var value: String {
|
||||
get { return detailTextLabel?.text ?? "" }
|
||||
set(value) { detailTextLabel?.text = value }
|
||||
}
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: .value1, reuseIdentifier: TunnelSettingsTableViewKeyValueCell.id)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
key = ""
|
||||
value = ""
|
||||
}
|
||||
}
|
||||
|
||||
class TunnelSettingsTableViewButtonCell: UITableViewCell {
|
||||
static let id: String = "TunnelSettingsTableViewButtonCell"
|
||||
var buttonText: String {
|
||||
get { return button.title(for: .normal) ?? "" }
|
||||
set(value) { button.setTitle(value, for: .normal) }
|
||||
}
|
||||
var onTapped: (() -> Void)?
|
||||
|
||||
let button: UIButton
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
button = UIButton(type: .system)
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
contentView.addSubview(button)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
button.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
// MARK: TunnelDetailTableViewController
|
||||
|
||||
class TunnelDetailTableViewController: UITableViewController {
|
||||
|
||||
let interfaceFields: [TunnelViewModel.InterfaceField] = [
|
||||
.name, .publicKey, .addresses,
|
||||
.listenPort, .mtu, .dns
|
||||
]
|
||||
|
||||
let peerFields: [TunnelViewModel.PeerField] = [
|
||||
.publicKey, .preSharedKey, .endpoint,
|
||||
.allowedIPs, .persistentKeepAlive
|
||||
]
|
||||
|
||||
let tunnelsManager: TunnelsManager
|
||||
let tunnel: TunnelContainer
|
||||
var tunnelViewModel: TunnelViewModel
|
||||
|
||||
init(tunnelsManager tm: TunnelsManager, tunnel t: TunnelContainer) {
|
||||
tunnelsManager = tm
|
||||
tunnel = t
|
||||
tunnelViewModel = TunnelViewModel(tunnelConfiguration: t.tunnelConfiguration())
|
||||
super.init(style: .grouped)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
self.title = tunnelViewModel.interfaceData[.name]
|
||||
self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(editTapped))
|
||||
|
||||
self.tableView.rowHeight = 44
|
||||
self.tableView.allowsSelection = false
|
||||
self.tableView.register(TunnelDetailTableViewStatusCell.self, forCellReuseIdentifier: TunnelDetailTableViewStatusCell.id)
|
||||
self.tableView.register(TunnelDetailTableViewKeyValueCell.self, forCellReuseIdentifier: TunnelDetailTableViewKeyValueCell.id)
|
||||
self.tableView.register(TunnelDetailTableViewButtonCell.self, forCellReuseIdentifier: TunnelDetailTableViewButtonCell.id)
|
||||
self.tableView.register(TunnelDetailTableViewActivateOnDemandCell.self, forCellReuseIdentifier: TunnelDetailTableViewActivateOnDemandCell.id)
|
||||
|
||||
// State restoration
|
||||
self.restorationIdentifier = "TunnelDetailVC:\(tunnel.name)"
|
||||
}
|
||||
|
||||
@objc func editTapped() {
|
||||
let editVC = TunnelEditTableViewController(tunnelsManager: tunnelsManager, tunnel: tunnel)
|
||||
editVC.delegate = self
|
||||
let editNC = UINavigationController(rootViewController: editVC)
|
||||
editNC.modalPresentationStyle = .formSheet
|
||||
present(editNC, animated: true)
|
||||
}
|
||||
|
||||
func showConfirmationAlert(message: String, buttonTitle: String, from sourceView: UIView,
|
||||
onConfirmed: @escaping (() -> Void)) {
|
||||
let destroyAction = UIAlertAction(title: buttonTitle, style: .destructive) { (_) in
|
||||
onConfirmed()
|
||||
}
|
||||
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel)
|
||||
let alert = UIAlertController(title: "", message: message, preferredStyle: .actionSheet)
|
||||
alert.addAction(destroyAction)
|
||||
alert.addAction(cancelAction)
|
||||
|
||||
// popoverPresentationController will be nil on iPhone and non-nil on iPad
|
||||
alert.popoverPresentationController?.sourceView = sourceView
|
||||
alert.popoverPresentationController?.sourceRect = sourceView.bounds
|
||||
|
||||
self.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: TunnelEditTableViewControllerDelegate
|
||||
|
||||
extension TunnelDetailTableViewController: TunnelEditTableViewControllerDelegate {
|
||||
func tunnelSaved(tunnel: TunnelContainer) {
|
||||
tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration())
|
||||
self.title = tunnel.name
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
func tunnelEditingCancelled() {
|
||||
// Nothing to do
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UITableViewDataSource
|
||||
|
||||
extension TunnelDetailTableViewController {
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return 4 + tunnelViewModel.peersData.count
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
let interfaceData = tunnelViewModel.interfaceData
|
||||
let numberOfPeerSections = tunnelViewModel.peersData.count
|
||||
|
||||
if (section == 0) {
|
||||
// Status
|
||||
return 1
|
||||
} else if (section == 1) {
|
||||
// Interface
|
||||
return interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFields).count
|
||||
} else if ((numberOfPeerSections > 0) && (section < (2 + numberOfPeerSections))) {
|
||||
// Peer
|
||||
let peerData = tunnelViewModel.peersData[section - 2]
|
||||
return peerData.filterFieldsWithValueOrControl(peerFields: peerFields).count
|
||||
} else if (section < (3 + numberOfPeerSections)) {
|
||||
// Activate on demand
|
||||
return 1
|
||||
} else {
|
||||
assert(section == (3 + numberOfPeerSections))
|
||||
// Delete tunnel
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
let numberOfPeerSections = tunnelViewModel.peersData.count
|
||||
|
||||
if (section == 0) {
|
||||
// Status
|
||||
return "Status"
|
||||
} else if (section == 1) {
|
||||
// Interface
|
||||
return "Interface"
|
||||
} else if ((numberOfPeerSections > 0) && (section < (2 + numberOfPeerSections))) {
|
||||
// Peer
|
||||
return "Peer"
|
||||
} else if (section < (3 + numberOfPeerSections)) {
|
||||
// On-Demand Activation
|
||||
return "On-Demand Activation"
|
||||
} else {
|
||||
// Delete tunnel
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let interfaceData = tunnelViewModel.interfaceData
|
||||
let numberOfPeerSections = tunnelViewModel.peersData.count
|
||||
|
||||
let section = indexPath.section
|
||||
let row = indexPath.row
|
||||
|
||||
if (section == 0) {
|
||||
// Status
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelDetailTableViewStatusCell.id, for: indexPath) as! TunnelDetailTableViewStatusCell
|
||||
cell.tunnel = self.tunnel
|
||||
cell.onSwitchToggled = { [weak self] isOn in
|
||||
guard let s = self else { return }
|
||||
if (isOn) {
|
||||
s.tunnelsManager.startActivation(of: s.tunnel) { [weak s] error in
|
||||
if let error = error {
|
||||
ErrorPresenter.showErrorAlert(error: error, from: s, onPresented: {
|
||||
DispatchQueue.main.async {
|
||||
cell.statusSwitch.isOn = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
s.tunnelsManager.startDeactivation(of: s.tunnel)
|
||||
}
|
||||
}
|
||||
return cell
|
||||
} else if (section == 1) {
|
||||
// Interface
|
||||
let field = interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFields)[row]
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelDetailTableViewKeyValueCell.id, for: indexPath) as! TunnelDetailTableViewKeyValueCell
|
||||
// Set key and value
|
||||
cell.key = field.rawValue
|
||||
cell.value = interfaceData[field]
|
||||
return cell
|
||||
} else if ((numberOfPeerSections > 0) && (section < (2 + numberOfPeerSections))) {
|
||||
// Peer
|
||||
let peerData = tunnelViewModel.peersData[section - 2]
|
||||
let field = peerData.filterFieldsWithValueOrControl(peerFields: peerFields)[row]
|
||||
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelDetailTableViewKeyValueCell.id, for: indexPath) as! TunnelDetailTableViewKeyValueCell
|
||||
// Set key and value
|
||||
cell.key = field.rawValue
|
||||
cell.value = peerData[field]
|
||||
return cell
|
||||
} else if (section < (3 + numberOfPeerSections)) {
|
||||
// On-Demand Activation
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelDetailTableViewActivateOnDemandCell.id, for: indexPath) as! TunnelDetailTableViewActivateOnDemandCell
|
||||
cell.tunnel = self.tunnel
|
||||
return cell
|
||||
} else {
|
||||
assert(section == (3 + numberOfPeerSections))
|
||||
// Delete configuration
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelDetailTableViewButtonCell.id, for: indexPath) as! TunnelDetailTableViewButtonCell
|
||||
cell.buttonText = "Delete tunnel"
|
||||
cell.hasDestructiveAction = true
|
||||
cell.onTapped = { [weak self] in
|
||||
guard let s = self else { return }
|
||||
s.showConfirmationAlert(message: "Delete this tunnel?", buttonTitle: "Delete", from: cell) { [weak s] in
|
||||
guard let tunnelsManager = s?.tunnelsManager, let tunnel = s?.tunnel else { return }
|
||||
tunnelsManager.remove(tunnel: tunnel) { (error) in
|
||||
if (error != nil) {
|
||||
print("Error removing tunnel: \(String(describing: error))")
|
||||
return
|
||||
}
|
||||
}
|
||||
s?.navigationController?.navigationController?.popToRootViewController(animated: true)
|
||||
}
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TunnelDetailTableViewStatusCell: UITableViewCell {
|
||||
static let id: String = "TunnelDetailTableViewStatusCell"
|
||||
|
||||
var tunnel: TunnelContainer? {
|
||||
didSet(value) {
|
||||
update(from: tunnel?.status)
|
||||
statusObservervationToken = tunnel?.observe(\.status) { [weak self] (tunnel, _) in
|
||||
self?.update(from: tunnel.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
var isSwitchInteractionEnabled: Bool {
|
||||
get { return statusSwitch.isUserInteractionEnabled }
|
||||
set(value) { statusSwitch.isUserInteractionEnabled = value }
|
||||
}
|
||||
var onSwitchToggled: ((Bool) -> Void)?
|
||||
private var isOnSwitchToggledHandlerEnabled: Bool = true
|
||||
|
||||
let statusSwitch: UISwitch
|
||||
private var statusObservervationToken: AnyObject?
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
statusSwitch = UISwitch()
|
||||
super.init(style: .default, reuseIdentifier: TunnelDetailTableViewKeyValueCell.id)
|
||||
accessoryView = statusSwitch
|
||||
|
||||
statusSwitch.addTarget(self, action: #selector(switchToggled), for: .valueChanged)
|
||||
}
|
||||
|
||||
@objc func switchToggled() {
|
||||
if (isOnSwitchToggledHandlerEnabled) {
|
||||
onSwitchToggled?(statusSwitch.isOn)
|
||||
}
|
||||
}
|
||||
|
||||
private func update(from status: TunnelStatus?) {
|
||||
guard let status = status else {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
let text: String
|
||||
switch (status) {
|
||||
case .inactive:
|
||||
text = "Inactive"
|
||||
case .activating:
|
||||
text = "Activating"
|
||||
case .active:
|
||||
text = "Active"
|
||||
case .deactivating:
|
||||
text = "Deactivating"
|
||||
case .reasserting:
|
||||
text = "Reactivating"
|
||||
case .restarting:
|
||||
text = "Restarting"
|
||||
case .waiting:
|
||||
text = "Waiting"
|
||||
}
|
||||
textLabel?.text = text
|
||||
DispatchQueue.main.async { [weak statusSwitch] in
|
||||
guard let statusSwitch = statusSwitch else { return }
|
||||
statusSwitch.isOn = !(status == .deactivating || status == .inactive)
|
||||
statusSwitch.isUserInteractionEnabled = (status == .inactive || status == .active)
|
||||
}
|
||||
textLabel?.textColor = (status == .active || status == .inactive) ? UIColor.black : UIColor.gray
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
textLabel?.text = "Invalid"
|
||||
statusSwitch.isOn = false
|
||||
textLabel?.textColor = UIColor.gray
|
||||
statusSwitch.isUserInteractionEnabled = false
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
class TunnelDetailTableViewKeyValueCell: CopyableLabelTableViewCell {
|
||||
static let id: String = "TunnelDetailTableViewKeyValueCell"
|
||||
var key: String {
|
||||
get { return keyLabel.text ?? "" }
|
||||
set(value) { keyLabel.text = value }
|
||||
}
|
||||
var value: String {
|
||||
get { return valueLabel.text }
|
||||
set(value) { valueLabel.text = value }
|
||||
}
|
||||
|
||||
override var textToCopy: String? {
|
||||
return self.valueLabel.text
|
||||
}
|
||||
|
||||
let keyLabel: UILabel
|
||||
let valueLabel: ScrollableLabel
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
keyLabel = UILabel()
|
||||
valueLabel = ScrollableLabel()
|
||||
|
||||
keyLabel.textColor = UIColor.black
|
||||
valueLabel.textColor = UIColor.gray
|
||||
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
contentView.addSubview(keyLabel)
|
||||
keyLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
keyLabel.textAlignment = .left
|
||||
NSLayoutConstraint.activate([
|
||||
keyLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor),
|
||||
keyLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
|
||||
])
|
||||
|
||||
contentView.addSubview(valueLabel)
|
||||
valueLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
valueLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor),
|
||||
valueLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
valueLabel.leftAnchor.constraint(equalTo: keyLabel.rightAnchor, constant: 8)
|
||||
])
|
||||
|
||||
// Key label should never appear truncated
|
||||
keyLabel.setContentCompressionResistancePriority(.defaultHigh + 1, for: .horizontal)
|
||||
// Key label should hug it's content; value label should not.
|
||||
keyLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
valueLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
key = ""
|
||||
value = ""
|
||||
}
|
||||
}
|
||||
|
||||
class TunnelDetailTableViewButtonCell: UITableViewCell {
|
||||
static let id: String = "TunnelDetailTableViewButtonCell"
|
||||
var buttonText: String {
|
||||
get { return button.title(for: .normal) ?? "" }
|
||||
set(value) { button.setTitle(value, for: .normal) }
|
||||
}
|
||||
var hasDestructiveAction: Bool {
|
||||
get { return button.tintColor == UIColor.red }
|
||||
set(value) { button.tintColor = value ? UIColor.red : buttonStandardTintColor }
|
||||
}
|
||||
var onTapped: (() -> Void)?
|
||||
|
||||
let button: UIButton
|
||||
var buttonStandardTintColor: UIColor
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
button = UIButton(type: .system)
|
||||
buttonStandardTintColor = button.tintColor
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
contentView.addSubview(button)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
button.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
class TunnelDetailTableViewActivateOnDemandCell: UITableViewCell {
|
||||
static let id: String = "TunnelDetailTableViewActivateOnDemandCell"
|
||||
|
||||
var tunnel: TunnelContainer? {
|
||||
didSet(value) {
|
||||
update(from: tunnel?.activateOnDemandSetting())
|
||||
onDemandStatusObservervationToken = tunnel?.observe(\.isActivateOnDemandEnabled) { [weak self] (tunnel, _) in
|
||||
self?.update(from: tunnel.activateOnDemandSetting())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var onDemandStatusObservervationToken: AnyObject?
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: .value1, reuseIdentifier: reuseIdentifier)
|
||||
textLabel?.text = "Activate on demand"
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func update(from activateOnDemandSetting: ActivateOnDemandSetting?) {
|
||||
detailTextLabel?.text = TunnelViewModel.activateOnDemandDetailText(for: activateOnDemandSetting)
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
detailTextLabel?.text = ""
|
||||
}
|
||||
}
|
||||
@@ -1,773 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol TunnelEditTableViewControllerDelegate: class {
|
||||
func tunnelSaved(tunnel: TunnelContainer)
|
||||
func tunnelEditingCancelled()
|
||||
}
|
||||
|
||||
// MARK: TunnelEditTableViewController
|
||||
|
||||
class TunnelEditTableViewController: UITableViewController {
|
||||
|
||||
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 activateOnDemandOptions: [ActivateOnDemandOption] = [
|
||||
.useOnDemandOverWiFiOrCellular,
|
||||
.useOnDemandOverWiFiOnly,
|
||||
.useOnDemandOverCellularOnly
|
||||
]
|
||||
|
||||
let tunnelsManager: TunnelsManager
|
||||
let tunnel: TunnelContainer?
|
||||
let tunnelViewModel: TunnelViewModel
|
||||
var activateOnDemandSetting: ActivateOnDemandSetting
|
||||
|
||||
init(tunnelsManager tm: TunnelsManager, tunnel t: TunnelContainer) {
|
||||
// Use this initializer to edit an existing tunnel.
|
||||
tunnelsManager = tm
|
||||
tunnel = t
|
||||
tunnelViewModel = TunnelViewModel(tunnelConfiguration: t.tunnelConfiguration())
|
||||
activateOnDemandSetting = t.activateOnDemandSetting()
|
||||
super.init(style: .grouped)
|
||||
}
|
||||
|
||||
init(tunnelsManager tm: TunnelsManager, tunnelConfiguration: TunnelConfiguration?) {
|
||||
// Use this initializer to create a new tunnel.
|
||||
// If tunnelConfiguration is passed, data will be prepopulated from that configuration.
|
||||
tunnelsManager = tm
|
||||
tunnel = nil
|
||||
tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnelConfiguration)
|
||||
activateOnDemandSetting = ActivateOnDemandSetting.defaultSetting
|
||||
super.init(style: .grouped)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
self.title = (tunnel == nil) ? "New configuration" : "Edit configuration"
|
||||
self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(saveTapped))
|
||||
self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped))
|
||||
|
||||
self.tableView.rowHeight = 44
|
||||
|
||||
self.tableView.register(TunnelEditTableViewKeyValueCell.self, forCellReuseIdentifier: TunnelEditTableViewKeyValueCell.id)
|
||||
self.tableView.register(TunnelEditTableViewReadOnlyKeyValueCell.self, forCellReuseIdentifier: TunnelEditTableViewReadOnlyKeyValueCell.id)
|
||||
self.tableView.register(TunnelEditTableViewButtonCell.self, forCellReuseIdentifier: TunnelEditTableViewButtonCell.id)
|
||||
self.tableView.register(TunnelEditTableViewSwitchCell.self, forCellReuseIdentifier: TunnelEditTableViewSwitchCell.id)
|
||||
self.tableView.register(TunnelEditTableViewSelectionListCell.self, forCellReuseIdentifier: TunnelEditTableViewSelectionListCell.id)
|
||||
}
|
||||
|
||||
@objc func saveTapped() {
|
||||
self.tableView.endEditing(false)
|
||||
let tunnelSaveResult = tunnelViewModel.save()
|
||||
switch (tunnelSaveResult) {
|
||||
case .error(let errorMessage):
|
||||
let erroringConfiguration = (tunnelViewModel.interfaceData.validatedConfiguration == nil) ? "Interface" : "Peer"
|
||||
ErrorPresenter.showErrorAlert(title: "Invalid \(erroringConfiguration)", message: errorMessage, from: self)
|
||||
self.tableView.reloadData() // Highlight erroring fields
|
||||
case .saved(let tunnelConfiguration):
|
||||
if let tunnel = tunnel {
|
||||
// We're modifying an existing tunnel
|
||||
tunnelsManager.modify(tunnel: tunnel,
|
||||
tunnelConfiguration: tunnelConfiguration,
|
||||
activateOnDemandSetting: activateOnDemandSetting) { [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,
|
||||
activateOnDemandSetting: activateOnDemandSetting) { [weak self] result in
|
||||
if let error = result.error {
|
||||
ErrorPresenter.showErrorAlert(error: error, from: self)
|
||||
} else {
|
||||
let tunnel: TunnelContainer = result.value!
|
||||
self?.dismiss(animated: true, completion: nil)
|
||||
self?.delegate?.tunnelSaved(tunnel: tunnel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func cancelTapped() {
|
||||
dismiss(animated: true, completion: nil)
|
||||
self.delegate?.tunnelEditingCancelled()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UITableViewDataSource
|
||||
|
||||
extension TunnelEditTableViewController {
|
||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||
let numberOfInterfaceSections = interfaceFieldsBySection.count
|
||||
let numberOfPeerSections = tunnelViewModel.peersData.count
|
||||
|
||||
return numberOfInterfaceSections + numberOfPeerSections + 1 /* Add Peer */ + 1 /* On-Demand */
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
let numberOfInterfaceSections = interfaceFieldsBySection.count
|
||||
let numberOfPeerSections = tunnelViewModel.peersData.count
|
||||
|
||||
if (section < numberOfInterfaceSections) {
|
||||
// Interface
|
||||
return interfaceFieldsBySection[section].count
|
||||
} else if ((numberOfPeerSections > 0) && (section < (numberOfInterfaceSections + numberOfPeerSections))) {
|
||||
// Peer
|
||||
let peerIndex = (section - numberOfInterfaceSections)
|
||||
let peerData = tunnelViewModel.peersData[peerIndex]
|
||||
let peerFieldsToShow = peerData.shouldAllowExcludePrivateIPsControl ? peerFields : peerFields.filter { $0 != .excludePrivateIPs }
|
||||
return peerFieldsToShow.count
|
||||
} else if (section < (numberOfInterfaceSections + numberOfPeerSections + 1)) {
|
||||
// Add peer
|
||||
return 1
|
||||
} else {
|
||||
// On-Demand Rules
|
||||
if (activateOnDemandSetting.isActivateOnDemandEnabled) {
|
||||
return 4
|
||||
} else {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
let numberOfInterfaceSections = interfaceFieldsBySection.count
|
||||
let numberOfPeerSections = tunnelViewModel.peersData.count
|
||||
|
||||
if (section < numberOfInterfaceSections) {
|
||||
// Interface
|
||||
return (section == 0) ? "Interface" : nil
|
||||
} else if ((numberOfPeerSections > 0) && (section < (numberOfInterfaceSections + numberOfPeerSections))) {
|
||||
// Peer
|
||||
return "Peer"
|
||||
} else if (section == (numberOfInterfaceSections + numberOfPeerSections)) {
|
||||
// Add peer
|
||||
return nil
|
||||
} else {
|
||||
assert(section == (numberOfInterfaceSections + numberOfPeerSections + 1))
|
||||
return "On-Demand Activation"
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let numberOfInterfaceSections = interfaceFieldsBySection.count
|
||||
let numberOfPeerSections = tunnelViewModel.peersData.count
|
||||
|
||||
let section = indexPath.section
|
||||
let row = indexPath.row
|
||||
|
||||
if (section < numberOfInterfaceSections) {
|
||||
// Interface
|
||||
let interfaceData = tunnelViewModel.interfaceData
|
||||
let field = interfaceFieldsBySection[section][row]
|
||||
if (field == .generateKeyPair) {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelEditTableViewButtonCell.id, for: indexPath) as! TunnelEditTableViewButtonCell
|
||||
cell.buttonText = field.rawValue
|
||||
cell.onTapped = { [weak self, weak interfaceData] in
|
||||
if let interfaceData = interfaceData, let s = self {
|
||||
interfaceData[.privateKey] = Curve25519.generatePrivateKey().base64EncodedString()
|
||||
if let privateKeyRow = s.interfaceFieldsBySection[section].firstIndex(of: .privateKey),
|
||||
let publicKeyRow = s.interfaceFieldsBySection[section].firstIndex(of: .publicKey) {
|
||||
let privateKeyIndex = IndexPath(row: privateKeyRow, section: section)
|
||||
let publicKeyIndex = IndexPath(row: publicKeyRow, section: section)
|
||||
s.tableView.reloadRows(at: [privateKeyIndex, publicKeyIndex], with: .automatic)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cell
|
||||
} else if (field == .publicKey) {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelEditTableViewReadOnlyKeyValueCell.id, for: indexPath) as! TunnelEditTableViewReadOnlyKeyValueCell
|
||||
cell.key = field.rawValue
|
||||
cell.value = interfaceData[field]
|
||||
return cell
|
||||
} else {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelEditTableViewKeyValueCell.id, for: indexPath) as! TunnelEditTableViewKeyValueCell
|
||||
// Set key
|
||||
cell.key = field.rawValue
|
||||
// Set placeholder text
|
||||
switch (field) {
|
||||
case .name:
|
||||
cell.placeholderText = "Required"
|
||||
case .privateKey:
|
||||
cell.placeholderText = "Required"
|
||||
case .addresses:
|
||||
cell.placeholderText = "Optional"
|
||||
case .listenPort:
|
||||
cell.placeholderText = "Automatic"
|
||||
case .mtu:
|
||||
cell.placeholderText = "Automatic"
|
||||
case .dns:
|
||||
cell.placeholderText = "Optional"
|
||||
case .publicKey: break
|
||||
case .generateKeyPair: break
|
||||
}
|
||||
// Set keyboardType
|
||||
if (field == .mtu || field == .listenPort) {
|
||||
cell.keyboardType = .numberPad
|
||||
} else if (field == .addresses || field == .dns) {
|
||||
cell.keyboardType = .numbersAndPunctuation
|
||||
}
|
||||
// Show erroring fields
|
||||
cell.isValueValid = (!interfaceData.fieldsWithError.contains(field))
|
||||
// Bind values to view model
|
||||
cell.value = interfaceData[field]
|
||||
if (field == .dns) { // While editing DNS, you might directly set exclude private IPs
|
||||
cell.onValueBeingEdited = { [weak interfaceData] value in
|
||||
interfaceData?[field] = value
|
||||
}
|
||||
} else {
|
||||
cell.onValueChanged = { [weak interfaceData] value in
|
||||
interfaceData?[field] = value
|
||||
}
|
||||
}
|
||||
// Compute public key live
|
||||
if (field == .privateKey) {
|
||||
cell.onValueBeingEdited = { [weak self, weak interfaceData] value in
|
||||
if let interfaceData = interfaceData, let s = self {
|
||||
interfaceData[.privateKey] = value
|
||||
if let row = s.interfaceFieldsBySection[section].firstIndex(of: .publicKey) {
|
||||
s.tableView.reloadRows(at: [IndexPath(row: row, section: section)], with: .none)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cell
|
||||
}
|
||||
} else if ((numberOfPeerSections > 0) && (section < (numberOfInterfaceSections + numberOfPeerSections))) {
|
||||
// Peer
|
||||
let peerIndex = (section - numberOfInterfaceSections)
|
||||
let peerData = tunnelViewModel.peersData[peerIndex]
|
||||
let peerFieldsToShow = peerData.shouldAllowExcludePrivateIPsControl ? peerFields : peerFields.filter { $0 != .excludePrivateIPs }
|
||||
let field = peerFieldsToShow[row]
|
||||
if (field == .deletePeer) {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelEditTableViewButtonCell.id, for: indexPath) as! TunnelEditTableViewButtonCell
|
||||
cell.buttonText = field.rawValue
|
||||
cell.hasDestructiveAction = true
|
||||
cell.onTapped = { [weak self, weak peerData] in
|
||||
guard let peerData = peerData else { return }
|
||||
guard let s = self else { return }
|
||||
s.showConfirmationAlert(message: "Delete this peer?",
|
||||
buttonTitle: "Delete", from: cell,
|
||||
onConfirmed: { [weak s] in
|
||||
guard let s = s else { return }
|
||||
let removedSectionIndices = s.deletePeer(peer: peerData)
|
||||
let shouldShowExcludePrivateIPs = (s.tunnelViewModel.peersData.count == 1 &&
|
||||
s.tunnelViewModel.peersData[0].shouldAllowExcludePrivateIPsControl)
|
||||
tableView.performBatchUpdates({
|
||||
s.tableView.deleteSections(removedSectionIndices, with: .automatic)
|
||||
if (shouldShowExcludePrivateIPs) {
|
||||
if let row = s.peerFields.firstIndex(of: .excludePrivateIPs) {
|
||||
let rowIndexPath = IndexPath(row: row, section: numberOfInterfaceSections /* First peer section */)
|
||||
s.tableView.insertRows(at: [rowIndexPath], with: .automatic)
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
return cell
|
||||
} else if (field == .excludePrivateIPs) {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelEditTableViewSwitchCell.id, for: indexPath) as! TunnelEditTableViewSwitchCell
|
||||
cell.message = field.rawValue
|
||||
cell.isEnabled = peerData.shouldAllowExcludePrivateIPsControl
|
||||
cell.isOn = peerData.excludePrivateIPsValue
|
||||
cell.onSwitchToggled = { [weak self] (isOn) in
|
||||
guard let s = self else { return }
|
||||
peerData.excludePrivateIPsValueChanged(isOn: isOn, dnsServers: s.tunnelViewModel.interfaceData[.dns])
|
||||
if let row = s.peerFields.firstIndex(of: .allowedIPs) {
|
||||
s.tableView.reloadRows(at: [IndexPath(row: row, section: section)], with: .none)
|
||||
}
|
||||
}
|
||||
return cell
|
||||
} else {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelEditTableViewKeyValueCell.id, for: indexPath) as! TunnelEditTableViewKeyValueCell
|
||||
// Set key
|
||||
cell.key = field.rawValue
|
||||
// Set placeholder text
|
||||
switch (field) {
|
||||
case .publicKey:
|
||||
cell.placeholderText = "Required"
|
||||
case .preSharedKey:
|
||||
cell.placeholderText = "Optional"
|
||||
case .endpoint:
|
||||
cell.placeholderText = "Optional"
|
||||
case .allowedIPs:
|
||||
cell.placeholderText = "Optional"
|
||||
case .persistentKeepAlive:
|
||||
cell.placeholderText = "Off"
|
||||
case .excludePrivateIPs: break
|
||||
case .deletePeer: break
|
||||
}
|
||||
// Set keyboardType
|
||||
if (field == .persistentKeepAlive) {
|
||||
cell.keyboardType = .numberPad
|
||||
} else if (field == .allowedIPs) {
|
||||
cell.keyboardType = .numbersAndPunctuation
|
||||
}
|
||||
// Show erroring fields
|
||||
cell.isValueValid = (!peerData.fieldsWithError.contains(field))
|
||||
// Bind values to view model
|
||||
cell.value = peerData[field]
|
||||
if (field != .allowedIPs) {
|
||||
cell.onValueChanged = { [weak peerData] value in
|
||||
peerData?[field] = value
|
||||
}
|
||||
}
|
||||
// Compute state of exclude private IPs live
|
||||
if (field == .allowedIPs) {
|
||||
cell.onValueBeingEdited = { [weak self, weak peerData] value in
|
||||
if let peerData = peerData, let s = self {
|
||||
let oldValue = peerData.shouldAllowExcludePrivateIPsControl
|
||||
peerData[.allowedIPs] = value
|
||||
if (oldValue != peerData.shouldAllowExcludePrivateIPsControl) {
|
||||
if let row = s.peerFields.firstIndex(of: .excludePrivateIPs) {
|
||||
if (peerData.shouldAllowExcludePrivateIPsControl) {
|
||||
s.tableView.insertRows(at: [IndexPath(row: row, section: section)], with: .automatic)
|
||||
} else {
|
||||
s.tableView.deleteRows(at: [IndexPath(row: row, section: section)], with: .automatic)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cell
|
||||
}
|
||||
} else if (section == (numberOfInterfaceSections + numberOfPeerSections)) {
|
||||
// Add peer
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelEditTableViewButtonCell.id, for: indexPath) as! TunnelEditTableViewButtonCell
|
||||
cell.buttonText = "Add peer"
|
||||
cell.onTapped = { [weak self] in
|
||||
guard let s = self else { return }
|
||||
let shouldHideExcludePrivateIPs = (s.tunnelViewModel.peersData.count == 1 &&
|
||||
s.tunnelViewModel.peersData[0].shouldAllowExcludePrivateIPsControl)
|
||||
let addedSectionIndices = s.appendEmptyPeer()
|
||||
tableView.performBatchUpdates({
|
||||
tableView.insertSections(addedSectionIndices, with: .automatic)
|
||||
if (shouldHideExcludePrivateIPs) {
|
||||
if let row = s.peerFields.firstIndex(of: .excludePrivateIPs) {
|
||||
let rowIndexPath = IndexPath(row: row, section: numberOfInterfaceSections /* First peer section */)
|
||||
s.tableView.deleteRows(at: [rowIndexPath], with: .automatic)
|
||||
}
|
||||
}
|
||||
}, completion: nil)
|
||||
}
|
||||
return cell
|
||||
} else {
|
||||
assert(section == (numberOfInterfaceSections + numberOfPeerSections + 1))
|
||||
if (row == 0) {
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelEditTableViewSwitchCell.id, for: indexPath) as! TunnelEditTableViewSwitchCell
|
||||
cell.message = "Activate on demand"
|
||||
cell.isOn = activateOnDemandSetting.isActivateOnDemandEnabled
|
||||
cell.onSwitchToggled = { [weak self] (isOn) in
|
||||
guard let s = self else { return }
|
||||
let indexPaths: [IndexPath] = (1 ..< 4).map { IndexPath(row: $0, section: section) }
|
||||
if (isOn) {
|
||||
s.activateOnDemandSetting.isActivateOnDemandEnabled = true
|
||||
if (s.activateOnDemandSetting.activateOnDemandOption == .none) {
|
||||
s.activateOnDemandSetting.activateOnDemandOption = TunnelViewModel.defaultActivateOnDemandOption()
|
||||
}
|
||||
s.tableView.insertRows(at: indexPaths, with: .automatic)
|
||||
} else {
|
||||
s.activateOnDemandSetting.isActivateOnDemandEnabled = false
|
||||
s.tableView.deleteRows(at: indexPaths, with: .automatic)
|
||||
}
|
||||
}
|
||||
return cell
|
||||
} else {
|
||||
assert(row < 4)
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelEditTableViewSelectionListCell.id, for: indexPath) as! TunnelEditTableViewSelectionListCell
|
||||
let rowOption = activateOnDemandOptions[row - 1]
|
||||
let selectedOption = activateOnDemandSetting.activateOnDemandOption
|
||||
assert(selectedOption != .none)
|
||||
cell.message = TunnelViewModel.activateOnDemandOptionText(for: rowOption)
|
||||
cell.isChecked = (selectedOption == rowOption)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func appendEmptyPeer() -> IndexSet {
|
||||
let numberOfInterfaceSections = interfaceFieldsBySection.count
|
||||
|
||||
tunnelViewModel.appendEmptyPeer()
|
||||
let addedPeerIndex = tunnelViewModel.peersData.count - 1
|
||||
|
||||
let addedSectionIndices = IndexSet(integer: (numberOfInterfaceSections + addedPeerIndex))
|
||||
return addedSectionIndices
|
||||
}
|
||||
|
||||
func deletePeer(peer: TunnelViewModel.PeerData) -> IndexSet {
|
||||
let numberOfInterfaceSections = interfaceFieldsBySection.count
|
||||
|
||||
assert(peer.index < tunnelViewModel.peersData.count)
|
||||
tunnelViewModel.deletePeer(peer: peer)
|
||||
|
||||
let removedSectionIndices = IndexSet(integer: (numberOfInterfaceSections + peer.index))
|
||||
return removedSectionIndices
|
||||
}
|
||||
|
||||
func showConfirmationAlert(message: String, buttonTitle: String, from sourceView: UIView,
|
||||
onConfirmed: @escaping (() -> Void)) {
|
||||
let destroyAction = UIAlertAction(title: buttonTitle, style: .destructive) { (_) in
|
||||
onConfirmed()
|
||||
}
|
||||
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel)
|
||||
let alert = UIAlertController(title: "", message: message, preferredStyle: .actionSheet)
|
||||
alert.addAction(destroyAction)
|
||||
alert.addAction(cancelAction)
|
||||
|
||||
// popoverPresentationController will be nil on iPhone and non-nil on iPad
|
||||
alert.popoverPresentationController?.sourceView = sourceView
|
||||
alert.popoverPresentationController?.sourceRect = sourceView.bounds
|
||||
|
||||
self.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UITableViewDelegate
|
||||
|
||||
extension TunnelEditTableViewController {
|
||||
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
||||
let numberOfInterfaceSections = interfaceFieldsBySection.count
|
||||
let numberOfPeerSections = tunnelViewModel.peersData.count
|
||||
|
||||
let section = indexPath.section
|
||||
let row = indexPath.row
|
||||
|
||||
if (section == (numberOfInterfaceSections + numberOfPeerSections + 1)) {
|
||||
return (row > 0) ? indexPath : nil
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
let numberOfInterfaceSections = interfaceFieldsBySection.count
|
||||
let numberOfPeerSections = tunnelViewModel.peersData.count
|
||||
|
||||
let section = indexPath.section
|
||||
let row = indexPath.row
|
||||
|
||||
assert(section == (numberOfInterfaceSections + numberOfPeerSections + 1))
|
||||
assert(row > 0)
|
||||
|
||||
let option = activateOnDemandOptions[row - 1]
|
||||
assert(option != .none)
|
||||
activateOnDemandSetting.activateOnDemandOption = option
|
||||
|
||||
let indexPaths: [IndexPath] = (1 ..< 4).map { IndexPath(row: $0, section: section) }
|
||||
tableView.reloadRows(at: indexPaths, with: .automatic)
|
||||
}
|
||||
}
|
||||
|
||||
class TunnelEditTableViewKeyValueCell: UITableViewCell {
|
||||
static let id: String = "TunnelEditTableViewKeyValueCell"
|
||||
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 isValueValid: Bool = true {
|
||||
didSet {
|
||||
if (isValueValid) {
|
||||
keyLabel.textColor = UIColor.black
|
||||
} else {
|
||||
keyLabel.textColor = UIColor.red
|
||||
}
|
||||
}
|
||||
}
|
||||
var keyboardType: UIKeyboardType {
|
||||
get { return valueTextField.keyboardType }
|
||||
set(value) { valueTextField.keyboardType = value }
|
||||
}
|
||||
|
||||
var onValueChanged: ((String) -> Void)?
|
||||
var onValueBeingEdited: ((String) -> Void)?
|
||||
|
||||
let keyLabel: UILabel
|
||||
let valueTextField: UITextField
|
||||
|
||||
private var textFieldValueOnBeginEditing: String = ""
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
keyLabel = UILabel()
|
||||
valueTextField = UITextField()
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
contentView.addSubview(keyLabel)
|
||||
keyLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
keyLabel.textAlignment = .right
|
||||
let widthRatioConstraint = NSLayoutConstraint(item: keyLabel, attribute: .width,
|
||||
relatedBy: .equal,
|
||||
toItem: self, attribute: .width,
|
||||
multiplier: 0.4, constant: 0)
|
||||
// The "Persistent Keepalive" key doesn't fit into 0.4 * width on the iPhone SE,
|
||||
// so set a CR priority > the 0.4-constraint's priority.
|
||||
widthRatioConstraint.priority = .defaultHigh + 1
|
||||
keyLabel.setContentCompressionResistancePriority(.defaultHigh + 2, for: .horizontal)
|
||||
NSLayoutConstraint.activate([
|
||||
keyLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
keyLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor),
|
||||
widthRatioConstraint
|
||||
])
|
||||
contentView.addSubview(valueTextField)
|
||||
valueTextField.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
valueTextField.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
valueTextField.leftAnchor.constraint(equalTo: keyLabel.rightAnchor, constant: 16),
|
||||
valueTextField.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor),
|
||||
])
|
||||
valueTextField.delegate = self
|
||||
|
||||
valueTextField.autocapitalizationType = .none
|
||||
valueTextField.autocorrectionType = .no
|
||||
valueTextField.spellCheckingType = .no
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
key = ""
|
||||
value = ""
|
||||
placeholderText = ""
|
||||
isValueValid = true
|
||||
keyboardType = .default
|
||||
onValueChanged = nil
|
||||
onValueBeingEdited = nil
|
||||
}
|
||||
}
|
||||
|
||||
extension TunnelEditTableViewKeyValueCell: UITextFieldDelegate {
|
||||
func textFieldDidBeginEditing(_ textField: UITextField) {
|
||||
textFieldValueOnBeginEditing = textField.text ?? ""
|
||||
isValueValid = true
|
||||
}
|
||||
func textFieldDidEndEditing(_ textField: UITextField) {
|
||||
let isModified = (textField.text ?? "" != textFieldValueOnBeginEditing)
|
||||
guard (isModified) else { return }
|
||||
if let onValueChanged = onValueChanged {
|
||||
onValueChanged(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
|
||||
}
|
||||
}
|
||||
|
||||
class TunnelEditTableViewReadOnlyKeyValueCell: CopyableLabelTableViewCell {
|
||||
static let id: String = "TunnelEditTableViewReadOnlyKeyValueCell"
|
||||
var key: String {
|
||||
get { return keyLabel.text ?? "" }
|
||||
set(value) {keyLabel.text = value }
|
||||
}
|
||||
var value: String {
|
||||
get { return valueLabel.text }
|
||||
set(value) { valueLabel.text = value }
|
||||
}
|
||||
|
||||
let keyLabel: UILabel
|
||||
let valueLabel: ScrollableLabel
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
keyLabel = UILabel()
|
||||
valueLabel = ScrollableLabel()
|
||||
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
keyLabel.textColor = UIColor.gray
|
||||
valueLabel.textColor = UIColor.gray
|
||||
|
||||
contentView.addSubview(keyLabel)
|
||||
keyLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
keyLabel.textAlignment = .right
|
||||
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,
|
||||
// so set a CR priority > the 0.4-constraint's priority.
|
||||
widthRatioConstraint.priority = .defaultHigh + 1
|
||||
keyLabel.setContentCompressionResistancePriority(.defaultHigh + 2, for: .horizontal)
|
||||
NSLayoutConstraint.activate([
|
||||
keyLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
keyLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor),
|
||||
widthRatioConstraint
|
||||
])
|
||||
|
||||
contentView.addSubview(valueLabel)
|
||||
valueLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
valueLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
valueLabel.leftAnchor.constraint(equalTo: keyLabel.rightAnchor, constant: 16),
|
||||
valueLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
override var textToCopy: String? {
|
||||
return self.valueLabel.text
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
key = ""
|
||||
value = ""
|
||||
}
|
||||
}
|
||||
|
||||
class TunnelEditTableViewButtonCell: UITableViewCell {
|
||||
static let id: String = "TunnelEditTableViewButtonCell"
|
||||
var buttonText: String {
|
||||
get { return button.title(for: .normal) ?? "" }
|
||||
set(value) { button.setTitle(value, for: .normal) }
|
||||
}
|
||||
var hasDestructiveAction: Bool {
|
||||
get { return button.tintColor == UIColor.red }
|
||||
set(value) { button.tintColor = value ? UIColor.red : buttonStandardTintColor }
|
||||
}
|
||||
var onTapped: (() -> Void)?
|
||||
|
||||
let button: UIButton
|
||||
var buttonStandardTintColor: UIColor
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
button = UIButton(type: .system)
|
||||
buttonStandardTintColor = button.tintColor
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
contentView.addSubview(button)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
button.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
class TunnelEditTableViewSwitchCell: UITableViewCell {
|
||||
static let id: String = "TunnelEditTableViewSwitchCell"
|
||||
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
|
||||
textLabel?.textColor = value ? UIColor.black : UIColor.gray
|
||||
}
|
||||
}
|
||||
|
||||
var onSwitchToggled: ((Bool) -> Void)?
|
||||
|
||||
let switchView: UISwitch
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
switchView = UISwitch()
|
||||
super.init(style: .default, reuseIdentifier: reuseIdentifier)
|
||||
accessoryView = switchView
|
||||
|
||||
switchView.addTarget(self, action: #selector(switchToggled), for: .valueChanged)
|
||||
}
|
||||
|
||||
@objc func switchToggled() {
|
||||
onSwitchToggled?(switchView.isOn)
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
message = ""
|
||||
isOn = false
|
||||
}
|
||||
}
|
||||
|
||||
class TunnelEditTableViewSelectionListCell: UITableViewCell {
|
||||
static let id: String = "TunnelEditTableViewSelectionListCell"
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,445 +0,0 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
import MobileCoreServices
|
||||
import UserNotifications
|
||||
|
||||
class TunnelsListTableViewController: UIViewController {
|
||||
|
||||
var tunnelsManager: TunnelsManager?
|
||||
|
||||
var busyIndicator: UIActivityIndicatorView?
|
||||
var centeredAddButton: BorderedTextButton?
|
||||
var tableView: UITableView?
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = UIColor.white
|
||||
|
||||
// Set up the navigation bar
|
||||
self.title = "WireGuard"
|
||||
let addButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped(sender:)))
|
||||
self.navigationItem.rightBarButtonItem = addButtonItem
|
||||
let settingsButtonItem = UIBarButtonItem(title: "Settings", style: .plain, target: self, action: #selector(settingsButtonTapped(sender:)))
|
||||
self.navigationItem.leftBarButtonItem = settingsButtonItem
|
||||
|
||||
// Set up the busy indicator
|
||||
let busyIndicator = UIActivityIndicatorView(style: .gray)
|
||||
busyIndicator.hidesWhenStopped = true
|
||||
|
||||
// Add the busyIndicator, centered
|
||||
view.addSubview(busyIndicator)
|
||||
busyIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
busyIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
busyIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
|
||||
])
|
||||
busyIndicator.startAnimating()
|
||||
self.busyIndicator = busyIndicator
|
||||
|
||||
// State restoration
|
||||
self.restorationIdentifier = "TunnelsListVC"
|
||||
}
|
||||
|
||||
func setTunnelsManager(tunnelsManager: TunnelsManager) {
|
||||
if (self.tunnelsManager != nil) {
|
||||
// If a tunnels manager is already set, do nothing
|
||||
return
|
||||
}
|
||||
|
||||
// Create the table view
|
||||
|
||||
let tableView = UITableView(frame: CGRect.zero, style: .plain)
|
||||
tableView.rowHeight = 60
|
||||
tableView.separatorStyle = .none
|
||||
tableView.register(TunnelsListTableViewCell.self, forCellReuseIdentifier: TunnelsListTableViewCell.id)
|
||||
|
||||
self.view.addSubview(tableView)
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
tableView.leftAnchor.constraint(equalTo: self.view.leftAnchor),
|
||||
tableView.rightAnchor.constraint(equalTo: self.view.rightAnchor),
|
||||
tableView.topAnchor.constraint(equalTo: self.view.topAnchor),
|
||||
tableView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor)
|
||||
])
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
self.tableView = tableView
|
||||
|
||||
// Add button at the center
|
||||
|
||||
let centeredAddButton = BorderedTextButton()
|
||||
centeredAddButton.title = "Add a tunnel"
|
||||
centeredAddButton.isHidden = true
|
||||
self.view.addSubview(centeredAddButton)
|
||||
centeredAddButton.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
centeredAddButton.centerXAnchor.constraint(equalTo: self.view.centerXAnchor),
|
||||
centeredAddButton.centerYAnchor.constraint(equalTo: self.view.centerYAnchor)
|
||||
])
|
||||
centeredAddButton.onTapped = { [weak self] in
|
||||
self?.addButtonTapped(sender: centeredAddButton)
|
||||
}
|
||||
centeredAddButton.isHidden = (tunnelsManager.numberOfTunnels() > 0)
|
||||
self.centeredAddButton = centeredAddButton
|
||||
|
||||
// Hide the busy indicator
|
||||
|
||||
self.busyIndicator?.stopAnimating()
|
||||
|
||||
// Keep track of the tunnels manager
|
||||
|
||||
self.tunnelsManager = tunnelsManager
|
||||
tunnelsManager.tunnelsListDelegate = self
|
||||
}
|
||||
|
||||
override func viewWillAppear(_: Bool) {
|
||||
// Remove selection when getting back to the list view on iPhone
|
||||
if let tableView = self.tableView, let selectedRowIndexPath = tableView.indexPathForSelectedRow {
|
||||
tableView.deselectRow(at: selectedRowIndexPath, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func addButtonTapped(sender: AnyObject) {
|
||||
if (self.tunnelsManager == nil) { return } // Do nothing until we've loaded the tunnels
|
||||
let alert = UIAlertController(title: "", message: "Add a new WireGuard tunnel", preferredStyle: .actionSheet)
|
||||
let importFileAction = UIAlertAction(title: "Create from file or archive", style: .default) { [weak self] (_) in
|
||||
self?.presentViewControllerForFileImport()
|
||||
}
|
||||
alert.addAction(importFileAction)
|
||||
|
||||
let scanQRCodeAction = UIAlertAction(title: "Create from QR code", style: .default) { [weak self] (_) in
|
||||
self?.presentViewControllerForScanningQRCode()
|
||||
}
|
||||
alert.addAction(scanQRCodeAction)
|
||||
|
||||
let createFromScratchAction = UIAlertAction(title: "Create from scratch", style: .default) { [weak self] (_) in
|
||||
if let s = self, let tunnelsManager = s.tunnelsManager {
|
||||
s.presentViewControllerForTunnelCreation(tunnelsManager: tunnelsManager, tunnelConfiguration: nil)
|
||||
}
|
||||
}
|
||||
alert.addAction(createFromScratchAction)
|
||||
|
||||
let cancelAction = UIAlertAction(title: "Cancel", style: .cancel)
|
||||
alert.addAction(cancelAction)
|
||||
|
||||
// popoverPresentationController will be nil on iPhone and non-nil on iPad
|
||||
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
|
||||
}
|
||||
self.present(alert, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@objc func settingsButtonTapped(sender: UIBarButtonItem!) {
|
||||
if (self.tunnelsManager == nil) { return } // Do nothing until we've loaded the tunnels
|
||||
let settingsVC = SettingsTableViewController(tunnelsManager: tunnelsManager)
|
||||
let settingsNC = UINavigationController(rootViewController: settingsVC)
|
||||
settingsNC.modalPresentationStyle = .formSheet
|
||||
self.present(settingsNC, animated: true)
|
||||
}
|
||||
|
||||
func presentViewControllerForTunnelCreation(tunnelsManager: TunnelsManager, tunnelConfiguration: TunnelConfiguration?) {
|
||||
let editVC = TunnelEditTableViewController(tunnelsManager: tunnelsManager, tunnelConfiguration: tunnelConfiguration)
|
||||
let editNC = UINavigationController(rootViewController: editVC)
|
||||
editNC.modalPresentationStyle = .formSheet
|
||||
self.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
|
||||
self.present(filePicker, animated: true)
|
||||
}
|
||||
|
||||
func presentViewControllerForScanningQRCode() {
|
||||
let scanQRCodeVC = QRScanViewController()
|
||||
scanQRCodeVC.delegate = self
|
||||
let scanQRCodeNC = UINavigationController(rootViewController: scanQRCodeVC)
|
||||
scanQRCodeNC.modalPresentationStyle = .fullScreen
|
||||
self.present(scanQRCodeNC, animated: true)
|
||||
}
|
||||
|
||||
func importFromFile(url: URL) {
|
||||
guard let tunnelsManager = tunnelsManager else { return }
|
||||
if (url.pathExtension == "zip") {
|
||||
ZipImporter.importConfigFiles(from: url) { [weak self] result in
|
||||
if let error = result.error {
|
||||
ErrorPresenter.showErrorAlert(error: error, from: self)
|
||||
return
|
||||
}
|
||||
let configs: [TunnelConfiguration?] = result.value!
|
||||
tunnelsManager.addMultiple(tunnelConfigurations: configs.compactMap { $0 }) { [weak self] (numberSuccessful) in
|
||||
if numberSuccessful == configs.count {
|
||||
return
|
||||
}
|
||||
ErrorPresenter.showErrorAlert(title: "Created \(numberSuccessful) tunnels",
|
||||
message: "Created \(numberSuccessful) of \(configs.count) tunnels from zip archive",
|
||||
from: self)
|
||||
}
|
||||
}
|
||||
} else /* if (url.pathExtension == "conf") -- we assume everything else is a conf */ {
|
||||
let fileBaseName = url.deletingPathExtension().lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let fileContents = try? String(contentsOf: url),
|
||||
let tunnelConfiguration = try? WgQuickConfigFileParser.parse(fileContents, name: fileBaseName) {
|
||||
tunnelsManager.add(tunnelConfiguration: tunnelConfiguration) { [weak self] result in
|
||||
if let error = result.error {
|
||||
ErrorPresenter.showErrorAlert(error: error, from: self)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ErrorPresenter.showErrorAlert(title: "Unable to import tunnel",
|
||||
message: "An error occured when importing the tunnel configuration.",
|
||||
from: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UIDocumentPickerDelegate
|
||||
|
||||
extension TunnelsListTableViewController: UIDocumentPickerDelegate {
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
urls.forEach(importFromFile)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: QRScanViewControllerDelegate
|
||||
|
||||
extension TunnelsListTableViewController: QRScanViewControllerDelegate {
|
||||
func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController,
|
||||
completionHandler: (() -> Void)?) {
|
||||
tunnelsManager?.add(tunnelConfiguration: tunnelConfiguration) { result in
|
||||
if let error = result.error {
|
||||
ErrorPresenter.showErrorAlert(error: error, from: qrScanViewController, onDismissal: completionHandler)
|
||||
} else {
|
||||
completionHandler?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UITableViewDataSource
|
||||
|
||||
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 = tableView.dequeueReusableCell(withIdentifier: TunnelsListTableViewCell.id, for: indexPath) as! TunnelsListTableViewCell
|
||||
if let tunnelsManager = tunnelsManager {
|
||||
let tunnel = tunnelsManager.tunnel(at: indexPath.row)
|
||||
cell.tunnel = tunnel
|
||||
cell.onSwitchToggled = { [weak self] isOn in
|
||||
guard let s = self, let tunnelsManager = s.tunnelsManager else { return }
|
||||
if (isOn) {
|
||||
tunnelsManager.startActivation(of: tunnel) { [weak s] error in
|
||||
if let error = error {
|
||||
ErrorPresenter.showErrorAlert(error: error, from: s, onPresented: {
|
||||
DispatchQueue.main.async {
|
||||
cell.statusSwitch.isOn = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tunnelsManager.startDeactivation(of: tunnel)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UITableViewDelegate
|
||||
|
||||
extension TunnelsListTableViewController: UITableViewDelegate {
|
||||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
guard let tunnelsManager = tunnelsManager else { return }
|
||||
let tunnel = tunnelsManager.tunnel(at: indexPath.row)
|
||||
let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager,
|
||||
tunnel: tunnel)
|
||||
let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC)
|
||||
tunnelDetailNC.restorationIdentifier = "DetailNC"
|
||||
showDetailViewController(tunnelDetailNC, sender: self) // Shall get propagated up to the split-vc
|
||||
}
|
||||
|
||||
func tableView(_ tableView: UITableView,
|
||||
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||
let deleteAction = UIContextualAction(style: .destructive, title: "Delete", handler: { [weak self] (_, _, completionHandler) in
|
||||
guard let tunnelsManager = self?.tunnelsManager else { return }
|
||||
let tunnel = tunnelsManager.tunnel(at: indexPath.row)
|
||||
tunnelsManager.remove(tunnel: tunnel, completionHandler: { (error) in
|
||||
if (error != nil) {
|
||||
ErrorPresenter.showErrorAlert(error: error!, from: self)
|
||||
completionHandler(false)
|
||||
} else {
|
||||
completionHandler(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
return UISwipeActionsConfiguration(actions: [deleteAction])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: TunnelsManagerDelegate
|
||||
|
||||
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(at oldIndex: Int, to newIndex: Int) {
|
||||
tableView?.moveRow(at: IndexPath(row: oldIndex, section: 0), to: IndexPath(row: newIndex, section: 0))
|
||||
}
|
||||
|
||||
func tunnelRemoved(at index: Int) {
|
||||
tableView?.deleteRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
|
||||
centeredAddButton?.isHidden = (tunnelsManager?.numberOfTunnels() ?? 0 > 0)
|
||||
}
|
||||
}
|
||||
|
||||
class TunnelsListTableViewCell: UITableViewCell {
|
||||
static let id: String = "TunnelsListTableViewCell"
|
||||
var tunnel: TunnelContainer? {
|
||||
didSet(value) {
|
||||
// Bind to the tunnel's name
|
||||
nameLabel.text = tunnel?.name ?? ""
|
||||
nameObservervationToken = tunnel?.observe(\.name) { [weak self] (tunnel, _) in
|
||||
self?.nameLabel.text = tunnel.name
|
||||
}
|
||||
// Bind to the tunnel's status
|
||||
update(from: tunnel?.status)
|
||||
statusObservervationToken = tunnel?.observe(\.status) { [weak self] (tunnel, _) in
|
||||
self?.update(from: tunnel.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
var onSwitchToggled: ((Bool) -> Void)?
|
||||
|
||||
let nameLabel: UILabel
|
||||
let busyIndicator: UIActivityIndicatorView
|
||||
let statusSwitch: UISwitch
|
||||
|
||||
private var statusObservervationToken: AnyObject?
|
||||
private var nameObservervationToken: AnyObject?
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
nameLabel = UILabel()
|
||||
busyIndicator = UIActivityIndicatorView(style: .gray)
|
||||
busyIndicator.hidesWhenStopped = true
|
||||
statusSwitch = UISwitch()
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
contentView.addSubview(statusSwitch)
|
||||
statusSwitch.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
statusSwitch.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
statusSwitch.rightAnchor.constraint(equalTo: contentView.rightAnchor, constant: -8)
|
||||
])
|
||||
contentView.addSubview(busyIndicator)
|
||||
busyIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
busyIndicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
busyIndicator.rightAnchor.constraint(equalTo: statusSwitch.leftAnchor, constant: -8)
|
||||
])
|
||||
contentView.addSubview(nameLabel)
|
||||
nameLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
nameLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
nameLabel.leftAnchor.constraint(equalTo: contentView.leftAnchor, constant: 16),
|
||||
nameLabel.rightAnchor.constraint(equalTo: busyIndicator.leftAnchor)
|
||||
])
|
||||
self.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.async { [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")
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
statusSwitch.isOn = false
|
||||
statusSwitch.isUserInteractionEnabled = false
|
||||
busyIndicator.stopAnimating()
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
class BorderedTextButton: UIView {
|
||||
let button: UIButton
|
||||
|
||||
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() {
|
||||
button = UIButton(type: .system)
|
||||
super.init(frame: CGRect.zero)
|
||||
addSubview(button)
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
button.centerXAnchor.constraint(equalTo: self.centerXAnchor),
|
||||
button.centerYAnchor.constraint(equalTo: self.centerYAnchor)
|
||||
])
|
||||
layer.borderWidth = 1
|
||||
layer.cornerRadius = 5
|
||||
layer.borderColor = button.tintColor.cgColor
|
||||
button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
|
||||
}
|
||||
|
||||
@objc func buttonTapped() {
|
||||
onTapped?()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
@@ -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 == .red }
|
||||
set(value) { button.tintColor = value ? .red : 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,220 @@
|
||||
// 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
|
||||
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
|
||||
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 isValueValid {
|
||||
keyLabel.textColor = .black
|
||||
} else {
|
||||
keyLabel.textColor = .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isStackedHorizontally = false
|
||||
var isStackedVertically = false
|
||||
var contentSizeBasedConstraints = [NSLayoutConstraint]()
|
||||
|
||||
var onValueChanged: ((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?(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,52 @@
|
||||
// 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
|
||||
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,48 @@
|
||||
// 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
|
||||
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,111 @@
|
||||
// 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 = {
|
||||
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")
|
||||
}
|
||||
|
||||
private func reset() {
|
||||
statusSwitch.isOn = false
|
||||
statusSwitch.isUserInteractionEnabled = false
|
||||
busyIndicator.stopAnimating()
|
||||
}
|
||||
|
||||
override func prepareForReuse() {
|
||||
super.prepareForReuse()
|
||||
reset()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
@@ -7,25 +7,23 @@ class MainViewController: UISplitViewController {
|
||||
|
||||
var tunnelsManager: TunnelsManager?
|
||||
var onTunnelsManagerReady: ((TunnelsManager) -> Void)?
|
||||
|
||||
var tunnelsListVC: TunnelsListTableViewController?
|
||||
|
||||
init() {
|
||||
let detailVC = UIViewController()
|
||||
detailVC.view.backgroundColor = UIColor.white
|
||||
detailVC.view.backgroundColor = .white
|
||||
let detailNC = UINavigationController(rootViewController: detailVC)
|
||||
|
||||
let masterVC = TunnelsListTableViewController()
|
||||
let masterNC = UINavigationController(rootViewController: masterVC)
|
||||
|
||||
self.tunnelsListVC = masterVC
|
||||
tunnelsListVC = masterVC
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
self.viewControllers = [ masterNC, detailNC ]
|
||||
viewControllers = [ masterNC, detailNC ]
|
||||
|
||||
// State restoration
|
||||
self.restorationIdentifier = "MainVC"
|
||||
restorationIdentifier = "MainVC"
|
||||
masterNC.restorationIdentifier = "MasterNC"
|
||||
detailNC.restorationIdentifier = "DetailNC"
|
||||
}
|
||||
@@ -35,35 +33,49 @@ class MainViewController: UISplitViewController {
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
self.delegate = self
|
||||
delegate = self
|
||||
|
||||
// On iPad, always show both masterVC and detailVC, even in portrait mode, like the Settings app
|
||||
self.preferredDisplayMode = .allVisible
|
||||
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 }
|
||||
|
||||
if let error = result.error {
|
||||
ErrorPresenter.showErrorAlert(error: error, from: self)
|
||||
return
|
||||
}
|
||||
let tunnelsManager: TunnelsManager = result.value!
|
||||
guard let s = self else { return }
|
||||
|
||||
s.tunnelsManager = tunnelsManager
|
||||
s.tunnelsListVC?.setTunnelsManager(tunnelsManager: tunnelsManager)
|
||||
self.tunnelsManager = tunnelsManager
|
||||
self.tunnelsListVC?.setTunnelsManager(tunnelsManager: tunnelsManager)
|
||||
|
||||
tunnelsManager.activationDelegate = s
|
||||
tunnelsManager.activationDelegate = self
|
||||
|
||||
s.onTunnelsManagerReady?(tunnelsManager)
|
||||
s.onTunnelsManagerReady = nil
|
||||
self.onTunnelsManagerReady?(tunnelsManager)
|
||||
self.onTunnelsManagerReady = nil
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension MainViewController: TunnelsManagerActivationDelegate {
|
||||
func tunnelActivationFailed(tunnel: TunnelContainer, error: TunnelsManagerError) {
|
||||
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 {
|
||||
@@ -74,13 +86,13 @@ extension MainViewController {
|
||||
}
|
||||
|
||||
func showTunnelDetailForTunnel(named tunnelName: String, animated: Bool) {
|
||||
let showTunnelDetailBlock: (TunnelsManager) -> Void = { [weak self] (tunnelsManager) in
|
||||
let showTunnelDetailBlock: (TunnelsManager) -> Void = { [weak self] tunnelsManager in
|
||||
if let tunnel = tunnelsManager.tunnel(named: tunnelName) {
|
||||
let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager, tunnel: tunnel)
|
||||
let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC)
|
||||
tunnelDetailNC.restorationIdentifier = "DetailNC"
|
||||
if let self = self {
|
||||
if (animated) {
|
||||
if animated {
|
||||
self.showDetailViewController(tunnelDetailNC, sender: self)
|
||||
} else {
|
||||
UIView.performWithoutAnimation {
|
||||
@@ -1,8 +1,7 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018 WireGuard LLC. All Rights Reserved.
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import AVFoundation
|
||||
import CoreData
|
||||
import UIKit
|
||||
|
||||
protocol QRScanViewControllerDelegate: class {
|
||||
@@ -18,29 +17,29 @@ class QRScanViewController: UIViewController {
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
self.title = "Scan QR code"
|
||||
self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped))
|
||||
title = tr("scanQRCodeViewTitle")
|
||||
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped))
|
||||
|
||||
let tipLabel = UILabel()
|
||||
tipLabel.text = "Tip: Generate with `qrencode -t ansiutf8 < tunnel.conf`"
|
||||
tipLabel.text = tr("scanQRCodeTipText")
|
||||
tipLabel.adjustsFontSizeToFitWidth = true
|
||||
tipLabel.textColor = UIColor.lightGray
|
||||
tipLabel.textColor = .lightGray
|
||||
tipLabel.textAlignment = .center
|
||||
|
||||
view.addSubview(tipLabel)
|
||||
tipLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
tipLabel.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor),
|
||||
tipLabel.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor),
|
||||
tipLabel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -32),
|
||||
])
|
||||
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: "Camera Unsupported", message: "This device is not able to scan QR codes")
|
||||
scanDidEncounterError(title: tr("alertScanQRCodeCameraUnsupportedTitle"), message: tr("alertScanQRCodeCameraUnsupportedMessage"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -77,15 +76,11 @@ class QRScanViewController: UIViewController {
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
if let connection = previewLayer?.connection {
|
||||
|
||||
let currentDevice: UIDevice = UIDevice.current
|
||||
|
||||
let orientation: UIDeviceOrientation = currentDevice.orientation
|
||||
|
||||
let previewLayerConnection: AVCaptureConnection = connection
|
||||
let currentDevice = UIDevice.current
|
||||
let orientation = currentDevice.orientation
|
||||
let previewLayerConnection = connection
|
||||
|
||||
if previewLayerConnection.isVideoOrientationSupported {
|
||||
|
||||
switch orientation {
|
||||
case .portrait:
|
||||
previewLayerConnection.videoOrientation = .portrait
|
||||
@@ -102,39 +97,38 @@ class QRScanViewController: UIViewController {
|
||||
}
|
||||
}
|
||||
|
||||
previewLayer?.frame = self.view.bounds
|
||||
previewLayer?.frame = view.bounds
|
||||
}
|
||||
|
||||
func scanDidComplete(withCode code: String) {
|
||||
let scannedTunnelConfiguration = try? WgQuickConfigFileParser.parse(code, name: "Scanned")
|
||||
let scannedTunnelConfiguration = try? TunnelConfiguration(fromWgQuickConfig: code, called: "Scanned")
|
||||
guard let tunnelConfiguration = scannedTunnelConfiguration else {
|
||||
scanDidEncounterError(title: "Invalid QR Code", message: "The scanned QR code is not a valid WireGuard configuration")
|
||||
scanDidEncounterError(title: tr("alertScanQRCodeInvalidQRCodeTitle"), message: tr("alertScanQRCodeInvalidQRCodeMessage"))
|
||||
return
|
||||
}
|
||||
|
||||
let alert = UIAlertController(title: NSLocalizedString("Please name the scanned tunnel", comment: ""), message: nil, preferredStyle: .alert)
|
||||
let alert = UIAlertController(title: tr("alertScanQRCodeNamePromptTitle"), message: nil, preferredStyle: .alert)
|
||||
alert.addTextField(configurationHandler: nil)
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel, handler: { [weak self] _ in
|
||||
alert.addAction(UIAlertAction(title: tr("actionCancel"), style: .cancel) { [weak self] _ in
|
||||
self?.dismiss(animated: true, completion: nil)
|
||||
}))
|
||||
alert.addAction(UIAlertAction(title: NSLocalizedString("Save", comment: ""), style: .default, handler: { [weak self] _ in
|
||||
let title = alert.textFields?[0].text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if (title.isEmpty) { return }
|
||||
tunnelConfiguration.interface.name = title
|
||||
if let s = self {
|
||||
s.delegate?.addScannedQRCode(tunnelConfiguration: tunnelConfiguration, qrScanViewController: s) {
|
||||
s.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: "OK", style: .default, handler: { [weak self] _ in
|
||||
alertController.addAction(UIAlertAction(title: tr("actionOK"), style: .default) { [weak self] _ in
|
||||
self?.dismiss(animated: true, completion: nil)
|
||||
}))
|
||||
})
|
||||
present(alertController, animated: true)
|
||||
captureSession = nil
|
||||
}
|
||||
@@ -151,7 +145,7 @@ extension QRScanViewController: AVCaptureMetadataOutputObjectsDelegate {
|
||||
guard let metadataObject = metadataObjects.first,
|
||||
let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject,
|
||||
let stringValue = readableObject.stringValue else {
|
||||
scanDidEncounterError(title: "Invalid Code", message: "The scanned code could not be read")
|
||||
scanDidEncounterError(title: tr("alertScanQRCodeUnreadableQRCodeTitle"), message: tr("alertScanQRCodeUnreadableQRCodeMessage"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
import os.log
|
||||
|
||||
class SettingsTableViewController: UITableViewController {
|
||||
|
||||
enum SettingsFields {
|
||||
case iosAppVersion
|
||||
case goBackendVersion
|
||||
case exportZipArchive
|
||||
case exportLogFile
|
||||
|
||||
var localizedUIString: String {
|
||||
switch self {
|
||||
case .iosAppVersion: return tr("settingsVersionKeyWireGuardForIOS")
|
||||
case .goBackendVersion: return tr("settingsVersionKeyWireGuardGoBackend")
|
||||
case .exportZipArchive: return tr("settingsExportZipButtonTitle")
|
||||
case .exportLogFile: return tr("settingsExportLogFileButtonTitle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let settingsFieldsBySection: [[SettingsFields]] = [
|
||||
[.iosAppVersion, .goBackendVersion],
|
||||
[.exportZipArchive],
|
||||
[.exportLogFile]
|
||||
]
|
||||
|
||||
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 exportLogForLastActivatedTunnel(sourceView: UIView) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
guard let networkExtensionLogFilePath = FileManager.networkExtensionLogFileURL?.path else {
|
||||
ErrorPresenter.showErrorAlert(title: tr("alertUnableToFindExtensionLogPathTitle"), message: tr("alertUnableToFindExtensionLogPathMessage"), from: self)
|
||||
return
|
||||
}
|
||||
|
||||
let isWritten = Logger.global?.writeLog(called: "APP", mergedWith: networkExtensionLogFilePath, called: "NET", 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)
|
||||
activityVC.popoverPresentationController?.sourceView = sourceView
|
||||
activityVC.popoverPresentationController?.sourceRect = sourceView.bounds
|
||||
activityVC.completionWithItemsHandler = { _, _, _, _ in
|
||||
// Remove the exported log file after the activity has completed
|
||||
_ = FileManager.deleteFile(at: destinationURL)
|
||||
}
|
||||
self.present(activityVC, 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 = WIREGUARD_GO_VERSION
|
||||
}
|
||||
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 {
|
||||
assert(field == .exportLogFile)
|
||||
let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.buttonText = field.localizedUIString
|
||||
cell.onTapped = { [weak self] in
|
||||
self?.exportLogForLastActivatedTunnel(sourceView: cell.button)
|
||||
}
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
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
|
||||
]
|
||||
|
||||
let tunnelsManager: TunnelsManager
|
||||
let tunnel: TunnelContainer
|
||||
var tunnelViewModel: TunnelViewModel
|
||||
|
||||
private var sections = [Section]()
|
||||
private var interfaceFieldIsVisible = [Bool]()
|
||||
private var peerFieldIsVisible = [[Bool]]()
|
||||
|
||||
private var statusObservationToken: AnyObject?
|
||||
private var reloadRuntimeConfigurationTimer: Timer?
|
||||
|
||||
init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) {
|
||||
self.tunnelsManager = tunnelsManager
|
||||
self.tunnel = tunnel
|
||||
tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.allowsSelection = false
|
||||
tableView.register(SwitchCell.self)
|
||||
tableView.register(KeyValueCell.self)
|
||||
tableView.register(ButtonCell.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 = .formSheet
|
||||
self.present(editNC, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
func showConfirmationAlert(message: String, buttonTitle: String, from sourceView: UIView, 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)
|
||||
|
||||
alert.popoverPresentationController?.sourceView = sourceView
|
||||
alert.popoverPresentationController?.sourceRect = sourceView.bounds
|
||||
|
||||
present(alert, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
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 sectionChanged<T>(fields: [T], fieldIsVisible fieldIsVisibleInput: [Bool], tableView: UITableView, section: Int, changes: [T: TunnelViewModel.ChangeHandlers.FieldChange]) {
|
||||
var fieldIsVisible = fieldIsVisibleInput
|
||||
var modifiedIndexPaths = [IndexPath]()
|
||||
for (index, field) in fields.enumerated() where changes[field] == .modified {
|
||||
let row = fieldIsVisible[0 ..< index].filter { $0 }.count
|
||||
modifiedIndexPaths.append(IndexPath(row: row, section: section))
|
||||
}
|
||||
if !modifiedIndexPaths.isEmpty {
|
||||
tableView.reloadRows(at: modifiedIndexPaths, with: .none)
|
||||
}
|
||||
|
||||
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 changeHandlers = TunnelViewModel.ChangeHandlers(
|
||||
interfaceChanged: { changes in
|
||||
sectionChanged(fields: TunnelDetailTableViewController.interfaceFields, fieldIsVisible: interfaceFieldIsVisible,
|
||||
tableView: tableView, section: interfaceSectionIndex, changes: changes)
|
||||
},
|
||||
peerChangedAt: { peerIndex, changes in
|
||||
sectionChanged(fields: TunnelDetailTableViewController.peerFields, fieldIsVisible: peerFieldIsVisible[peerIndex],
|
||||
tableView: tableView, section: firstPeerSectionIndex + peerIndex, changes: changes)
|
||||
},
|
||||
peersRemovedAt: { peerIndices in
|
||||
let sectionIndices = peerIndices.map { firstPeerSectionIndex + $0 }
|
||||
tableView.deleteSections(IndexSet(sectionIndices), with: .automatic)
|
||||
},
|
||||
peersInsertedAt: { peerIndices in
|
||||
let sectionIndices = peerIndices.map { firstPeerSectionIndex + $0 }
|
||||
tableView.insertSections(IndexSet(sectionIndices), with: .automatic)
|
||||
}
|
||||
)
|
||||
|
||||
tableView.beginUpdates()
|
||||
self.tunnelViewModel.applyConfiguration(other: tunnelConfiguration, changeHandlers: changeHandlers)
|
||||
self.loadSections()
|
||||
self.loadVisibleFields()
|
||||
tableView.endUpdates()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TunnelDetailTableViewController: TunnelEditTableViewControllerDelegate {
|
||||
func tunnelSaved(tunnel: TunnelContainer) {
|
||||
tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration)
|
||||
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 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 {
|
||||
cell.value = peerData[field]
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
private func onDemandCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.key = tr("tunnelOnDemandKey")
|
||||
cell.value = TunnelViewModel.activateOnDemandDetailText(for: tunnel.activateOnDemandSetting)
|
||||
cell.observationToken = tunnel.observe(\.isActivateOnDemandEnabled) { [weak cell] tunnel, _ in
|
||||
cell?.value = TunnelViewModel.activateOnDemandDetailText(for: tunnel.activateOnDemandSetting)
|
||||
}
|
||||
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 }
|
||||
self.showConfirmationAlert(message: tr("deleteTunnelConfirmationAlertMessage"), buttonTitle: tr("deleteTunnelConfirmationAlertButtonTitle"), from: cell) { [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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
|
||||
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 activateOnDemandOptions: [ActivateOnDemandOption] = [
|
||||
.useOnDemandOverWiFiOrCellular,
|
||||
.useOnDemandOverWiFiOnly,
|
||||
.useOnDemandOverCellularOnly
|
||||
]
|
||||
|
||||
let tunnelsManager: TunnelsManager
|
||||
let tunnel: TunnelContainer?
|
||||
let tunnelViewModel: TunnelViewModel
|
||||
var activateOnDemandSetting: ActivateOnDemandSetting
|
||||
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)
|
||||
activateOnDemandSetting = tunnel.activateOnDemandSetting
|
||||
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)
|
||||
activateOnDemandSetting = ActivateOnDemandSetting.defaultSetting
|
||||
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(CheckmarkCell.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):
|
||||
if let tunnel = tunnel {
|
||||
// We're modifying an existing tunnel
|
||||
tunnelsManager.modify(tunnel: tunnel, tunnelConfiguration: tunnelConfiguration, activateOnDemandSetting: activateOnDemandSetting) { [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, activateOnDemandSetting: activateOnDemandSetting) { [weak self] result in
|
||||
if let error = result.error {
|
||||
ErrorPresenter.showErrorAlert(error: error, from: self)
|
||||
} else {
|
||||
let tunnel: TunnelContainer = result.value!
|
||||
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 activateOnDemandSetting.isActivateOnDemandEnabled {
|
||||
return 4
|
||||
} else {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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] = Curve25519.generatePrivateKey().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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
} 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 }
|
||||
self.showConfirmationAlert(message: tr("deletePeerConfirmationAlertMessage"), buttonTitle: tr("deletePeerConfirmationAlertButtonTitle"), from: cell) { [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 {
|
||||
if indexPath.row == 0 {
|
||||
let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
cell.message = tr("tunnelOnDemandKey")
|
||||
cell.isOn = activateOnDemandSetting.isActivateOnDemandEnabled
|
||||
cell.onSwitchToggled = { [weak self] isOn in
|
||||
guard let self = self else { return }
|
||||
guard isOn != self.activateOnDemandSetting.isActivateOnDemandEnabled else { return }
|
||||
|
||||
self.activateOnDemandSetting.isActivateOnDemandEnabled = isOn
|
||||
self.loadSections()
|
||||
|
||||
let section = self.sections.firstIndex { $0 == .onDemand }!
|
||||
let indexPaths = (1 ..< 4).map { IndexPath(row: $0, section: section) }
|
||||
if isOn {
|
||||
if self.activateOnDemandSetting.activateOnDemandOption == .none {
|
||||
self.activateOnDemandSetting.activateOnDemandOption = TunnelViewModel.defaultActivateOnDemandOption()
|
||||
}
|
||||
self.tableView.insertRows(at: indexPaths, with: .fade)
|
||||
} else {
|
||||
self.tableView.deleteRows(at: indexPaths, with: .fade)
|
||||
}
|
||||
}
|
||||
return cell
|
||||
} else {
|
||||
let cell: CheckmarkCell = tableView.dequeueReusableCell(for: indexPath)
|
||||
let rowOption = activateOnDemandOptions[indexPath.row - 1]
|
||||
let selectedOption = activateOnDemandSetting.activateOnDemandOption
|
||||
assert(selectedOption != .none)
|
||||
cell.message = TunnelViewModel.activateOnDemandOptionText(for: rowOption)
|
||||
cell.isChecked = selectedOption == rowOption
|
||||
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)
|
||||
}
|
||||
|
||||
func showConfirmationAlert(message: String, buttonTitle: String, from sourceView: UIView, 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)
|
||||
|
||||
alert.popoverPresentationController?.sourceView = sourceView
|
||||
alert.popoverPresentationController?.sourceRect = sourceView.bounds
|
||||
|
||||
present(alert, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension TunnelEditTableViewController {
|
||||
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
||||
if case .onDemand = sections[indexPath.section], indexPath.row > 0 {
|
||||
return indexPath
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
switch sections[indexPath.section] {
|
||||
case .onDemand:
|
||||
let option = activateOnDemandOptions[indexPath.row - 1]
|
||||
assert(option != .none)
|
||||
activateOnDemandSetting.activateOnDemandOption = option
|
||||
|
||||
let indexPaths = (1 ..< 4).map { IndexPath(row: $0, section: indexPath.section) }
|
||||
UIView.performWithoutAnimation {
|
||||
tableView.reloadRows(at: indexPaths, with: .none)
|
||||
}
|
||||
default:
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import UIKit
|
||||
import MobileCoreServices
|
||||
import UserNotifications
|
||||
|
||||
class TunnelsListTableViewController: UIViewController {
|
||||
|
||||
var tunnelsManager: TunnelsManager?
|
||||
|
||||
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 = {
|
||||
let busyIndicator = UIActivityIndicatorView(style: .gray)
|
||||
busyIndicator.hidesWhenStopped = true
|
||||
return busyIndicator
|
||||
}()
|
||||
|
||||
var detailDisplayedTunnel: TunnelContainer?
|
||||
|
||||
override func loadView() {
|
||||
view = UIView()
|
||||
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()
|
||||
|
||||
title = tr("tunnelsListTitle")
|
||||
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:)))
|
||||
|
||||
restorationIdentifier = "TunnelsListVC"
|
||||
}
|
||||
|
||||
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 = .formSheet
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
extension TunnelsListTableViewController: UIDocumentPickerDelegate {
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||
guard let tunnelsManager = tunnelsManager else { return }
|
||||
urls.forEach { url in
|
||||
TunnelImporter.importFromFile(url: url, into: tunnelsManager, sourceVC: self, errorPresenterType: ErrorPresenter.self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TunnelsListTableViewController: QRScanViewControllerDelegate {
|
||||
func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController,
|
||||
completionHandler: (() -> Void)?) {
|
||||
tunnelsManager?.add(tunnelConfiguration: tunnelConfiguration) { result in
|
||||
if let error = result.error {
|
||||
ErrorPresenter.showErrorAlert(error: error, from: qrScanViewController, onDismissal: completionHandler)
|
||||
} else {
|
||||
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 let tunnelsManager = tunnelsManager else { return }
|
||||
let tunnel = tunnelsManager.tunnel(at: indexPath.row)
|
||||
let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager,
|
||||
tunnel: tunnel)
|
||||
let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC)
|
||||
tunnelDetailNC.restorationIdentifier = "DetailNC"
|
||||
showDetailViewController(tunnelDetailNC, sender: self) // Shall get propagated up to the split-vc
|
||||
detailDisplayedTunnel = tunnel
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.$(APP_ID)</string>
|
||||
</array>
|
||||
<string>group.$(APP_ID_IOS)</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Cocoa
|
||||
|
||||
@NSApplicationMain
|
||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
var tunnelsManager: TunnelsManager?
|
||||
var tunnelsTracker: TunnelsTracker?
|
||||
var statusItemController: StatusItemController?
|
||||
|
||||
var manageTunnelsRootVC: ManageTunnelsRootViewController?
|
||||
var manageTunnelsWindowObject: NSWindow?
|
||||
var isTerminationAlertShown = false
|
||||
|
||||
func applicationDidFinishLaunching(_ aNotification: Notification) {
|
||||
Logger.configureGlobal(withFilePath: FileManager.appLogFileURL?.path)
|
||||
|
||||
TunnelsManager.create { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
if let error = result.error {
|
||||
ErrorPresenter.showErrorAlert(error: error, from: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let tunnelsManager: TunnelsManager = result.value!
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
guard let currentTunnel = tunnelsTracker?.currentTunnel, currentTunnel.status == .active || currentTunnel.status == .activating else {
|
||||
return .terminateNow
|
||||
}
|
||||
if isTerminationAlertShown {
|
||||
return .terminateNow
|
||||
}
|
||||
let alert = NSAlert()
|
||||
alert.messageText = tr("macAppExitingWithActiveTunnelMessage")
|
||||
alert.informativeText = tr("macAppExitingWithActiveTunnelInfo")
|
||||
if let window = manageTunnelsWindowObject {
|
||||
alert.beginSheetModal(for: window) { [weak self] _ in
|
||||
self?.isTerminationAlertShown = true
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
return .terminateCancel
|
||||
} else {
|
||||
alert.runModal()
|
||||
return .terminateNow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppDelegate: StatusMenuWindowDelegate {
|
||||
func manageTunnelsWindow() -> NSWindow {
|
||||
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
|
||||
}
|
||||
return manageTunnelsWindowObject!
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
|
||||
|
||||
import Cocoa
|
||||
|
||||
class Application: NSApplication {
|
||||
|
||||
private let characterKeyCommands = [
|
||||
"x": #selector(NSText.cut(_:)),
|
||||
"c": #selector(NSText.copy(_:)),
|
||||
"v": #selector(NSText.paste(_:)),
|
||||
"z": #selector(UndoActionRespondable.undo(_:)),
|
||||
"a": #selector(NSResponder.selectAll(_:)),
|
||||
"Z": #selector(UndoActionRespondable.redo(_:)),
|
||||
"w": #selector(NSWindow.performClose(_:)),
|
||||
"m": #selector(NSWindow.performMiniaturize(_:)),
|
||||
"q": #selector(NSApplication.terminate(_:))
|
||||
]
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
override func sendEvent(_ event: NSEvent) {
|
||||
let modifierFlags = event.modifierFlags.rawValue & NSEvent.ModifierFlags.deviceIndependentFlagsMask.rawValue
|
||||
|
||||
if event.type == .keyDown,
|
||||
(modifierFlags == NSEvent.ModifierFlags.command.rawValue || modifierFlags == NSEvent.ModifierFlags.command.rawValue | NSEvent.ModifierFlags.shift.rawValue),
|
||||
let selector = characterKeyCommands[event.charactersIgnoringModifiers ?? ""] {
|
||||
sendAction(selector, to: nil, from: self)
|
||||
} else {
|
||||
super.sendEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc protocol UndoActionRespondable {
|
||||
func undo(_ sender: AnyObject)
|
||||
func redo(_ sender: AnyObject)
|
||||
}
|
||||
@@ -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: 104 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 995 B |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 2.1 KiB |