Move all source files to Sources/ and rename WireGuardKit targets

Signed-off-by: Andrej Mihajlov <and@mullvad.net>
This commit is contained in:
Andrej Mihajlov
2020-12-02 12:27:39 +01:00
parent 9c38a1b897
commit ec57408570
209 changed files with 54 additions and 58 deletions
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import Foundation
import os.log
extension FileManager {
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 {
wg_log(.error, message: "Cannot obtain shared folder URL")
return nil
}
return sharedFolderURL
}
static var logFileURL: URL? {
return sharedFolderURL?.appendingPathComponent("tunnel-log.bin")
}
static var networkExtensionLastErrorFileURL: URL? {
return sharedFolderURL?.appendingPathComponent("last-error.txt")
}
static func deleteFile(at url: URL) -> Bool {
do {
try FileManager.default.removeItem(at: url)
} catch {
return false
}
return true
}
}
+117
View File
@@ -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) != errSecItemNotFound
}
}
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import Foundation
import os.log
import WireGuardKit
public class Logger {
enum LoggerError: Error {
case openFailure
}
static var global: Logger?
var log: OpaquePointer
var tag: String
init(tagged tag: String, withFilePath filePath: String) throws {
guard let log = open_log(filePath) else { throw LoggerError.openFailure }
self.log = log
self.tag = tag
}
deinit {
close_log(self.log)
}
func log(message: String) {
write_msg_to_log(log, tag, message.trimmingCharacters(in: .newlines))
}
func writeLog(to targetFile: String) -> Bool {
return write_log_to_file(targetFile, self.log) == 0
}
static func configureGlobal(tagged tag: String, 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(tagged: tag, 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))"
}
Logger.global?.log(message: "App version: \(appVersion); Go backend version: \(wireGuardVersion)")
}
}
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)
}
+173
View File
@@ -0,0 +1,173 @@
/* SPDX-License-Identifier: MIT
*
* Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
*/
#include <string.h>
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <stdatomic.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 = 2048,
MAGIC = 0xabadbeefU
};
struct log_line {
atomic_uint_fast64_t time_ns;
char line[MAX_LOG_LINE_LENGTH];
};
struct log {
atomic_uint_fast32_t next_index;
struct log_line lines[MAX_LINES];
uint32_t magic;
};
void write_msg_to_log(struct log *log, const char *tag, const char *msg)
{
uint32_t index;
struct log_line *line;
struct timespec ts;
// Race: This isn't synchronized with the fetch_add below, so items might be slightly out of order.
clock_gettime(CLOCK_REALTIME, &ts);
// Race: More than MAX_LINES writers and this will clash.
index = atomic_fetch_add(&log->next_index, 1);
line = &log->lines[index % MAX_LINES];
// Race: Before this line executes, we'll display old data after new data.
atomic_store(&line->time_ns, 0);
memset(line->line, 0, MAX_LOG_LINE_LENGTH);
snprintf(line->line, MAX_LOG_LINE_LENGTH, "[%s] %s", tag, msg);
atomic_store(&line->time_ns, ts.tv_sec * 1000000000ULL + ts.tv_nsec);
msync(&log->next_index, sizeof(log->next_index), MS_ASYNC);
msync(line, sizeof(*line), MS_ASYNC);
}
int write_log_to_file(const char *file_name, const struct log *input_log)
{
struct log *log;
uint32_t l, i;
FILE *file;
int ret;
log = malloc(sizeof(*log));
if (!log)
return -errno;
memcpy(log, input_log, sizeof(*log));
file = fopen(file_name, "w");
if (!file) {
free(log);
return -errno;
}
for (l = 0, i = log->next_index; l < MAX_LINES; ++l, ++i) {
const struct log_line *line = &log->lines[i % MAX_LINES];
time_t seconds = line->time_ns / 1000000000ULL;
uint32_t useconds = (line->time_ns % 1000000000ULL) / 1000ULL;
struct tm tm;
if (!line->time_ns)
continue;
if (!localtime_r(&seconds, &tm))
goto err;
if (fprintf(file, "%04d-%02d-%02d %02d:%02d:%02d.%06d: %s\n",
tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
tm.tm_hour, tm.tm_min, tm.tm_sec, useconds,
line->line) < 0)
goto err;
}
errno = 0;
err:
ret = -errno;
fclose(file);
free(log);
return ret;
}
uint32_t view_lines_from_cursor(const struct log *input_log, uint32_t cursor, void *ctx, void(*cb)(const char *, uint64_t, void *))
{
struct log *log;
uint32_t l, i = cursor;
log = malloc(sizeof(*log));
if (!log)
return cursor;
memcpy(log, input_log, sizeof(*log));
if (i == -1)
i = log->next_index;
for (l = 0; l < MAX_LINES; ++l, ++i) {
const struct log_line *line = &log->lines[i % MAX_LINES];
if (cursor != -1 && i % MAX_LINES == log->next_index % MAX_LINES)
break;
if (!line->time_ns) {
if (cursor == -1)
continue;
else
break;
}
cb(line->line, line->time_ns, ctx);
cursor = (i + 1) % MAX_LINES;
}
free(log);
return cursor;
}
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));
}
+18
View File
@@ -0,0 +1,18 @@
/* SPDX-License-Identifier: MIT
*
* Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
*/
#ifndef RINGLOGGER_H
#define RINGLOGGER_H
#include <stdint.h>
struct log;
void write_msg_to_log(struct log *log, const char *tag, const char *msg);
int write_log_to_file(const char *file_name, const struct log *input_log);
uint32_t view_lines_from_cursor(const struct log *input_log, uint32_t cursor, void *ctx, void(*)(const char *, uint64_t, void *));
struct log *open_log(const char *file_name);
void close_log(struct log *log);
#endif
+63
View File
@@ -0,0 +1,63 @@
#include "ringlogger.h"
#include <stdio.h>
#include <stdbool.h>
#include <string.h>
#include <unistd.h>
#include <inttypes.h>
#include <sys/wait.h>
static void forkwrite(void)
{
struct log *log = open_log("/tmp/test_log");
char c[512];
int i, base;
bool in_fork = !fork();
base = 10000 * in_fork;
for (i = 0; i < 1024; ++i) {
snprintf(c, 512, "bla bla bla %d", base + i);
write_msg_to_log(log, "HMM", c);
}
if (in_fork)
_exit(0);
wait(NULL);
write_log_to_file("/dev/stdout", log);
close_log(log);
}
static void writetext(const char *text)
{
struct log *log = open_log("/tmp/test_log");
write_msg_to_log(log, "TXT", text);
close_log(log);
}
static void show_line(const char *line, uint64_t time_ns)
{
printf("%" PRIu64 ": %s\n", time_ns, line);
}
static void follow(void)
{
uint32_t cursor = -1;
struct log *log = open_log("/tmp/test_log");
for (;;) {
cursor = view_lines_from_cursor(log, cursor, show_line);
usleep(1000 * 300);
}
}
int main(int argc, char *argv[])
{
if (!strcmp(argv[1], "fork"))
forkwrite();
else if (!strcmp(argv[1], "write"))
writetext(argv[2]);
else if (!strcmp(argv[1], "follow"))
follow();
return 0;
}
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import NetworkExtension
import WireGuardKit
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
}
#if os(macOS)
providerConfiguration = ["UID": getuid()]
#endif
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? {
if let passwordReference = passwordReference,
let config = Keychain.openReference(called: passwordReference) {
return try? TunnelConfiguration(fromWgQuickConfig: config, called: name)
}
if let oldConfig = providerConfiguration?["WgQuickConfig"] as? String {
return try? TunnelConfiguration(fromWgQuickConfig: oldConfig, called: name)
}
return nil
}
func destroyConfigurationReference() {
guard let ref = passwordReference else { return }
Keychain.deleteReference(called: ref)
}
func verifyConfigurationReference() -> Bool {
guard let ref = passwordReference else { return false }
return Keychain.verifyReference(called: ref)
}
@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.
*/
if let oldConfig = providerConfiguration?["WgQuickConfig"] as? String {
#if os(macOS)
providerConfiguration = ["UID": getuid()]
#elseif os(iOS)
providerConfiguration = nil
#else
#error("Unimplemented")
#endif
guard passwordReference == nil else { return true }
wg_log(.debug, message: "Migrating tunnel configuration '\(name)'")
passwordReference = Keychain.makeReference(containing: oldConfig, called: name)
return true
}
#if os(macOS)
if passwordReference != nil && providerConfiguration?["UID"] == nil && verifyConfigurationReference() {
providerConfiguration = ["UID": getuid()]
return true
}
#endif
return false
}
}
@@ -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,248 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import Foundation
import WireGuardKit
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 { $0.isNewline }
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: .whitespacesAndNewlines)
let lowercasedLine = trimmedLine.lowercased()
if !trimmedLine.isEmpty {
if let equalsIndex = trimmedLine.firstIndex(of: "=") {
// Line contains an attribute
let keyWithCase = trimmedLine[..<equalsIndex].trimmingCharacters(in: .whitespacesAndNewlines)
let key = keyWithCase.lowercased()
let value = trimmedLine[trimmedLine.index(equalsIndex, offsetBy: 1)...].trimmingCharacters(in: .whitespacesAndNewlines)
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<PublicKey>(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"
output.append("PrivateKey = \(interface.privateKey.base64Key)\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")
output.append("PublicKey = \(peer.publicKey.base64Key)\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 = PrivateKey(base64Key: privateKeyString) 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: .whitespacesAndNewlines) {
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: .whitespacesAndNewlines) {
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 = PublicKey(base64Key: publicKeyString) else {
throw ParseError.peerHasInvalidPublicKey(publicKeyString)
}
var peer = PeerConfiguration(publicKey: publicKey)
if let preSharedKeyString = attributes["presharedkey"] {
guard let preSharedKey = PreSharedKey(base64Key: preSharedKeyString) 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,7 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
// iOS permission prompts
NSCameraUsageDescription = "Camera is used for scanning QR codes for importing WireGuard configurations";
NSFaceIDUsageDescription = "Face ID is used for authenticating viewing and exporting of private keys";
@@ -0,0 +1,445 @@
// 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";
"tunnelsListSelectButtonTitle" = "Select";
"tunnelsListSelectAllButtonTitle" = "Select All";
"tunnelsListDeleteButtonTitle" = "Delete";
"tunnelsListSelectedTitle (%d)" = "%d selected";
// 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
"alertImportedFromMultipleFilesTitle (%d)" = "Created %d tunnels";
"alertImportedFromMultipleFilesMessage (%1$d of %2$d)" = "Created %1$d of %2$d tunnels from imported files";
"alertImportedFromZipTitle (%d)" = "Created %d tunnels";
"alertImportedFromZipMessage (%1$d of %2$d)" = "Created %1$d of %2$d tunnels from zip archive";
"alertBadConfigImportTitle" = "Unable to import tunnel";
"alertBadConfigImportMessage (%@)" = "The file %@ does not contain a valid WireGuard configuration";
"deleteTunnelsConfirmationAlertButtonTitle" = "Delete";
"deleteTunnelConfirmationAlertButtonMessage (%d)" = "Delete %d tunnel?";
"deleteTunnelsConfirmationAlertButtonMessage (%d)" = "Delete %d tunnels?";
// 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";
"macToggleStatusButtonActivate" = "Activate";
"macToggleStatusButtonActivating" = "Activating…";
"macToggleStatusButtonDeactivate" = "Deactivate";
"macToggleStatusButtonDeactivating" = "Deactivating…";
"macToggleStatusButtonReasserting" = "Reactivating…";
"macToggleStatusButtonRestarting" = "Restarting…";
"macToggleStatusButtonWaiting" = "Waiting…";
"tunnelSectionTitleInterface" = "Interface";
"tunnelInterfaceName" = "Name";
"tunnelInterfacePrivateKey" = "Private key";
"tunnelInterfacePublicKey" = "Public key";
"tunnelInterfaceGenerateKeypair" = "Generate keypair";
"tunnelInterfaceAddresses" = "Addresses";
"tunnelInterfaceListenPort" = "Listen port";
"tunnelInterfaceMTU" = "MTU";
"tunnelInterfaceDNS" = "DNS servers";
"tunnelInterfaceStatus" = "Status";
"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";
"tunnelOnDemandCellular" = "Cellular";
"tunnelOnDemandEthernet" = "Ethernet";
"tunnelOnDemandWiFi" = "Wi-Fi";
"tunnelOnDemandSSIDsKey" = "SSIDs";
"tunnelOnDemandAnySSID" = "Any SSID";
"tunnelOnDemandOnlyTheseSSIDs" = "Only these SSIDs";
"tunnelOnDemandExceptTheseSSIDs" = "Except these SSIDs";
"tunnelOnDemandOnlySSID (%d)" = "Only %d SSID";
"tunnelOnDemandOnlySSIDs (%d)" = "Only %d SSIDs";
"tunnelOnDemandExceptSSID (%d)" = "Except %d SSID";
"tunnelOnDemandExceptSSIDs (%d)" = "Except %d SSIDs";
"tunnelOnDemandSSIDOptionDescriptionMac (%1$@: %2$@)" = "%1$@: %2$@";
"tunnelOnDemandSSIDViewTitle" = "SSIDs";
"tunnelOnDemandSectionTitleSelectedSSIDs" = "SSIDs";
"tunnelOnDemandNoSSIDs" = "No SSIDs";
"tunnelOnDemandSectionTitleAddSSIDs" = "Add SSIDs";
"tunnelOnDemandAddMessageAddConnectedSSID (%@)" = "Add connected: %@";
"tunnelOnDemandAddMessageAddNewSSID" = "Add new";
"tunnelOnDemandKey" = "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";
"tunnelPeerPresharedKeyEnabled" = "enabled";
// 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" = "Interfaces private key is required";
"alertInvalidInterfaceMessagePrivateKeyInvalid" = "Interfaces 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" = "Interfaces listen port must be between 0 and 65535, or unspecified";
"alertInvalidInterfaceMessageMTUInvalid" = "Interfaces MTU must be between 576 and 65535, or unspecified";
"alertInvalidInterfaceMessageDNSInvalid" = "Interfaces 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" = "Peers public key is required";
"alertInvalidPeerMessagePublicKeyInvalid" = "Peers public key must be a 32-byte key in base64 encoding";
"alertInvalidPeerMessagePreSharedKeyInvalid" = "Peers preshared key must be a 32-byte key in base64 encoding";
"alertInvalidPeerMessageAllowedIPsInvalid" = "Peers allowed IPs must be a list of comma-separated IP addresses, optionally in CIDR notation";
"alertInvalidPeerMessageEndpointInvalid" = "Peers endpoint must be of the form host:port or [host]:port";
"alertInvalidPeerMessagePersistentKeepaliveInvalid" = "Peers 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" = "Log";
"settingsViewLogButtonTitle" = "View log";
// Log view
"logViewTitle" = "Log";
// Log alerts
"alertUnableToRemovePreviousLogTitle" = "Log export failed";
"alertUnableToRemovePreviousLogMessage" = "The pre-existing log could not be cleared";
"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.";
// Conf import error alerts
"alertCantOpenInputConfFileTitle" = "Unable to import from file";
"alertCantOpenInputConfFileMessage (%@)" = "The file %@ could not be read.";
// 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 / main menu
"macMenuNetworks (%@)" = "Networks: %@";
"macMenuNetworksNone" = "Networks: None";
"macMenuTitle" = "WireGuard";
"macMenuManageTunnels" = "Manage Tunnels";
"macMenuImportTunnels" = "Import Tunnel(s) from File…";
"macMenuAddEmptyTunnel" = "Add Empty Tunnel…";
"macMenuViewLog" = "View Log";
"macMenuExportTunnels" = "Export Tunnels to Zip…";
"macMenuAbout" = "About WireGuard";
"macMenuQuit" = "Quit WireGuard";
"macMenuHideApp" = "Hide WireGuard";
"macMenuHideOtherApps" = "Hide Others";
"macMenuShowAllApps" = "Show All";
"macMenuFile" = "File";
"macMenuCloseWindow" = "Close Window";
"macMenuEdit" = "Edit";
"macMenuCut" = "Cut";
"macMenuCopy" = "Copy";
"macMenuPaste" = "Paste";
"macMenuSelectAll" = "Select All";
"macMenuTunnel" = "Tunnel";
"macMenuToggleStatus" = "Toggle Status";
"macMenuEditTunnel" = "Edit…";
"macMenuDeleteSelected" = "Delete Selected";
"macMenuWindow" = "Window";
"macMenuMinimize" = "Minimize";
"macMenuZoom" = "Zoom";
// Mac manage tunnels window
"macWindowTitleManageTunnels" = "Manage WireGuard Tunnels";
"macDeleteTunnelConfirmationAlertMessage (%@)" = "Are you sure you want to delete %@?";
"macDeleteMultipleTunnelsConfirmationAlertMessage (%d)" = "Are you sure you want to delete %d tunnels?";
"macDeleteTunnelConfirmationAlertInfo" = "You cannot undo this action.";
"macDeleteTunnelConfirmationAlertButtonTitleDelete" = "Delete";
"macDeleteTunnelConfirmationAlertButtonTitleCancel" = "Cancel";
"macDeleteTunnelConfirmationAlertButtonTitleDeleting" = "Deleting…";
"macButtonImportTunnels" = "Import tunnel(s) from file";
"macSheetButtonImport" = "Import";
"macNameFieldExportLog" = "Save log to:";
"macSheetButtonExportLog" = "Save";
"macNameFieldExportZip" = "Export tunnels to:";
"macSheetButtonExportZip" = "Save";
"macButtonDeleteTunnels (%d)" = "Delete %d tunnels";
"macButtonEdit" = "Edit";
// Mac detail/edit view fields
"macFieldKey (%@)" = "%@:";
"macFieldOnDemand" = "On-Demand:";
"macFieldOnDemandSSIDs" = "SSIDs:";
// 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
"macConfirmAndQuitAlertMessage" = "Do you want to close the tunnels manager or quit WireGuard entirely?";
"macConfirmAndQuitAlertInfo" = "If you close the tunnels manager, WireGuard will continue to be available from the menu bar icon.";
"macConfirmAndQuitInfoWithActiveTunnel (%@)" = "If you close the tunnels manager, WireGuard will continue to be available from the menu bar icon.\n\nNote that if you quit WireGuard entirely the currently active tunnel ('%@') will still remain active until you deactivate it from this application or through the Network panel in System Preferences.";
"macConfirmAndQuitAlertQuitWireGuard" = "Quit WireGuard";
"macConfirmAndQuitAlertCloseWindow" = "Close Tunnels Manager";
"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.";
// Mac tooltip
"macToolTipEditTunnel" = "Edit tunnel (⌘E)";
"macToolTipToggleStatus" = "Toggle status (⌘T)";
// Mac log view
"macLogColumnTitleTime" = "Time";
"macLogColumnTitleLogMessage" = "Log message";
"macLogButtonTitleClose" = "Close";
"macLogButtonTitleSave" = "Save…";
// Mac unusable tunnel view
"macUnusableTunnelMessage" = "The configuration for this tunnel cannot be found in the keychain.";
"macUnusableTunnelInfo" = "In case this tunnel was created by another user, only that user can view, edit, or activate this tunnel.";
"macUnusableTunnelButtonTitleDeleteTunnel" = "Delete tunnel";
// Mac App Store updating alert
"macAppStoreUpdatingAlertMessage" = "App Store would like to update WireGuard";
"macAppStoreUpdatingAlertInfoWithOnDemand (%@)" = "Please disable on-demand for tunnel %@, deactivate it, and then continue updating in App Store.";
"macAppStoreUpdatingAlertInfoWithoutOnDemand (%@)" = "Please deactivate tunnel %@ and then continue updating in App Store.";
// Donation
"donateLink" = "♥ Donate to the WireGuard Project";
@@ -0,0 +1,2 @@
#include "Version.xcconfig"
#include "Developer.xcconfig"
@@ -0,0 +1,10 @@
// Developer.xcconfig
// You Apple developer account's Team ID
DEVELOPMENT_TEAM = <team_id>
// The bundle identifier of the apps.
// Should be an app id created at developer.apple.com
// with Network Extensions capabilty.
APP_ID_IOS = <app_id>
APP_ID_MACOS = <app_id>
@@ -0,0 +1,2 @@
VERSION_NAME = 0.0.20200127
VERSION_ID = 17
@@ -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)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 953 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,133 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import NetworkExtension
enum ActivateOnDemandOption: Equatable {
case off
case wiFiInterfaceOnly(ActivateOnDemandSSIDOption)
case nonWiFiInterfaceOnly
case anyInterface(ActivateOnDemandSSIDOption)
}
#if os(iOS)
private let nonWiFiInterfaceType: NEOnDemandRuleInterfaceType = .cellular
#elseif os(macOS)
private let nonWiFiInterfaceType: NEOnDemandRuleInterfaceType = .ethernet
#else
#error("Unimplemented")
#endif
enum ActivateOnDemandSSIDOption: Equatable {
case anySSID
case onlySpecificSSIDs([String])
case exceptSpecificSSIDs([String])
}
extension ActivateOnDemandOption {
func apply(on tunnelProviderManager: NETunnelProviderManager) {
let rules: [NEOnDemandRule]?
switch self {
case .off:
rules = nil
case .wiFiInterfaceOnly(let ssidOption):
rules = ssidOnDemandRules(option: ssidOption) + [NEOnDemandRuleDisconnect(interfaceType: nonWiFiInterfaceType)]
case .nonWiFiInterfaceOnly:
rules = [NEOnDemandRuleConnect(interfaceType: nonWiFiInterfaceType), NEOnDemandRuleDisconnect(interfaceType: .wiFi)]
case .anyInterface(let ssidOption):
if case .anySSID = ssidOption {
rules = [NEOnDemandRuleConnect(interfaceType: .any)]
} else {
rules = ssidOnDemandRules(option: ssidOption) + [NEOnDemandRuleConnect(interfaceType: nonWiFiInterfaceType)]
}
}
tunnelProviderManager.onDemandRules = rules
tunnelProviderManager.isOnDemandEnabled = self != .off
}
init(from tunnelProviderManager: NETunnelProviderManager) {
if tunnelProviderManager.isOnDemandEnabled, let onDemandRules = tunnelProviderManager.onDemandRules {
self = ActivateOnDemandOption.create(from: onDemandRules)
} else {
self = .off
}
}
private static func create(from rules: [NEOnDemandRule]) -> ActivateOnDemandOption {
switch rules.count {
case 0:
return .off
case 1:
let rule = rules[0]
guard rule.action == .connect else { return .off }
return .anyInterface(.anySSID)
case 2:
guard let connectRule = rules.first(where: { $0.action == .connect }) else {
wg_log(.error, message: "Unexpected onDemandRules set on tunnel provider manager: \(rules.count) rules found but no connect rule.")
return .off
}
guard let disconnectRule = rules.first(where: { $0.action == .disconnect }) else {
wg_log(.error, message: "Unexpected onDemandRules set on tunnel provider manager: \(rules.count) rules found but no disconnect rule.")
return .off
}
if connectRule.interfaceTypeMatch == .wiFi && disconnectRule.interfaceTypeMatch == nonWiFiInterfaceType {
return .wiFiInterfaceOnly(.anySSID)
} else if connectRule.interfaceTypeMatch == nonWiFiInterfaceType && disconnectRule.interfaceTypeMatch == .wiFi {
return .nonWiFiInterfaceOnly
} else {
wg_log(.error, message: "Unexpected onDemandRules set on tunnel provider manager: \(rules.count) rules found but interface types are inconsistent.")
return .off
}
case 3:
guard let ssidRule = rules.first(where: { $0.interfaceTypeMatch == .wiFi && $0.ssidMatch != nil }) else { return .off }
guard let nonWiFiRule = rules.first(where: { $0.interfaceTypeMatch == nonWiFiInterfaceType }) else { return .off }
let ssids = ssidRule.ssidMatch!
switch (ssidRule.action, nonWiFiRule.action) {
case (.connect, .connect):
return .anyInterface(.onlySpecificSSIDs(ssids))
case (.connect, .disconnect):
return .wiFiInterfaceOnly(.onlySpecificSSIDs(ssids))
case (.disconnect, .connect):
return .anyInterface(.exceptSpecificSSIDs(ssids))
case (.disconnect, .disconnect):
return .wiFiInterfaceOnly(.exceptSpecificSSIDs(ssids))
default:
wg_log(.error, message: "Unexpected onDemandRules set on tunnel provider manager: \(rules.count) rules found")
return .off
}
default:
wg_log(.error, message: "Unexpected number of onDemandRules set on tunnel provider manager: \(rules.count) rules found")
return .off
}
}
}
private extension NEOnDemandRuleConnect {
convenience init(interfaceType: NEOnDemandRuleInterfaceType, ssids: [String]? = nil) {
self.init()
interfaceTypeMatch = interfaceType
ssidMatch = ssids
}
}
private extension NEOnDemandRuleDisconnect {
convenience init(interfaceType: NEOnDemandRuleInterfaceType, ssids: [String]? = nil) {
self.init()
interfaceTypeMatch = interfaceType
ssidMatch = ssids
}
}
private func ssidOnDemandRules(option: ActivateOnDemandSSIDOption) -> [NEOnDemandRule] {
switch option {
case .anySSID:
return [NEOnDemandRuleConnect(interfaceType: .wiFi)]
case .onlySpecificSSIDs(let ssids):
assert(!ssids.isEmpty)
return [NEOnDemandRuleConnect(interfaceType: .wiFi, ssids: ssids),
NEOnDemandRuleDisconnect(interfaceType: .wiFi)]
case .exceptSpecificSSIDs(let ssids):
return [NEOnDemandRuleDisconnect(interfaceType: .wiFi, ssids: ssids),
NEOnDemandRuleConnect(interfaceType: .wiFi)]
}
}
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import NetworkExtension
import WireGuardKit
// 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: PrivateKey())
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: PrivateKey().publicKey)
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,185 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import Foundation
import WireGuardKit
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<PublicKey>(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 = PrivateKey(hexKey: privateKeyString) 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 = PublicKey(hexKey: publicKeyString) else {
throw ParseError.peerHasInvalidPublicKey(publicKeyString)
}
var peer = PeerConfiguration(publicKey: publicKey)
if let preSharedKeyString = attributes["preshared_key"] {
guard let preSharedKey = PreSharedKey(hexKey: preSharedKeyString) else {
throw ParseError.peerHasInvalidPreSharedKey(preSharedKeyString)
}
// TODO(zx2c4): does the compiler optimize this away?
var accumulator: UInt8 = 0
for index in 0..<preSharedKey.rawValue.count {
accumulator |= preSharedKey.rawValue[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,63 @@
// 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
@unknown default:
fatalError()
}
}
}
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"
@unknown default:
fatalError()
}
}
}
@@ -0,0 +1,807 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import Foundation
import NetworkExtension
import os.log
import WireGuardKit
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 {
fileprivate var tunnels: [TunnelContainer]
weak var tunnelsListDelegate: TunnelsManagerListDelegate?
weak var activationDelegate: TunnelsManagerActivationDelegate?
private var statusObservationToken: AnyObject?
private var waiteeObservationToken: AnyObject?
private var configurationsObservationToken: AnyObject?
private var catalinaWorkaround: Any?
init(tunnelProviders: [NETunnelProviderManager]) {
tunnels = tunnelProviders.map { TunnelContainer(tunnel: $0) }.sorted { TunnelsManager.tunnelNameIsLessThan($0.name, $1.name) }
startObservingTunnelStatuses()
startObservingTunnelConfigurations()
#if os(macOS)
if #available(macOS 10.15, *) {
self.catalinaWorkaround = CatalinaWorkaround(tunnelsManager: self)
}
#endif
}
static func create(completionHandler: @escaping (Result<TunnelsManager, TunnelsManagerError>) -> 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> = []
var tunnelNames: Set<String> = []
for (index, tunnelManager) in tunnelManagers.enumerated().reversed() {
if let tunnelName = tunnelManager.localizedDescription {
tunnelNames.insert(tunnelName)
}
guard let proto = tunnelManager.protocolConfiguration as? NETunnelProviderProtocol else { continue }
if proto.migrateConfigurationIfNeeded(called: tunnelManager.localizedDescription ?? "unknown") {
tunnelManager.saveToPreferences { _ in }
}
#if os(iOS)
let passwordRef = proto.verifyConfigurationReference() ? proto.passwordReference : nil
#elseif os(macOS)
let passwordRef: Data?
if proto.providerConfiguration?["UID"] as? uid_t == getuid() {
passwordRef = proto.verifyConfigurationReference() ? proto.passwordReference : nil
} else {
passwordRef = proto.passwordReference // To handle multiple users in macOS, we skip verifying
}
#else
#error("Unimplemented")
#endif
if let ref = passwordRef {
refs.insert(ref)
} else {
wg_log(.info, message: "Removing orphaned tunnel with non-verifying keychain entry: \(tunnelManager.localizedDescription ?? "<unknown>")")
tunnelManager.removeFromPreferences { _ in }
tunnelManagers.remove(at: index)
}
}
#if os(macOS)
if #available(macOS 10.15, *) {
// Don't delete orphaned keychain refs. We need them to restore tunnels as a workaround.
} else {
Keychain.deleteReferences(except: refs)
}
#else
Keychain.deleteReferences(except: refs)
#endif
#if os(iOS)
RecentTunnelsTracker.cleanupTunnels(except: tunnelNames)
#endif
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.isEquivalentTo(currentTunnel) }) {
// 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: { loadedTunnelProvider.isEquivalentTo($0) }) {
matchingTunnel.tunnelProvider = loadedTunnelProvider
matchingTunnel.refreshStatus()
} else {
// Tunnel was added outside the app
if let proto = loadedTunnelProvider.protocolConfiguration as? NETunnelProviderProtocol {
if proto.migrateConfigurationIfNeeded(called: loadedTunnelProvider.localizedDescription ?? "unknown") {
loadedTunnelProvider.saveToPreferences { _ in }
}
}
let tunnel = TunnelContainer(tunnel: loadedTunnelProvider)
self.tunnels.append(tunnel)
self.tunnels.sort { TunnelsManager.tunnelNameIsLessThan($0.name, $1.name) }
self.tunnelsListDelegate?.tunnelAdded(at: self.tunnels.firstIndex(of: tunnel)!)
}
}
}
}
func add(tunnelConfiguration: TunnelConfiguration, onDemandOption: ActivateOnDemandOption = .off, completionHandler: @escaping (Result<TunnelContainer, TunnelsManagerError>) -> 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
onDemandOption.apply(on: tunnelProviderManager)
let activeTunnel = tunnels.first { $0.status == .active || $0.status == .activating }
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 }
#if os(iOS)
// HACK: In iOS, adding a tunnel causes deactivation of any currently active tunnel.
// This is an ugly hack to reactivate the tunnel that has been deactivated like that.
if let activeTunnel = activeTunnel {
if activeTunnel.status == .inactive || activeTunnel.status == .deactivating {
self.startActivation(of: activeTunnel)
}
if activeTunnel.status == .active || activeTunnel.status == .activating {
activeTunnel.status = .restarting
}
}
#endif
let tunnel = TunnelContainer(tunnel: tunnelProviderManager)
self.tunnels.append(tunnel)
self.tunnels.sort { TunnelsManager.tunnelNameIsLessThan($0.name, $1.name) }
self.tunnelsListDelegate?.tunnelAdded(at: self.tunnels.firstIndex(of: tunnel)!)
completionHandler(.success(tunnel))
}
}
func addMultiple(tunnelConfigurations: [TunnelConfiguration], completionHandler: @escaping (UInt, TunnelsManagerError?) -> Void) {
addMultiple(tunnelConfigurations: ArraySlice(tunnelConfigurations), numberSuccessful: 0, lastError: nil, completionHandler: completionHandler)
}
private func addMultiple(tunnelConfigurations: ArraySlice<TunnelConfiguration>, numberSuccessful: UInt, lastError: TunnelsManagerError?, completionHandler: @escaping (UInt, TunnelsManagerError?) -> Void) {
guard let head = tunnelConfigurations.first else {
completionHandler(numberSuccessful, lastError)
return
}
let tail = tunnelConfigurations.dropFirst()
add(tunnelConfiguration: head) { [weak self, tail] result in
DispatchQueue.main.async {
var numberSuccessfulCount = numberSuccessful
var lastError: TunnelsManagerError?
switch result {
case .failure(let error):
lastError = error
case .success:
numberSuccessfulCount = numberSuccessful + 1
}
self?.addMultiple(tunnelConfigurations: tail, numberSuccessful: numberSuccessfulCount, lastError: lastError, completionHandler: completionHandler)
}
}
}
func modify(tunnel: TunnelContainer, tunnelConfiguration: TunnelConfiguration, onDemandOption: ActivateOnDemandOption, completionHandler: @escaping (TunnelsManagerError?) -> Void) {
let tunnelName = tunnelConfiguration.name ?? ""
if tunnelName.isEmpty {
completionHandler(TunnelsManagerError.tunnelNameEmpty)
return
}
let tunnelProviderManager = tunnel.tunnelProvider
let oldName = tunnelProviderManager.localizedDescription ?? ""
let isNameChanged = tunnelName != oldName
if isNameChanged {
guard !tunnels.contains(where: { $0.name == tunnelName }) else {
completionHandler(TunnelsManagerError.tunnelAlreadyExistsWithThatName)
return
}
tunnel.name = tunnelName
}
var isTunnelConfigurationChanged = false
if tunnelProviderManager.tunnelConfiguration != tunnelConfiguration {
tunnelProviderManager.setTunnelConfiguration(tunnelConfiguration)
isTunnelConfigurationChanged = true
}
tunnelProviderManager.isEnabled = true
let isActivatingOnDemand = !tunnelProviderManager.isOnDemandEnabled && onDemandOption != .off
onDemandOption.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 { TunnelsManager.tunnelNameIsLessThan($0.name, $1.name) }
let newIndex = self.tunnels.firstIndex(of: tunnel)!
self.tunnelsListDelegate?.tunnelMoved(from: oldIndex, to: newIndex)
#if os(iOS)
RecentTunnelsTracker.handleTunnelRenamed(oldName: oldName, newName: tunnelName)
#endif
}
self.tunnelsListDelegate?.tunnelModified(at: self.tunnels.firstIndex(of: tunnel)!)
if isTunnelConfigurationChanged {
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
#if os(macOS)
if tunnel.isTunnelAvailableToUser {
(tunnelProviderManager.protocolConfiguration as? NETunnelProviderProtocol)?.destroyConfigurationReference()
}
#elseif os(iOS)
(tunnelProviderManager.protocolConfiguration as? NETunnelProviderProtocol)?.destroyConfigurationReference()
#else
#error("Unimplemented")
#endif
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)
#if os(iOS)
RecentTunnelsTracker.handleTunnelRemoved(tunnelName: tunnel.name)
#endif
}
}
func removeMultiple(tunnels: [TunnelContainer], completionHandler: @escaping (TunnelsManagerError?) -> Void) {
removeMultiple(tunnels: ArraySlice(tunnels), completionHandler: completionHandler)
}
private func removeMultiple(tunnels: ArraySlice<TunnelContainer>, completionHandler: @escaping (TunnelsManagerError?) -> Void) {
guard let head = tunnels.first else {
completionHandler(nil)
return
}
let tail = tunnels.dropFirst()
remove(tunnel: head) { [weak self, tail] error in
DispatchQueue.main.async {
if let error = error {
completionHandler(error)
} else {
self?.removeMultiple(tunnels: tail, completionHandler: completionHandler)
}
}
}
}
func numberOfTunnels() -> Int {
return tunnels.count
}
func tunnel(at index: Int) -> TunnelContainer {
return tunnels[index]
}
func mapTunnels<T>(transform: (TunnelContainer) throws -> T) rethrows -> [T] {
return try tunnels.map(transform)
}
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
#if os(iOS)
RecentTunnelsTracker.handleTunnelActivated(tunnelName: tunnel.name)
#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 tunnel = self.tunnels.first(where: { $0.tunnelProvider == tunnelProvider }) else { 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()
}
}
}
static func tunnelNameIsLessThan(_ a: String, _ b: String) -> Bool {
return a.compare(b, options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive, .numeric]) == .orderedAscending
}
}
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 onDemandOption: ActivateOnDemandOption {
return ActivateOnDemandOption(from: tunnelProvider)
}
#if os(macOS)
var isTunnelAvailableToUser: Bool {
return (tunnelProvider.protocolConfiguration as? NETunnelProviderProtocol)?.providerConfiguration?["UID"] as? uid_t == getuid()
}
#endif
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([ UInt8(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 {
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 {
fileprivate 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)
}
func isEquivalentTo(_ tunnel: TunnelContainer) -> Bool {
return localizedDescription == tunnel.name && tunnelConfiguration == tunnel.tunnelConfiguration
}
}
#if os(macOS)
@available(macOS 10.15, *)
class CatalinaWorkaround {
// In macOS Catalina, for some users, the tunnels get deleted arbitrarily
// by the OS. It's not clear what triggers that.
// As a workaround, in macOS Catalina, when we realize that tunnels have been
// deleted outside the app, we reinstate those tunnels using the information
// in the keychain.
unowned let tunnelsManager: TunnelsManager
private var configChangeSubscriber: Any?
struct ReinstationData {
let tunnelConfiguration: TunnelConfiguration
let keychainPasswordRef: Data
}
init(tunnelsManager: TunnelsManager) {
self.tunnelsManager = tunnelsManager
// Attempt reinstation when there's a change in tunnel configurations,
// which indicates that tunnels may have been deleted outside the app.
// We use debounce to wait for all change notifications to arrive
// before attempting to reinstate, so that we don't have saveToPreferences
// being called while another saveToPreferences is in progress.
self.configChangeSubscriber = NotificationCenter.default
.publisher(for: .NEVPNConfigurationChange, object: nil)
.debounce(for: .seconds(1), scheduler: RunLoop.main)
.subscribe(on: RunLoop.main)
.sink { [weak self] _ in
self?.reinstateTunnelsDeletedOutsideApp()
}
// Attempt reinstation on app launch
reinstateTunnelsDeletedOutsideApp()
}
func reinstateTunnelsDeletedOutsideApp() {
let rd = reinstationDataForTunnelsDeletedOutsideApp()
reinstateTunnels(ArraySlice(rd), completionHandler: nil)
}
private func reinstateTunnels(_ rdArray: ArraySlice<ReinstationData>, completionHandler: (() -> Void)?) {
guard let head = rdArray.first else {
completionHandler?()
return
}
let tail = rdArray.dropFirst()
self.tunnelsManager.reinstateTunnel(reinstationData: head) { _ in
DispatchQueue.main.async {
self.reinstateTunnels(tail, completionHandler: completionHandler)
}
}
}
private func reinstationDataForTunnelsDeletedOutsideApp() -> [ReinstationData] {
let knownRefs: [Data] = self.tunnelsManager.tunnels
.compactMap { $0.tunnelProvider.protocolConfiguration as? NETunnelProviderProtocol }
.compactMap { $0.passwordReference }
let knownRefsSet: Set<Data> = Set(knownRefs)
var result: CFTypeRef?
let ret = SecItemCopyMatching([kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: Bundle.main.bundleIdentifier as Any,
kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true,
kSecReturnPersistentRef as String: true] as CFDictionary,
&result)
guard ret == errSecSuccess, let resultDicts = result as? [[String: Any]] else { return [] }
let labelPrefix = "WireGuard Tunnel: "
var reinstationData: [ReinstationData] = []
for resultDict in resultDicts {
guard let ref = resultDict[kSecValuePersistentRef as String] as? Data else { continue }
guard let label = resultDict[kSecAttrLabel as String] as? String else { continue }
guard label.hasPrefix(labelPrefix) else { continue }
if !knownRefsSet.contains(ref) {
let tunnelName = String(label.dropFirst(labelPrefix.count))
if let configStr = Keychain.openReference(called: ref),
let config = try? TunnelConfiguration(fromWgQuickConfig: configStr, called: tunnelName) {
reinstationData.append(ReinstationData(tunnelConfiguration: config, keychainPasswordRef: ref))
}
}
}
return reinstationData
}
}
#endif
#if os(macOS)
@available(macOS 10.15, *)
extension TunnelsManager {
fileprivate func reinstateTunnel(reinstationData: CatalinaWorkaround.ReinstationData, completionHandler: @escaping (Bool) -> Void) {
let tunnelName = reinstationData.tunnelConfiguration.name ?? ""
if tunnelName.isEmpty {
completionHandler(false)
return
}
if tunnels.contains(where: { $0.name == tunnelName }) {
completionHandler(false)
return
}
let tunnelProviderProtocol = NETunnelProviderProtocol()
guard let appId = Bundle.main.bundleIdentifier else { fatalError() }
tunnelProviderProtocol.providerBundleIdentifier = "\(appId).network-extension"
tunnelProviderProtocol.passwordReference = reinstationData.keychainPasswordRef
tunnelProviderProtocol.providerConfiguration = ["UID": getuid()]
tunnelProviderProtocol.serverAddress = {
let endpoints = reinstationData.tunnelConfiguration.peers.compactMap { $0.endpoint }
if endpoints.count == 1 {
return endpoints[0].stringRepresentation
} else if endpoints.isEmpty {
return "Unspecified"
} else {
return "Multiple endpoints"
}
}()
let tunnelProvider = NETunnelProviderManager()
tunnelProvider.localizedDescription = tunnelName
tunnelProvider.protocolConfiguration = tunnelProviderProtocol
objc_setAssociatedObject(tunnelProvider, &NETunnelProviderManager.cachedConfigKey, reinstationData.tunnelConfiguration, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
tunnelProvider.isEnabled = true
tunnelProvider.saveToPreferences { [weak self] error in
guard error == nil else {
wg_log(.error, message: "Reinstate: Saving configuration failed: \(error!)")
completionHandler(false)
return
}
guard let self = self else { return }
let tunnel = TunnelContainer(tunnel: tunnelProvider)
self.tunnels.append(tunnel)
self.tunnels.sort { TunnelsManager.tunnelNameIsLessThan($0.name, $1.name) }
self.tunnelsListDelegate?.tunnelAdded(at: self.tunnels.firstIndex(of: tunnel)!)
completionHandler(true)
}
}
}
#endif
@@ -0,0 +1,198 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import Foundation
class ActivateOnDemandViewModel {
enum OnDemandField {
case onDemand
case nonWiFiInterface
case wiFiInterface
case ssid
var localizedUIString: String {
switch self {
case .onDemand:
return tr("tunnelOnDemandKey")
case .nonWiFiInterface:
#if os(iOS)
return tr("tunnelOnDemandCellular")
#elseif os(macOS)
return tr("tunnelOnDemandEthernet")
#else
#error("Unimplemented")
#endif
case .wiFiInterface: return tr("tunnelOnDemandWiFi")
case .ssid: return tr("tunnelOnDemandSSIDsKey")
}
}
}
enum OnDemandSSIDOption {
case anySSID
case onlySpecificSSIDs
case exceptSpecificSSIDs
var localizedUIString: String {
switch self {
case .anySSID: return tr("tunnelOnDemandAnySSID")
case .onlySpecificSSIDs: return tr("tunnelOnDemandOnlyTheseSSIDs")
case .exceptSpecificSSIDs: return tr("tunnelOnDemandExceptTheseSSIDs")
}
}
}
var isNonWiFiInterfaceEnabled = false
var isWiFiInterfaceEnabled = false
var selectedSSIDs = [String]()
var ssidOption: OnDemandSSIDOption = .anySSID
}
extension ActivateOnDemandViewModel {
convenience init(tunnel: TunnelContainer) {
self.init()
if tunnel.isActivateOnDemandEnabled {
switch tunnel.onDemandOption {
case .off:
break
case .wiFiInterfaceOnly(let onDemandSSIDOption):
isWiFiInterfaceEnabled = true
(ssidOption, selectedSSIDs) = ssidViewModel(from: onDemandSSIDOption)
case .nonWiFiInterfaceOnly:
isNonWiFiInterfaceEnabled = true
case .anyInterface(let onDemandSSIDOption):
isWiFiInterfaceEnabled = true
isNonWiFiInterfaceEnabled = true
(ssidOption, selectedSSIDs) = ssidViewModel(from: onDemandSSIDOption)
}
}
}
func toOnDemandOption() -> ActivateOnDemandOption {
switch (isWiFiInterfaceEnabled, isNonWiFiInterfaceEnabled) {
case (false, false):
return .off
case (false, true):
return .nonWiFiInterfaceOnly
case (true, false):
return .wiFiInterfaceOnly(toSSIDOption())
case (true, true):
return .anyInterface(toSSIDOption())
}
}
}
extension ActivateOnDemandViewModel {
func isEnabled(field: OnDemandField) -> Bool {
switch field {
case .nonWiFiInterface:
return isNonWiFiInterfaceEnabled
case .wiFiInterface:
return isWiFiInterfaceEnabled
default:
return false
}
}
func setEnabled(field: OnDemandField, isEnabled: Bool) {
switch field {
case .nonWiFiInterface:
isNonWiFiInterfaceEnabled = isEnabled
case .wiFiInterface:
isWiFiInterfaceEnabled = isEnabled
default:
break
}
}
}
extension ActivateOnDemandViewModel {
var localizedInterfaceDescription: String {
switch (isWiFiInterfaceEnabled, isNonWiFiInterfaceEnabled) {
case (false, false):
return tr("tunnelOnDemandOptionOff")
case (true, false):
return tr("tunnelOnDemandOptionWiFiOnly")
case (false, true):
#if os(iOS)
return tr("tunnelOnDemandOptionCellularOnly")
#elseif os(macOS)
return tr("tunnelOnDemandOptionEthernetOnly")
#else
#error("Unimplemented")
#endif
case (true, true):
#if os(iOS)
return tr("tunnelOnDemandOptionWiFiOrCellular")
#elseif os(macOS)
return tr("tunnelOnDemandOptionWiFiOrEthernet")
#else
#error("Unimplemented")
#endif
}
}
var localizedSSIDDescription: String {
guard isWiFiInterfaceEnabled else { return "" }
switch ssidOption {
case .anySSID: return tr("tunnelOnDemandAnySSID")
case .onlySpecificSSIDs:
if selectedSSIDs.count == 1 {
return tr(format: "tunnelOnDemandOnlySSID (%d)", selectedSSIDs.count)
} else {
return tr(format: "tunnelOnDemandOnlySSIDs (%d)", selectedSSIDs.count)
}
case .exceptSpecificSSIDs:
if selectedSSIDs.count == 1 {
return tr(format: "tunnelOnDemandExceptSSID (%d)", selectedSSIDs.count)
} else {
return tr(format: "tunnelOnDemandExceptSSIDs (%d)", selectedSSIDs.count)
}
}
}
func fixSSIDOption() {
selectedSSIDs = uniquifiedNonEmptySelectedSSIDs()
if selectedSSIDs.isEmpty {
ssidOption = .anySSID
}
}
}
private extension ActivateOnDemandViewModel {
func ssidViewModel(from ssidOption: ActivateOnDemandSSIDOption) -> (OnDemandSSIDOption, [String]) {
switch ssidOption {
case .anySSID:
return (.anySSID, [])
case .onlySpecificSSIDs(let ssids):
return (.onlySpecificSSIDs, ssids)
case .exceptSpecificSSIDs(let ssids):
return (.exceptSpecificSSIDs, ssids)
}
}
func toSSIDOption() -> ActivateOnDemandSSIDOption {
switch ssidOption {
case .anySSID:
return .anySSID
case .onlySpecificSSIDs:
let ssids = uniquifiedNonEmptySelectedSSIDs()
return ssids.isEmpty ? .anySSID : .onlySpecificSSIDs(selectedSSIDs)
case .exceptSpecificSSIDs:
let ssids = uniquifiedNonEmptySelectedSSIDs()
return ssids.isEmpty ? .anySSID : .exceptSpecificSSIDs(selectedSSIDs)
}
}
func uniquifiedNonEmptySelectedSSIDs() -> [String] {
let nonEmptySSIDs = selectedSSIDs.filter { !$0.isEmpty }
var seenSSIDs = Set<String>()
var uniquified = [String]()
for ssid in nonEmptySSIDs {
guard !seenSSIDs.contains(ssid) else { continue }
uniquified.append(ssid)
seenSSIDs.insert(ssid)
}
return uniquified
}
}
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
protocol ErrorPresenterProtocol {
static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?, onPresented: (() -> Void)?, onDismissal: (() -> Void)?)
}
extension ErrorPresenterProtocol {
static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?, onPresented: (() -> Void)?) {
showErrorAlert(title: title, message: message, from: sourceVC, onPresented: onPresented, onDismissal: nil)
}
static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?, onDismissal: (() -> Void)?) {
showErrorAlert(title: title, message: message, from: sourceVC, onPresented: nil, onDismissal: onDismissal)
}
static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?) {
showErrorAlert(title: title, message: message, from: sourceVC, onPresented: nil, onDismissal: nil)
}
static func showErrorAlert(error: WireGuardAppError, from sourceVC: AnyObject?, onPresented: (() -> Void)? = nil, onDismissal: (() -> Void)? = nil) {
let (title, message) = error.alertText
showErrorAlert(title: title, message: message, from: sourceVC, onPresented: onPresented, onDismissal: onDismissal)
}
}
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import Foundation
public class LogViewHelper {
var log: OpaquePointer
var cursor: UInt32 = UINT32_MAX
static let formatOptions: ISO8601DateFormatter.Options = [
.withYear, .withMonth, .withDay, .withTime,
.withDashSeparatorInDate, .withColonSeparatorInTime, .withSpaceBetweenDateAndTime,
.withFractionalSeconds
]
struct LogEntry {
let timestamp: String
let message: String
func text() -> String {
return timestamp + " " + message
}
}
class LogEntries {
var entries: [LogEntry] = []
}
init?(logFilePath: String?) {
guard let logFilePath = logFilePath else { return nil }
guard let log = open_log(logFilePath) else { return nil }
self.log = log
}
deinit {
close_log(self.log)
}
func fetchLogEntriesSinceLastFetch(completion: @escaping ([LogViewHelper.LogEntry]) -> Void) {
var logEntries = LogEntries()
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return }
let newCursor = view_lines_from_cursor(self.log, self.cursor, &logEntries) { cStr, timestamp, ctx in
let message = cStr != nil ? String(cString: cStr!) : ""
let date = Date(timeIntervalSince1970: Double(timestamp) / 1000000000)
let dateString = ISO8601DateFormatter.string(from: date, timeZone: TimeZone.current, formatOptions: LogViewHelper.formatOptions)
if let logEntries = ctx?.bindMemory(to: LogEntries.self, capacity: 1) {
logEntries.pointee.entries.append(LogEntry(timestamp: dateString, message: message))
}
}
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.cursor = newCursor
completion(logEntries.entries)
}
}
}
}
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import Foundation
import LocalAuthentication
#if os(macOS)
import AppKit
#endif
class PrivateDataConfirmation {
static func confirmAccess(to reason: String, _ after: @escaping () -> Void) {
let context = LAContext()
var error: NSError?
if !context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error) {
guard let error = error as? LAError else { return }
if error.code == .passcodeNotSet {
// We give no protection to folks who just don't set a passcode.
after()
}
return
}
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
DispatchQueue.main.async {
#if os(macOS)
if !NSApp.isActive {
NSApp.activate(ignoringOtherApps: true)
}
#endif
if success {
after()
}
}
}
}
}
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import Foundation
import WireGuardKit
class TunnelImporter {
static func importFromFile(urls: [URL], into tunnelsManager: TunnelsManager, sourceVC: AnyObject?, errorPresenterType: ErrorPresenterProtocol.Type, completionHandler: (() -> Void)? = nil) {
guard !urls.isEmpty else {
completionHandler?()
return
}
let dispatchGroup = DispatchGroup()
var configs = [TunnelConfiguration?]()
var lastFileImportErrorText: (title: String, message: String)?
for url in urls {
if url.pathExtension.lowercased() == "zip" {
dispatchGroup.enter()
ZipImporter.importConfigFiles(from: url) { result in
switch result {
case .failure(let error):
lastFileImportErrorText = error.alertText
case .success(let configsInZip):
configs.append(contentsOf: configsInZip)
}
dispatchGroup.leave()
}
} else { /* if it is not a zip, we assume it is a conf */
let fileName = url.lastPathComponent
let fileBaseName = url.deletingPathExtension().lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines)
dispatchGroup.enter()
DispatchQueue.global(qos: .userInitiated).async {
let fileContents: String
do {
fileContents = try String(contentsOf: url)
} catch let error {
DispatchQueue.main.async {
if let cocoaError = error as? CocoaError, cocoaError.isFileError {
lastFileImportErrorText = (title: tr("alertCantOpenInputConfFileTitle"), message: error.localizedDescription)
} else {
lastFileImportErrorText = (title: tr("alertCantOpenInputConfFileTitle"), message: tr(format: "alertCantOpenInputConfFileMessage (%@)", fileName))
}
configs.append(nil)
dispatchGroup.leave()
}
return
}
var parseError: Error?
var tunnelConfiguration: TunnelConfiguration?
do {
tunnelConfiguration = try TunnelConfiguration(fromWgQuickConfig: fileContents, called: fileBaseName)
} catch let error {
parseError = error
}
DispatchQueue.main.async {
if parseError != nil {
if let parseError = parseError as? WireGuardAppError {
lastFileImportErrorText = parseError.alertText
} else {
lastFileImportErrorText = (title: tr("alertBadConfigImportTitle"), message: tr(format: "alertBadConfigImportMessage (%@)", fileName))
}
}
configs.append(tunnelConfiguration)
dispatchGroup.leave()
}
}
}
}
dispatchGroup.notify(queue: .main) {
tunnelsManager.addMultiple(tunnelConfigurations: configs.compactMap { $0 }) { numberSuccessful, lastAddError in
if !configs.isEmpty && numberSuccessful == configs.count {
completionHandler?()
return
}
let alertText: (title: String, message: String)?
if urls.count == 1 {
if urls.first!.pathExtension.lowercased() == "zip" && !configs.isEmpty {
alertText = (title: tr(format: "alertImportedFromZipTitle (%d)", numberSuccessful),
message: tr(format: "alertImportedFromZipMessage (%1$d of %2$d)", numberSuccessful, configs.count))
} else {
alertText = lastFileImportErrorText ?? lastAddError?.alertText
}
} else {
alertText = (title: tr(format: "alertImportedFromMultipleFilesTitle (%d)", numberSuccessful),
message: tr(format: "alertImportedFromMultipleFilesMessage (%1$d of %2$d)", numberSuccessful, configs.count))
}
if let alertText = alertText {
errorPresenterType.showErrorAlert(title: alertText.title, message: alertText.message, from: sourceVC, onPresented: completionHandler)
} else {
completionHandler?()
}
}
}
}
}
@@ -0,0 +1,696 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import Foundation
import WireGuardKit
class TunnelViewModel {
enum InterfaceField: CaseIterable {
case name
case privateKey
case publicKey
case generateKeyPair
case addresses
case listenPort
case mtu
case dns
case status
case toggleStatus
var localizedUIString: String {
switch self {
case .name: return tr("tunnelInterfaceName")
case .privateKey: return tr("tunnelInterfacePrivateKey")
case .publicKey: return tr("tunnelInterfacePublicKey")
case .generateKeyPair: return tr("tunnelInterfaceGenerateKeypair")
case .addresses: return tr("tunnelInterfaceAddresses")
case .listenPort: return tr("tunnelInterfaceListenPort")
case .mtu: return tr("tunnelInterfaceMTU")
case .dns: return tr("tunnelInterfaceDNS")
case .status: return tr("tunnelInterfaceStatus")
case .toggleStatus: return ""
}
}
}
static let interfaceFieldsWithControl: Set<InterfaceField> = [
.generateKeyPair
]
enum PeerField: CaseIterable {
case publicKey
case preSharedKey
case endpoint
case persistentKeepAlive
case allowedIPs
case rxBytes
case txBytes
case lastHandshakeTime
case excludePrivateIPs
case deletePeer
var localizedUIString: String {
switch self {
case .publicKey: return tr("tunnelPeerPublicKey")
case .preSharedKey: return tr("tunnelPeerPreSharedKey")
case .endpoint: return tr("tunnelPeerEndpoint")
case .persistentKeepAlive: return tr("tunnelPeerPersistentKeepalive")
case .allowedIPs: return tr("tunnelPeerAllowedIPs")
case .rxBytes: return tr("tunnelPeerRxBytes")
case .txBytes: return tr("tunnelPeerTxBytes")
case .lastHandshakeTime: return tr("tunnelPeerLastHandshakeTime")
case .excludePrivateIPs: return tr("tunnelPeerExcludePrivateIPs")
case .deletePeer: return tr("deletePeerButtonTitle")
}
}
}
static let peerFieldsWithControl: Set<PeerField> = [
.excludePrivateIPs, .deletePeer
]
static let keyLengthInBase64 = 44
struct Changes {
enum FieldChange: Equatable {
case added
case removed
case modified(newValue: String)
}
var interfaceChanges: [InterfaceField: FieldChange]
var peerChanges: [(peerIndex: Int, changes: [PeerField: FieldChange])]
var peersRemovedIndices: [Int]
var peersInsertedIndices: [Int]
}
class InterfaceData {
var scratchpad = [InterfaceField: String]()
var fieldsWithError = Set<InterfaceField>()
var validatedConfiguration: InterfaceConfiguration?
var validatedName: String?
subscript(field: InterfaceField) -> String {
get {
if scratchpad.isEmpty {
populateScratchpad()
}
return scratchpad[field] ?? ""
}
set(stringValue) {
if scratchpad.isEmpty {
populateScratchpad()
}
validatedConfiguration = nil
validatedName = nil
if stringValue.isEmpty {
scratchpad.removeValue(forKey: field)
} else {
scratchpad[field] = stringValue
}
if field == .privateKey {
if stringValue.count == TunnelViewModel.keyLengthInBase64,
let privateKey = PrivateKey(base64Key: stringValue) {
scratchpad[.publicKey] = privateKey.publicKey.base64Key
} else {
scratchpad.removeValue(forKey: .publicKey)
}
}
}
}
func populateScratchpad() {
guard let config = validatedConfiguration else { return }
guard let name = validatedName else { return }
scratchpad = TunnelViewModel.InterfaceData.createScratchPad(from: config, name: name)
}
private static func createScratchPad(from config: InterfaceConfiguration, name: String) -> [InterfaceField: String] {
var scratchpad = [InterfaceField: String]()
scratchpad[.name] = name
scratchpad[.privateKey] = config.privateKey.base64Key
scratchpad[.publicKey] = config.privateKey.publicKey.base64Key
if !config.addresses.isEmpty {
scratchpad[.addresses] = config.addresses.map { $0.stringRepresentation }.joined(separator: ", ")
}
if let listenPort = config.listenPort {
scratchpad[.listenPort] = String(listenPort)
}
if let mtu = config.mtu {
scratchpad[.mtu] = String(mtu)
}
if !config.dns.isEmpty {
scratchpad[.dns] = config.dns.map { $0.stringRepresentation }.joined(separator: ", ")
}
return scratchpad
}
func save() -> SaveResult<(String, InterfaceConfiguration)> {
if let config = validatedConfiguration, let name = validatedName {
return .saved((name, config))
}
fieldsWithError.removeAll()
guard let name = scratchpad[.name]?.trimmingCharacters(in: .whitespacesAndNewlines), (!name.isEmpty) else {
fieldsWithError.insert(.name)
return .error(tr("alertInvalidInterfaceMessageNameRequired"))
}
guard let privateKeyString = scratchpad[.privateKey] else {
fieldsWithError.insert(.privateKey)
return .error(tr("alertInvalidInterfaceMessagePrivateKeyRequired"))
}
guard let privateKey = PrivateKey(base64Key: privateKeyString) else {
fieldsWithError.insert(.privateKey)
return .error(tr("alertInvalidInterfaceMessagePrivateKeyInvalid"))
}
var config = InterfaceConfiguration(privateKey: privateKey)
var errorMessages = [String]()
if let addressesString = scratchpad[.addresses] {
var addresses = [IPAddressRange]()
for addressString in addressesString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
if let address = IPAddressRange(from: addressString) {
addresses.append(address)
} else {
fieldsWithError.insert(.addresses)
errorMessages.append(tr("alertInvalidInterfaceMessageAddressInvalid"))
}
}
config.addresses = addresses
}
if let listenPortString = scratchpad[.listenPort] {
if let listenPort = UInt16(listenPortString) {
config.listenPort = listenPort
} else {
fieldsWithError.insert(.listenPort)
errorMessages.append(tr("alertInvalidInterfaceMessageListenPortInvalid"))
}
}
if let mtuString = scratchpad[.mtu] {
if let mtu = UInt16(mtuString), mtu >= 576 {
config.mtu = mtu
} else {
fieldsWithError.insert(.mtu)
errorMessages.append(tr("alertInvalidInterfaceMessageMTUInvalid"))
}
}
if let dnsString = scratchpad[.dns] {
var dnsServers = [DNSServer]()
for dnsServerString in dnsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
if let dnsServer = DNSServer(from: dnsServerString) {
dnsServers.append(dnsServer)
} else {
fieldsWithError.insert(.dns)
errorMessages.append(tr("alertInvalidInterfaceMessageDNSInvalid"))
}
}
config.dns = dnsServers
}
guard errorMessages.isEmpty else { return .error(errorMessages.first!) }
validatedConfiguration = config
validatedName = name
return .saved((name, config))
}
func filterFieldsWithValueOrControl(interfaceFields: [InterfaceField]) -> [InterfaceField] {
return interfaceFields.filter { field in
if TunnelViewModel.interfaceFieldsWithControl.contains(field) {
return true
}
return !self[field].isEmpty
}
}
func applyConfiguration(other: InterfaceConfiguration, otherName: String) -> [InterfaceField: Changes.FieldChange] {
if scratchpad.isEmpty {
populateScratchpad()
}
let otherScratchPad = InterfaceData.createScratchPad(from: other, name: otherName)
var changes = [InterfaceField: Changes.FieldChange]()
for field in InterfaceField.allCases {
switch (scratchpad[field] ?? "", otherScratchPad[field] ?? "") {
case ("", ""):
break
case ("", _):
changes[field] = .added
case (_, ""):
changes[field] = .removed
case (let this, let other):
if this != other {
changes[field] = .modified(newValue: other)
}
}
}
scratchpad = otherScratchPad
return changes
}
}
class PeerData {
var index: Int
var scratchpad = [PeerField: String]()
var fieldsWithError = Set<PeerField>()
var validatedConfiguration: PeerConfiguration?
var publicKey: PublicKey? {
if let validatedConfiguration = validatedConfiguration {
return validatedConfiguration.publicKey
}
if let scratchPadPublicKey = scratchpad[.publicKey] {
return PublicKey(base64Key: scratchPadPublicKey)
}
return nil
}
private(set) var shouldAllowExcludePrivateIPsControl = false
private(set) var shouldStronglyRecommendDNS = false
private(set) var excludePrivateIPsValue = false
fileprivate var numberOfPeers = 0
init(index: Int) {
self.index = index
}
subscript(field: PeerField) -> String {
get {
if scratchpad.isEmpty {
populateScratchpad()
}
return scratchpad[field] ?? ""
}
set(stringValue) {
if scratchpad.isEmpty {
populateScratchpad()
}
validatedConfiguration = nil
if stringValue.isEmpty {
scratchpad.removeValue(forKey: field)
} else {
scratchpad[field] = stringValue
}
if field == .allowedIPs {
updateExcludePrivateIPsFieldState()
}
}
}
func populateScratchpad() {
guard let config = validatedConfiguration else { return }
scratchpad = TunnelViewModel.PeerData.createScratchPad(from: config)
updateExcludePrivateIPsFieldState()
}
private static func createScratchPad(from config: PeerConfiguration) -> [PeerField: String] {
var scratchpad = [PeerField: String]()
scratchpad[.publicKey] = config.publicKey.base64Key
if let preSharedKey = config.preSharedKey?.base64Key {
scratchpad[.preSharedKey] = preSharedKey
}
if !config.allowedIPs.isEmpty {
scratchpad[.allowedIPs] = config.allowedIPs.map { $0.stringRepresentation }.joined(separator: ", ")
}
if let endpoint = config.endpoint {
scratchpad[.endpoint] = endpoint.stringRepresentation
}
if let persistentKeepAlive = config.persistentKeepAlive {
scratchpad[.persistentKeepAlive] = String(persistentKeepAlive)
}
if let rxBytes = config.rxBytes {
scratchpad[.rxBytes] = prettyBytes(rxBytes)
}
if let txBytes = config.txBytes {
scratchpad[.txBytes] = prettyBytes(txBytes)
}
if let lastHandshakeTime = config.lastHandshakeTime {
scratchpad[.lastHandshakeTime] = prettyTimeAgo(timestamp: lastHandshakeTime)
}
return scratchpad
}
func save() -> SaveResult<PeerConfiguration> {
if let validatedConfiguration = validatedConfiguration {
return .saved(validatedConfiguration)
}
fieldsWithError.removeAll()
guard let publicKeyString = scratchpad[.publicKey] else {
fieldsWithError.insert(.publicKey)
return .error(tr("alertInvalidPeerMessagePublicKeyRequired"))
}
guard let publicKey = PublicKey(base64Key: publicKeyString) else {
fieldsWithError.insert(.publicKey)
return .error(tr("alertInvalidPeerMessagePublicKeyInvalid"))
}
var config = PeerConfiguration(publicKey: publicKey)
var errorMessages = [String]()
if let preSharedKeyString = scratchpad[.preSharedKey] {
if let preSharedKey = PreSharedKey(base64Key: preSharedKeyString) {
config.preSharedKey = preSharedKey
} else {
fieldsWithError.insert(.preSharedKey)
errorMessages.append(tr("alertInvalidPeerMessagePreSharedKeyInvalid"))
}
}
if let allowedIPsString = scratchpad[.allowedIPs] {
var allowedIPs = [IPAddressRange]()
for allowedIPString in allowedIPsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
if let allowedIP = IPAddressRange(from: allowedIPString) {
allowedIPs.append(allowedIP)
} else {
fieldsWithError.insert(.allowedIPs)
errorMessages.append(tr("alertInvalidPeerMessageAllowedIPsInvalid"))
}
}
config.allowedIPs = allowedIPs
}
if let endpointString = scratchpad[.endpoint] {
if let endpoint = Endpoint(from: endpointString) {
config.endpoint = endpoint
} else {
fieldsWithError.insert(.endpoint)
errorMessages.append(tr("alertInvalidPeerMessageEndpointInvalid"))
}
}
if let persistentKeepAliveString = scratchpad[.persistentKeepAlive] {
if let persistentKeepAlive = UInt16(persistentKeepAliveString) {
config.persistentKeepAlive = persistentKeepAlive
} else {
fieldsWithError.insert(.persistentKeepAlive)
errorMessages.append(tr("alertInvalidPeerMessagePersistentKeepaliveInvalid"))
}
}
guard errorMessages.isEmpty else { return .error(errorMessages.first!) }
validatedConfiguration = config
return .saved(config)
}
func filterFieldsWithValueOrControl(peerFields: [PeerField]) -> [PeerField] {
return peerFields.filter { field in
if TunnelViewModel.peerFieldsWithControl.contains(field) {
return true
}
return (!self[field].isEmpty)
}
}
static let ipv4DefaultRouteString = "0.0.0.0/0"
static let ipv4DefaultRouteModRFC1918String = [ // Set of all non-private IPv4 IPs
"0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3",
"64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12",
"172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7",
"176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16",
"192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10",
"193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4"
]
static func excludePrivateIPsFieldStates(isSinglePeer: Bool, allowedIPs: Set<String>) -> (shouldAllowExcludePrivateIPsControl: Bool, excludePrivateIPsValue: Bool) {
guard isSinglePeer else {
return (shouldAllowExcludePrivateIPsControl: false, excludePrivateIPsValue: false)
}
let allowedIPStrings = Set<String>(allowedIPs)
if allowedIPStrings.contains(TunnelViewModel.PeerData.ipv4DefaultRouteString) {
return (shouldAllowExcludePrivateIPsControl: true, excludePrivateIPsValue: false)
} else if allowedIPStrings.isSuperset(of: TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String) {
return (shouldAllowExcludePrivateIPsControl: true, excludePrivateIPsValue: true)
} else {
return (shouldAllowExcludePrivateIPsControl: false, excludePrivateIPsValue: false)
}
}
func updateExcludePrivateIPsFieldState() {
if scratchpad.isEmpty {
populateScratchpad()
}
let allowedIPStrings = Set<String>(scratchpad[.allowedIPs].splitToArray(trimmingCharacters: .whitespacesAndNewlines))
(shouldAllowExcludePrivateIPsControl, excludePrivateIPsValue) = TunnelViewModel.PeerData.excludePrivateIPsFieldStates(isSinglePeer: numberOfPeers == 1, allowedIPs: allowedIPStrings)
shouldStronglyRecommendDNS = allowedIPStrings.contains(TunnelViewModel.PeerData.ipv4DefaultRouteString) || allowedIPStrings.isSuperset(of: TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String)
}
static func normalizedIPAddressRangeStrings(_ list: [String]) -> [String] {
return list.compactMap { IPAddressRange(from: $0) }.map { $0.stringRepresentation }
}
static func modifiedAllowedIPs(currentAllowedIPs: [String], excludePrivateIPs: Bool, dnsServers: [String], oldDNSServers: [String]?) -> [String] {
let normalizedDNSServers = normalizedIPAddressRangeStrings(dnsServers)
let normalizedOldDNSServers = oldDNSServers == nil ? normalizedDNSServers : normalizedIPAddressRangeStrings(oldDNSServers!)
let ipv6Addresses = normalizedIPAddressRangeStrings(currentAllowedIPs.filter { $0.contains(":") })
if excludePrivateIPs {
return ipv6Addresses + TunnelViewModel.PeerData.ipv4DefaultRouteModRFC1918String + normalizedDNSServers
} else {
return ipv6Addresses.filter { !normalizedOldDNSServers.contains($0) } + [TunnelViewModel.PeerData.ipv4DefaultRouteString]
}
}
func excludePrivateIPsValueChanged(isOn: Bool, dnsServers: String, oldDNSServers: String? = nil) {
let allowedIPStrings = scratchpad[.allowedIPs].splitToArray(trimmingCharacters: .whitespacesAndNewlines)
let dnsServerStrings = dnsServers.splitToArray(trimmingCharacters: .whitespacesAndNewlines)
let oldDNSServerStrings = oldDNSServers?.splitToArray(trimmingCharacters: .whitespacesAndNewlines)
let modifiedAllowedIPStrings = TunnelViewModel.PeerData.modifiedAllowedIPs(currentAllowedIPs: allowedIPStrings, excludePrivateIPs: isOn, dnsServers: dnsServerStrings, oldDNSServers: oldDNSServerStrings)
scratchpad[.allowedIPs] = modifiedAllowedIPStrings.joined(separator: ", ")
validatedConfiguration = nil
excludePrivateIPsValue = isOn
}
func applyConfiguration(other: PeerConfiguration) -> [PeerField: Changes.FieldChange] {
if scratchpad.isEmpty {
populateScratchpad()
}
let otherScratchPad = PeerData.createScratchPad(from: other)
var changes = [PeerField: Changes.FieldChange]()
for field in PeerField.allCases {
switch (scratchpad[field] ?? "", otherScratchPad[field] ?? "") {
case ("", ""):
break
case ("", _):
changes[field] = .added
case (_, ""):
changes[field] = .removed
case (let this, let other):
if this != other {
changes[field] = .modified(newValue: other)
}
}
}
scratchpad = otherScratchPad
return changes
}
}
enum SaveResult<Configuration> {
case saved(Configuration)
case error(String)
}
private(set) var interfaceData: InterfaceData
private(set) var peersData: [PeerData]
init(tunnelConfiguration: TunnelConfiguration?) {
let interfaceData = InterfaceData()
var peersData = [PeerData]()
if let tunnelConfiguration = tunnelConfiguration {
interfaceData.validatedConfiguration = tunnelConfiguration.interface
interfaceData.validatedName = tunnelConfiguration.name
for (index, peerConfiguration) in tunnelConfiguration.peers.enumerated() {
let peerData = PeerData(index: index)
peerData.validatedConfiguration = peerConfiguration
peersData.append(peerData)
}
}
let numberOfPeers = peersData.count
for peerData in peersData {
peerData.numberOfPeers = numberOfPeers
peerData.updateExcludePrivateIPsFieldState()
}
self.interfaceData = interfaceData
self.peersData = peersData
}
func appendEmptyPeer() {
let peer = PeerData(index: peersData.count)
peersData.append(peer)
for peer in peersData {
peer.numberOfPeers = peersData.count
peer.updateExcludePrivateIPsFieldState()
}
}
func deletePeer(peer: PeerData) {
let removedPeer = peersData.remove(at: peer.index)
assert(removedPeer.index == peer.index)
for peer in peersData[peer.index ..< peersData.count] {
assert(peer.index > 0)
peer.index -= 1
}
for peer in peersData {
peer.numberOfPeers = peersData.count
peer.updateExcludePrivateIPsFieldState()
}
}
func updateDNSServersInAllowedIPsIfRequired(oldDNSServers: String, newDNSServers: String) -> Bool {
guard peersData.count == 1, let firstPeer = peersData.first else { return false }
guard firstPeer.shouldAllowExcludePrivateIPsControl && firstPeer.excludePrivateIPsValue else { return false }
let allowedIPStrings = firstPeer[.allowedIPs].splitToArray(trimmingCharacters: .whitespacesAndNewlines)
let oldDNSServerStrings = TunnelViewModel.PeerData.normalizedIPAddressRangeStrings(oldDNSServers.splitToArray(trimmingCharacters: .whitespacesAndNewlines))
let newDNSServerStrings = TunnelViewModel.PeerData.normalizedIPAddressRangeStrings(newDNSServers.splitToArray(trimmingCharacters: .whitespacesAndNewlines))
let updatedAllowedIPStrings = allowedIPStrings.filter { !oldDNSServerStrings.contains($0) } + newDNSServerStrings
firstPeer[.allowedIPs] = updatedAllowedIPStrings.joined(separator: ", ")
return true
}
func save() -> SaveResult<TunnelConfiguration> {
let interfaceSaveResult = interfaceData.save()
let peerSaveResults = peersData.map { $0.save() } // Save all, to help mark erroring fields in red
switch interfaceSaveResult {
case .error(let errorMessage):
return .error(errorMessage)
case .saved(let interfaceConfiguration):
var peerConfigurations = [PeerConfiguration]()
peerConfigurations.reserveCapacity(peerSaveResults.count)
for peerSaveResult in peerSaveResults {
switch peerSaveResult {
case .error(let errorMessage):
return .error(errorMessage)
case .saved(let peerConfiguration):
peerConfigurations.append(peerConfiguration)
}
}
let peerPublicKeysArray = peerConfigurations.map { $0.publicKey }
let peerPublicKeysSet = Set<PublicKey>(peerPublicKeysArray)
if peerPublicKeysArray.count != peerPublicKeysSet.count {
return .error(tr("alertInvalidPeerMessagePublicKeyDuplicated"))
}
let tunnelConfiguration = TunnelConfiguration(name: interfaceConfiguration.0, interface: interfaceConfiguration.1, peers: peerConfigurations)
return .saved(tunnelConfiguration)
}
}
func asWgQuickConfig() -> String? {
let saveResult = save()
if case .saved(let tunnelConfiguration) = saveResult {
return tunnelConfiguration.asWgQuickConfig()
}
return nil
}
@discardableResult
func applyConfiguration(other: TunnelConfiguration) -> Changes {
// Replaces current data with data from other TunnelConfiguration, ignoring any changes in peer ordering.
let interfaceChanges = interfaceData.applyConfiguration(other: other.interface, otherName: other.name ?? "")
var peerChanges = [(peerIndex: Int, changes: [PeerField: Changes.FieldChange])]()
for otherPeer in other.peers {
if let peersDataIndex = peersData.firstIndex(where: { $0.publicKey == otherPeer.publicKey }) {
let peerData = peersData[peersDataIndex]
let changes = peerData.applyConfiguration(other: otherPeer)
if !changes.isEmpty {
peerChanges.append((peerIndex: peersDataIndex, changes: changes))
}
}
}
var removedPeerIndices = [Int]()
for (index, peerData) in peersData.enumerated().reversed() {
if let peerPublicKey = peerData.publicKey, !other.peers.contains(where: { $0.publicKey == peerPublicKey}) {
removedPeerIndices.append(index)
peersData.remove(at: index)
}
}
var addedPeerIndices = [Int]()
for otherPeer in other.peers {
if !peersData.contains(where: { $0.publicKey == otherPeer.publicKey }) {
addedPeerIndices.append(peersData.count)
let peerData = PeerData(index: peersData.count)
peerData.validatedConfiguration = otherPeer
peersData.append(peerData)
}
}
for (index, peer) in peersData.enumerated() {
peer.index = index
peer.numberOfPeers = peersData.count
peer.updateExcludePrivateIPsFieldState()
}
return Changes(interfaceChanges: interfaceChanges, peerChanges: peerChanges, peersRemovedIndices: removedPeerIndices, peersInsertedIndices: addedPeerIndices)
}
}
private func prettyBytes(_ bytes: UInt64) -> String {
switch bytes {
case 0..<1024:
return "\(bytes) B"
case 1024 ..< (1024 * 1024):
return String(format: "%.2f", Double(bytes) / 1024) + " KiB"
case 1024 ..< (1024 * 1024 * 1024):
return String(format: "%.2f", Double(bytes) / (1024 * 1024)) + " MiB"
case 1024 ..< (1024 * 1024 * 1024 * 1024):
return String(format: "%.2f", Double(bytes) / (1024 * 1024 * 1024)) + " GiB"
default:
return String(format: "%.2f", Double(bytes) / (1024 * 1024 * 1024 * 1024)) + " TiB"
}
}
private func prettyTimeAgo(timestamp: Date) -> String {
let now = Date()
let timeInterval = Int64(now.timeIntervalSince(timestamp))
switch timeInterval {
case ..<0: return tr("tunnelHandshakeTimestampSystemClockBackward")
case 0: return tr("tunnelHandshakeTimestampNow")
default:
return tr(format: "tunnelHandshakeTimestampAgo (%@)", prettyTime(secondsLeft: timeInterval))
}
}
private func prettyTime(secondsLeft: Int64) -> String {
var left = secondsLeft
var timeStrings = [String]()
let years = left / (365 * 24 * 60 * 60)
left = left % (365 * 24 * 60 * 60)
let days = left / (24 * 60 * 60)
left = left % (24 * 60 * 60)
let hours = left / (60 * 60)
left = left % (60 * 60)
let minutes = left / 60
let seconds = left % 60
#if os(iOS)
if years > 0 {
return years == 1 ? tr(format: "tunnelHandshakeTimestampYear (%d)", years) : tr(format: "tunnelHandshakeTimestampYears (%d)", years)
}
if days > 0 {
return days == 1 ? tr(format: "tunnelHandshakeTimestampDay (%d)", days) : tr(format: "tunnelHandshakeTimestampDays (%d)", days)
}
if hours > 0 {
let hhmmss = String(format: "%02d:%02d:%02d", hours, minutes, seconds)
return tr(format: "tunnelHandshakeTimestampHours hh:mm:ss (%@)", hhmmss)
}
if minutes > 0 {
let mmss = String(format: "%02d:%02d", minutes, seconds)
return tr(format: "tunnelHandshakeTimestampMinutes mm:ss (%@)", mmss)
}
return seconds == 1 ? tr(format: "tunnelHandshakeTimestampSecond (%d)", seconds) : tr(format: "tunnelHandshakeTimestampSeconds (%d)", seconds)
#elseif os(macOS)
if years > 0 {
timeStrings.append(years == 1 ? tr(format: "tunnelHandshakeTimestampYear (%d)", years) : tr(format: "tunnelHandshakeTimestampYears (%d)", years))
}
if days > 0 {
timeStrings.append(days == 1 ? tr(format: "tunnelHandshakeTimestampDay (%d)", days) : tr(format: "tunnelHandshakeTimestampDays (%d)", days))
}
if hours > 0 {
timeStrings.append(hours == 1 ? tr(format: "tunnelHandshakeTimestampHour (%d)", hours) : tr(format: "tunnelHandshakeTimestampHours (%d)", hours))
}
if minutes > 0 {
timeStrings.append(minutes == 1 ? tr(format: "tunnelHandshakeTimestampMinute (%d)", minutes) : tr(format: "tunnelHandshakeTimestampMinutes (%d)", minutes))
}
if seconds > 0 {
timeStrings.append(seconds == 1 ? tr(format: "tunnelHandshakeTimestampSecond (%d)", seconds) : tr(format: "tunnelHandshakeTimestampSeconds (%d)", seconds))
}
return timeStrings.joined(separator: ", ")
#endif
}
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
import os.log
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var mainVC: MainViewController?
var isLaunchedForSpecificAction = false
func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
Logger.configureGlobal(tagged: "APP", withFilePath: FileManager.logFileURL?.path)
if let launchOptions = launchOptions {
if launchOptions[.url] != nil || launchOptions[.shortcutItem] != nil {
isLaunchedForSpecificAction = true
}
}
let window = UIWindow(frame: UIScreen.main.bounds)
if #available(iOS 13.0, *) {
window.backgroundColor = .systemBackground
} else {
window.backgroundColor = .white
}
self.window = window
let mainVC = MainViewController()
window.rootViewController = mainVC
window.makeKeyAndVisible()
self.mainVC = mainVC
return true
}
func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
mainVC?.importFromDisposableFile(url: url)
return true
}
func applicationDidBecomeActive(_ application: UIApplication) {
mainVC?.refreshTunnelConnectionStatuses()
}
func applicationWillResignActive(_ application: UIApplication) {
guard let allTunnelNames = mainVC?.allTunnelNames() else { return }
application.shortcutItems = QuickActionItem.createItems(allTunnelNames: allTunnelNames)
}
func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
guard shortcutItem.type == QuickActionItem.type else {
completionHandler(false)
return
}
let tunnelName = shortcutItem.localizedTitle
mainVC?.showTunnelDetailForTunnel(named: tunnelName, animated: false, shouldToggleStatus: true)
completionHandler(true)
}
}
extension AppDelegate {
func application(_ application: UIApplication, shouldSaveApplicationState coder: NSCoder) -> Bool {
return true
}
func application(_ application: UIApplication, shouldRestoreApplicationState coder: NSCoder) -> Bool {
return !self.isLaunchedForSpecificAction
}
func application(_ application: UIApplication, viewControllerWithRestorationIdentifierPath identifierComponents: [String], coder: NSCoder) -> UIViewController? {
guard let vcIdentifier = identifierComponents.last else { return nil }
if vcIdentifier.hasPrefix("TunnelDetailVC:") {
let tunnelName = String(vcIdentifier.suffix(vcIdentifier.count - "TunnelDetailVC:".count))
if let tunnelsManager = mainVC?.tunnelsManager {
if let tunnel = tunnelsManager.tunnel(named: tunnelName) {
return TunnelDetailTableViewController(tunnelsManager: tunnelsManager, tunnel: tunnel)
}
} else {
// Show it when tunnelsManager is available
mainVC?.showTunnelDetailForTunnel(named: tunnelName, animated: false, shouldToggleStatus: false)
}
}
return nil
}
}
@@ -0,0 +1,116 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "wireguard_logo_20pt@2x.png",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "wireguard_logo_20pt@3x.png",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "wireguard_logo_29pt@2x.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "wireguard_logo_29pt@3x.png",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "wireguard_logo_40pt@2x.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "wireguard_logo_40pt@3x.png",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "wireguard_logo_60pt@2x.png",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "wireguard_logo_60pt@3x.png",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "wireguard_logo_20pt@1x.png",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "wireguard_logo_20pt@2x-1.png",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "wireguard_logo_29pt@1x.png",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "wireguard_logo_29pt@2x-1.png",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "wireguard_logo_40pt@1x.png",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "wireguard_logo_40pt@2x-1.png",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "wireguard_logo_76pt@1x.png",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "wireguard_logo_76pt@2x.png",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "wireguard_logo_83.5pt@2x.png",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "wireguard_logo.png",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,15 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "wireguard.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"preserves-vector-representation" : true
}
}
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="14460.31" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="dyO-pm-zxZ">
<device id="ipad9_7" orientation="landscape">
<adaptation id="fullscreen"/>
</device>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="14460.20"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="wbj-wA-LRS">
<objects>
<viewController id="xPs-rU-RaC" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="VmF-QJ-FIU">
<rect key="frame" x="0.0" y="0.0" width="703.5" height="768"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<viewLayoutGuide key="safeArea" id="gU2-7a-hJw"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="XJD-RE-ATd" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
<!--Table View Controller-->
<scene sceneID="pXL-Um-fWR">
<objects>
<tableViewController clearsSelectionOnViewWillAppear="NO" id="9s0-Gz-xEQ" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="none" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="PWV-jc-Hj5">
<rect key="frame" x="0.0" y="0.0" width="320" height="768"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<prototypes>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" reuseIdentifier="LaunchScreenCellReuseId" id="bv7-0X-YhD">
<rect key="frame" x="0.0" y="28" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="bv7-0X-YhD" id="Uag-Pa-rmu">
<rect key="frame" x="0.0" y="0.0" width="320" height="44"/>
<autoresizingMask key="autoresizingMask"/>
</tableViewCellContentView>
</tableViewCell>
</prototypes>
<connections>
<outlet property="dataSource" destination="9s0-Gz-xEQ" id="djY-Us-7mp"/>
<outlet property="delegate" destination="9s0-Gz-xEQ" id="Frz-TZ-bD1"/>
</connections>
</tableView>
<navigationItem key="navigationItem" id="0ZE-eq-OPY"/>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="YJ1-t3-sLe" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
<!--Navigation Controller-->
<scene sceneID="bPm-bN-3Gh">
<objects>
<navigationController id="Fi9-6j-pBd" sceneMemberID="viewController">
<navigationBar key="navigationBar" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="h1o-PJ-Yrv">
<rect key="frame" x="0.0" y="20" width="320" height="50"/>
<autoresizingMask key="autoresizingMask"/>
</navigationBar>
<connections>
<segue destination="9s0-Gz-xEQ" kind="relationship" relationship="rootViewController" id="Gdt-8A-Sqj"/>
</connections>
</navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="fqW-5d-sYk" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
<!--Split View Controller-->
<scene sceneID="FTm-2q-Sdy">
<objects>
<splitViewController id="dyO-pm-zxZ" sceneMemberID="viewController">
<connections>
<segue destination="Fi9-6j-pBd" kind="relationship" relationship="masterViewController" id="mFt-pZ-wHb"/>
<segue destination="xPs-rU-RaC" kind="relationship" relationship="detailViewController" id="q2k-Zf-dXJ"/>
</connections>
</splitViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="XEu-8J-cff" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
class ConfirmationAlertPresenter {
static func showConfirmationAlert(message: String, buttonTitle: String, from sourceObject: AnyObject, presentingVC: UIViewController, onConfirmed: @escaping (() -> Void)) {
let destroyAction = UIAlertAction(title: buttonTitle, style: .destructive) { _ in
onConfirmed()
}
let cancelAction = UIAlertAction(title: tr("actionCancel"), style: .cancel)
let alert = UIAlertController(title: "", message: message, preferredStyle: .actionSheet)
alert.addAction(destroyAction)
alert.addAction(cancelAction)
if let sourceView = sourceObject as? UIView {
alert.popoverPresentationController?.sourceView = sourceView
alert.popoverPresentationController?.sourceRect = sourceView.bounds
} else if let sourceBarButtonItem = sourceObject as? UIBarButtonItem {
alert.popoverPresentationController?.barButtonItem = sourceBarButtonItem
}
presentingVC.present(alert, animated: true, completion: nil)
}
}
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
import os.log
class ErrorPresenter: ErrorPresenterProtocol {
static func showErrorAlert(title: String, message: String, from sourceVC: AnyObject?, onPresented: (() -> Void)?, onDismissal: (() -> Void)?) {
guard let sourceVC = sourceVC as? UIViewController else { return }
let okAction = UIAlertAction(title: "OK", style: .default) { _ in
onDismissal?()
}
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(okAction)
sourceVC.present(alert, animated: true, completion: onPresented)
}
}
+131
View File
@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>conf</string>
</array>
<key>CFBundleTypeIconFiles</key>
<array>
<string>wireguard_doc_logo_22x29.png</string>
<string>wireguard_doc_logo_44x58.png</string>
<string>wireguard_doc_logo_64x64.png</string>
<string>wireguard_doc_logo_320x320.png</string>
</array>
<key>CFBundleTypeName</key>
<string>WireGuard wg-quick configuration file</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>com.wireguard.config.quick</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Zip file</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.pkware.zip-archive</string>
</array>
</dict>
<dict>
<key>CFBundleTypeIconFiles</key>
<array/>
<key>CFBundleTypeName</key>
<string>Text file</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.text</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleDisplayName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(VERSION_NAME)</string>
<key>CFBundleVersion</key>
<string>$(VERSION_ID)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<false/>
<key>NSCameraUsageDescription</key>
<string>Localized</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.text</string>
</array>
<key>UTTypeDescription</key>
<string>WireGuard wg-quick configuration file</string>
<key>UTTypeIconFiles</key>
<array>
<string>wireguard_doc_logo_22x29.png</string>
<string>wireguard_doc_logo_44x58.png</string>
<string>wireguard_doc_logo_64x64.png</string>
<string>wireguard_doc_logo_320x320.png</string>
</array>
<key>UTTypeIdentifier</key>
<string>com.wireguard.config.quick</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<string>conf</string>
</dict>
</dict>
</array>
<key>NSFaceIDUsageDescription</key>
<string>Localized</string>
<key>com.wireguard.ios.app_group_id</key>
<string>group.$(APP_ID_IOS)</string>
</dict>
</plist>
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
class QuickActionItem: UIApplicationShortcutItem {
static let type = "WireGuardTunnelActivateAndShow"
init(tunnelName: String) {
super.init(type: QuickActionItem.type, localizedTitle: tunnelName, localizedSubtitle: nil, icon: nil, userInfo: nil)
}
static func createItems(allTunnelNames: [String]) -> [QuickActionItem] {
let numberOfItems = 10
// Currently, only 4 items shown by iOS, but that can increase in the future.
// iOS will discard additional items we give it.
var tunnelNames = RecentTunnelsTracker.recentlyActivatedTunnelNames(limit: numberOfItems)
let numberOfSlotsRemaining = numberOfItems - tunnelNames.count
if numberOfSlotsRemaining > 0 {
let moreTunnels = allTunnelNames.filter { !tunnelNames.contains($0) }.prefix(numberOfSlotsRemaining)
tunnelNames.append(contentsOf: moreTunnels)
}
return tunnelNames.map { QuickActionItem(tunnelName: $0) }
}
}
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import Foundation
class RecentTunnelsTracker {
private static let keyRecentlyActivatedTunnelNames = "recentlyActivatedTunnelNames"
private static let maxNumberOfTunnels = 10
private static var userDefaults: UserDefaults? {
guard let appGroupId = FileManager.appGroupId else {
wg_log(.error, staticMessage: "Cannot obtain app group ID from bundle for tracking recently used tunnels")
return nil
}
guard let userDefaults = UserDefaults(suiteName: appGroupId) else {
wg_log(.error, staticMessage: "Cannot obtain shared user defaults for tracking recently used tunnels")
return nil
}
return userDefaults
}
static func handleTunnelActivated(tunnelName: String) {
guard let userDefaults = RecentTunnelsTracker.userDefaults else { return }
var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? []
if let existingIndex = recentTunnels.firstIndex(of: tunnelName) {
recentTunnels.remove(at: existingIndex)
}
recentTunnels.insert(tunnelName, at: 0)
if recentTunnels.count > maxNumberOfTunnels {
recentTunnels.removeLast(recentTunnels.count - maxNumberOfTunnels)
}
userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames)
}
static func handleTunnelRemoved(tunnelName: String) {
guard let userDefaults = RecentTunnelsTracker.userDefaults else { return }
var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? []
if let existingIndex = recentTunnels.firstIndex(of: tunnelName) {
recentTunnels.remove(at: existingIndex)
userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames)
}
}
static func handleTunnelRenamed(oldName: String, newName: String) {
guard let userDefaults = RecentTunnelsTracker.userDefaults else { return }
var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? []
if let existingIndex = recentTunnels.firstIndex(of: oldName) {
recentTunnels[existingIndex] = newName
userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames)
}
}
static func cleanupTunnels(except tunnelNamesToKeep: Set<String>) {
guard let userDefaults = RecentTunnelsTracker.userDefaults else { return }
var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? []
let oldCount = recentTunnels.count
recentTunnels.removeAll { !tunnelNamesToKeep.contains($0) }
if oldCount != recentTunnels.count {
userDefaults.set(recentTunnels, forKey: keyRecentlyActivatedTunnelNames)
}
}
static func recentlyActivatedTunnelNames(limit: Int) -> [String] {
guard let userDefaults = RecentTunnelsTracker.userDefaults else { return [] }
var recentTunnels = userDefaults.stringArray(forKey: keyRecentlyActivatedTunnelNames) ?? []
if limit < recentTunnels.count {
recentTunnels.removeLast(recentTunnels.count - limit)
}
return recentTunnels
}
}
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
extension UITableViewCell {
static var reuseIdentifier: String {
return NSStringFromClass(self)
}
}
extension UITableView {
func register<T: UITableViewCell>(_: T.Type) {
register(T.self, forCellReuseIdentifier: T.reuseIdentifier)
}
func dequeueReusableCell<T: UITableViewCell>(for indexPath: IndexPath) -> T {
//swiftlint:disable:next force_cast
return dequeueReusableCell(withIdentifier: T.reuseIdentifier, for: indexPath) as! T
}
}
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
class BorderedTextButton: UIView {
let button: UIButton = {
let button = UIButton(type: .system)
button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
button.titleLabel?.adjustsFontForContentSizeCategory = true
return button
}()
override var intrinsicContentSize: CGSize {
let buttonSize = button.intrinsicContentSize
return CGSize(width: buttonSize.width + 32, height: buttonSize.height + 16)
}
var title: String {
get { return button.title(for: .normal) ?? "" }
set(value) { button.setTitle(value, for: .normal) }
}
var onTapped: (() -> Void)?
init() {
super.init(frame: CGRect.zero)
layer.borderWidth = 1
layer.cornerRadius = 5
layer.borderColor = button.tintColor.cgColor
addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
button.centerXAnchor.constraint(equalTo: centerXAnchor),
button.centerYAnchor.constraint(equalTo: centerYAnchor)
])
button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc func buttonTapped() {
onTapped?()
}
}
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
class ButtonCell: UITableViewCell {
var buttonText: String {
get { return button.title(for: .normal) ?? "" }
set(value) { button.setTitle(value, for: .normal) }
}
var hasDestructiveAction: Bool {
get { return button.tintColor == .systemRed }
set(value) { button.tintColor = value ? .systemRed : buttonStandardTintColor }
}
var onTapped: (() -> Void)?
let button: UIButton = {
let button = UIButton(type: .system)
button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body)
button.titleLabel?.adjustsFontForContentSizeCategory = true
return button
}()
var buttonStandardTintColor: UIColor
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
buttonStandardTintColor = button.tintColor
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.addSubview(button)
button.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
button.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor),
contentView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: button.bottomAnchor),
button.centerXAnchor.constraint(equalTo: contentView.centerXAnchor)
])
button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside)
}
@objc func buttonTapped() {
onTapped?()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
buttonText = ""
onTapped = nil
hasDestructiveAction = false
}
}
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
class CheckmarkCell: UITableViewCell {
var message: String {
get { return textLabel?.text ?? "" }
set(value) { textLabel!.text = value }
}
var isChecked: Bool {
didSet {
accessoryType = isChecked ? .checkmark : .none
}
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
isChecked = false
super.init(style: .default, reuseIdentifier: reuseIdentifier)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
message = ""
isChecked = false
}
}
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
class ChevronCell: UITableViewCell {
var message: String {
get { return textLabel?.text ?? "" }
set(value) { textLabel?.text = value }
}
var detailMessage: String {
get { return detailTextLabel?.text ?? "" }
set(value) { detailTextLabel?.text = value }
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: .value1, reuseIdentifier: reuseIdentifier)
accessoryType = .disclosureIndicator
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
message = ""
}
}
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
class EditableTextCell: UITableViewCell {
var message: String {
get { return valueTextField.text ?? "" }
set(value) { valueTextField.text = value }
}
let valueTextField: UITextField = {
let valueTextField = UITextField()
valueTextField.textAlignment = .left
valueTextField.isEnabled = true
valueTextField.font = UIFont.preferredFont(forTextStyle: .body)
valueTextField.adjustsFontForContentSizeCategory = true
valueTextField.autocapitalizationType = .none
valueTextField.autocorrectionType = .no
valueTextField.spellCheckingType = .no
return valueTextField
}()
var onValueBeingEdited: ((String) -> Void)?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
valueTextField.delegate = self
contentView.addSubview(valueTextField)
valueTextField.translatesAutoresizingMaskIntoConstraints = false
let bottomAnchorConstraint = contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: valueTextField.bottomAnchor, multiplier: 1)
bottomAnchorConstraint.priority = .defaultLow
NSLayoutConstraint.activate([
valueTextField.leadingAnchor.constraint(equalToSystemSpacingAfter: contentView.layoutMarginsGuide.leadingAnchor, multiplier: 1),
contentView.layoutMarginsGuide.trailingAnchor.constraint(equalToSystemSpacingAfter: valueTextField.trailingAnchor, multiplier: 1),
valueTextField.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 1),
bottomAnchorConstraint
])
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func beginEditing() {
valueTextField.becomeFirstResponder()
}
override func prepareForReuse() {
super.prepareForReuse()
message = ""
}
}
extension EditableTextCell: UITextFieldDelegate {
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if let onValueBeingEdited = onValueBeingEdited {
let modifiedText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string)
onValueBeingEdited(modifiedText)
}
return true
}
}
@@ -0,0 +1,236 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
class KeyValueCell: UITableViewCell {
let keyLabel: UILabel = {
let keyLabel = UILabel()
keyLabel.font = UIFont.preferredFont(forTextStyle: .body)
keyLabel.adjustsFontForContentSizeCategory = true
if #available(iOS 13.0, *) {
keyLabel.textColor = .label
} else {
keyLabel.textColor = .black
}
keyLabel.textAlignment = .left
return keyLabel
}()
let valueLabelScrollView: UIScrollView = {
let scrollView = UIScrollView(frame: .zero)
scrollView.isDirectionalLockEnabled = true
scrollView.showsHorizontalScrollIndicator = false
scrollView.showsVerticalScrollIndicator = false
return scrollView
}()
let valueTextField: UITextField = {
let valueTextField = UITextField()
valueTextField.textAlignment = .right
valueTextField.isEnabled = false
valueTextField.font = UIFont.preferredFont(forTextStyle: .body)
valueTextField.adjustsFontForContentSizeCategory = true
valueTextField.autocapitalizationType = .none
valueTextField.autocorrectionType = .no
valueTextField.spellCheckingType = .no
if #available(iOS 13.0, *) {
valueTextField.textColor = .secondaryLabel
} else {
valueTextField.textColor = .gray
}
return valueTextField
}()
var copyableGesture = true
var key: String {
get { return keyLabel.text ?? "" }
set(value) { keyLabel.text = value }
}
var value: String {
get { return valueTextField.text ?? "" }
set(value) { valueTextField.text = value }
}
var placeholderText: String {
get { return valueTextField.placeholder ?? "" }
set(value) { valueTextField.placeholder = value }
}
var keyboardType: UIKeyboardType {
get { return valueTextField.keyboardType }
set(value) { valueTextField.keyboardType = value }
}
var isValueValid = true {
didSet {
if #available(iOS 13.0, *) {
if isValueValid {
keyLabel.textColor = .label
} else {
keyLabel.textColor = .systemRed
}
} else {
if isValueValid {
keyLabel.textColor = .black
} else {
keyLabel.textColor = .red
}
}
}
}
var isStackedHorizontally = false
var isStackedVertically = false
var contentSizeBasedConstraints = [NSLayoutConstraint]()
var onValueChanged: ((String, String) -> Void)?
var onValueBeingEdited: ((String) -> Void)?
var observationToken: AnyObject?
private var textFieldValueOnBeginEditing: String = ""
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.addSubview(keyLabel)
keyLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
keyLabel.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
keyLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5)
])
valueTextField.delegate = self
valueLabelScrollView.addSubview(valueTextField)
valueTextField.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
valueTextField.leadingAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.leadingAnchor),
valueTextField.topAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.topAnchor),
valueTextField.bottomAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.bottomAnchor),
valueTextField.trailingAnchor.constraint(equalTo: valueLabelScrollView.contentLayoutGuide.trailingAnchor),
valueTextField.heightAnchor.constraint(equalTo: valueLabelScrollView.heightAnchor)
])
let expandToFitValueLabelConstraint = NSLayoutConstraint(item: valueTextField, attribute: .width, relatedBy: .equal, toItem: valueLabelScrollView, attribute: .width, multiplier: 1, constant: 0)
expandToFitValueLabelConstraint.priority = .defaultLow + 1
expandToFitValueLabelConstraint.isActive = true
contentView.addSubview(valueLabelScrollView)
contentView.addSubview(valueLabelScrollView)
valueLabelScrollView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
valueLabelScrollView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor),
contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: valueLabelScrollView.bottomAnchor, multiplier: 0.5)
])
keyLabel.setContentCompressionResistancePriority(.defaultHigh + 1, for: .horizontal)
keyLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
valueLabelScrollView.setContentHuggingPriority(.defaultLow, for: .horizontal)
let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:)))
addGestureRecognizer(gestureRecognizer)
isUserInteractionEnabled = true
configureForContentSize()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func configureForContentSize() {
var constraints = [NSLayoutConstraint]()
if traitCollection.preferredContentSizeCategory.isAccessibilityCategory {
// Stack vertically
if !isStackedVertically {
constraints = [
valueLabelScrollView.topAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5),
valueLabelScrollView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor),
keyLabel.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor)
]
isStackedVertically = true
isStackedHorizontally = false
}
} else {
// Stack horizontally
if !isStackedHorizontally {
constraints = [
contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5),
valueLabelScrollView.leadingAnchor.constraint(equalToSystemSpacingAfter: keyLabel.trailingAnchor, multiplier: 1),
valueLabelScrollView.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5)
]
isStackedHorizontally = true
isStackedVertically = false
}
}
if !constraints.isEmpty {
NSLayoutConstraint.deactivate(contentSizeBasedConstraints)
NSLayoutConstraint.activate(constraints)
contentSizeBasedConstraints = constraints
}
}
@objc func handleTapGesture(_ recognizer: UIGestureRecognizer) {
if !copyableGesture {
return
}
guard recognizer.state == .recognized else { return }
if let recognizerView = recognizer.view,
let recognizerSuperView = recognizerView.superview, recognizerView.becomeFirstResponder() {
let menuController = UIMenuController.shared
menuController.setTargetRect(detailTextLabel?.frame ?? recognizerView.frame, in: detailTextLabel?.superview ?? recognizerSuperView)
menuController.setMenuVisible(true, animated: true)
}
}
override var canBecomeFirstResponder: Bool {
return true
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
return (action == #selector(UIResponderStandardEditActions.copy(_:)))
}
override func copy(_ sender: Any?) {
UIPasteboard.general.string = valueTextField.text
}
override func prepareForReuse() {
super.prepareForReuse()
copyableGesture = true
placeholderText = ""
isValueValid = true
keyboardType = .default
onValueChanged = nil
onValueBeingEdited = nil
observationToken = nil
key = ""
value = ""
configureForContentSize()
}
}
extension KeyValueCell: UITextFieldDelegate {
func textFieldDidBeginEditing(_ textField: UITextField) {
textFieldValueOnBeginEditing = textField.text ?? ""
isValueValid = true
}
func textFieldDidEndEditing(_ textField: UITextField) {
let isModified = textField.text ?? "" != textFieldValueOnBeginEditing
guard isModified else { return }
onValueChanged?(textFieldValueOnBeginEditing, textField.text ?? "")
}
func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
if let onValueBeingEdited = onValueBeingEdited {
let modifiedText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string)
onValueBeingEdited(modifiedText)
}
return true
}
}
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
class SwitchCell: UITableViewCell {
var message: String {
get { return textLabel?.text ?? "" }
set(value) { textLabel?.text = value }
}
var isOn: Bool {
get { return switchView.isOn }
set(value) { switchView.isOn = value }
}
var isEnabled: Bool {
get { return switchView.isEnabled }
set(value) {
switchView.isEnabled = value
if #available(iOS 13.0, *) {
textLabel?.textColor = value ? .label : .secondaryLabel
} else {
textLabel?.textColor = value ? .black : .gray
}
}
}
var onSwitchToggled: ((Bool) -> Void)?
var observationToken: AnyObject?
let switchView = UISwitch()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: .default, reuseIdentifier: reuseIdentifier)
accessoryView = switchView
switchView.addTarget(self, action: #selector(switchToggled), for: .valueChanged)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc func switchToggled() {
onSwitchToggled?(switchView.isOn)
}
override func prepareForReuse() {
super.prepareForReuse()
onSwitchToggled = nil
isEnabled = true
message = ""
isOn = false
observationToken = nil
}
}
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
class TextCell: UITableViewCell {
var message: String {
get { return textLabel?.text ?? "" }
set(value) { textLabel!.text = value }
}
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: .default, reuseIdentifier: reuseIdentifier)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setTextColor(_ color: UIColor) {
textLabel?.textColor = color
}
func setTextAlignment(_ alignment: NSTextAlignment) {
textLabel?.textAlignment = alignment
}
override func prepareForReuse() {
super.prepareForReuse()
message = ""
if #available(iOS 13.0, *) {
setTextColor(.label)
} else {
setTextColor(.black)
}
setTextAlignment(.left)
}
}
@@ -0,0 +1,52 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
class TunnelEditKeyValueCell: KeyValueCell {
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
keyLabel.textAlignment = .right
valueTextField.textAlignment = .left
let widthRatioConstraint = NSLayoutConstraint(item: keyLabel, attribute: .width, relatedBy: .equal, toItem: self, attribute: .width, multiplier: 0.4, constant: 0)
// In case the key doesn't fit into 0.4 * width,
// set a CR priority > the 0.4-constraint's priority.
widthRatioConstraint.priority = .defaultHigh + 1
widthRatioConstraint.isActive = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class TunnelEditEditableKeyValueCell: TunnelEditKeyValueCell {
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
copyableGesture = false
if #available(iOS 13.0, *) {
valueTextField.textColor = .label
} else {
valueTextField.textColor = .black
}
valueTextField.isEnabled = true
valueLabelScrollView.isScrollEnabled = false
valueTextField.widthAnchor.constraint(equalTo: valueLabelScrollView.widthAnchor).isActive = true
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func prepareForReuse() {
super.prepareForReuse()
copyableGesture = false
}
}
@@ -0,0 +1,122 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
class TunnelListCell: UITableViewCell {
var tunnel: TunnelContainer? {
didSet(value) {
// Bind to the tunnel's name
nameLabel.text = tunnel?.name ?? ""
nameObservationToken = tunnel?.observe(\.name) { [weak self] tunnel, _ in
self?.nameLabel.text = tunnel.name
}
// Bind to the tunnel's status
update(from: tunnel?.status)
statusObservationToken = tunnel?.observe(\.status) { [weak self] tunnel, _ in
self?.update(from: tunnel.status)
}
}
}
var onSwitchToggled: ((Bool) -> Void)?
let nameLabel: UILabel = {
let nameLabel = UILabel()
nameLabel.font = UIFont.preferredFont(forTextStyle: .body)
nameLabel.adjustsFontForContentSizeCategory = true
nameLabel.numberOfLines = 0
return nameLabel
}()
let busyIndicator: UIActivityIndicatorView = {
if #available(iOS 13.0, *) {
let busyIndicator = UIActivityIndicatorView(style: .medium)
busyIndicator.hidesWhenStopped = true
return busyIndicator
} else {
let busyIndicator = UIActivityIndicatorView(style: .gray)
busyIndicator.hidesWhenStopped = true
return busyIndicator
}
}()
let statusSwitch = UISwitch()
private var statusObservationToken: AnyObject?
private var nameObservationToken: AnyObject?
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
contentView.addSubview(statusSwitch)
statusSwitch.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
statusSwitch.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
contentView.trailingAnchor.constraint(equalTo: statusSwitch.trailingAnchor)
])
contentView.addSubview(busyIndicator)
busyIndicator.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
busyIndicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
statusSwitch.leadingAnchor.constraint(equalToSystemSpacingAfter: busyIndicator.trailingAnchor, multiplier: 1)
])
contentView.addSubview(nameLabel)
nameLabel.translatesAutoresizingMaskIntoConstraints = false
nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let bottomAnchorConstraint = contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: nameLabel.bottomAnchor, multiplier: 1)
bottomAnchorConstraint.priority = .defaultLow
NSLayoutConstraint.activate([
nameLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 1),
nameLabel.leadingAnchor.constraint(equalToSystemSpacingAfter: contentView.layoutMarginsGuide.leadingAnchor, multiplier: 1),
busyIndicator.leadingAnchor.constraint(equalToSystemSpacingAfter: nameLabel.trailingAnchor, multiplier: 1),
bottomAnchorConstraint
])
accessoryType = .disclosureIndicator
statusSwitch.addTarget(self, action: #selector(switchToggled), for: .valueChanged)
}
@objc func switchToggled() {
onSwitchToggled?(statusSwitch.isOn)
}
private func update(from status: TunnelStatus?) {
guard let status = status else {
reset()
return
}
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak statusSwitch, weak busyIndicator] in
guard let statusSwitch = statusSwitch, let busyIndicator = busyIndicator else { return }
statusSwitch.isOn = !(status == .deactivating || status == .inactive)
statusSwitch.isUserInteractionEnabled = (status == .inactive || status == .active)
if status == .inactive || status == .active {
busyIndicator.stopAnimating()
} else {
busyIndicator.startAnimating()
}
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func setEditing(_ editing: Bool, animated: Bool) {
super.setEditing(editing, animated: animated)
statusSwitch.isEnabled = !editing
}
private func reset() {
statusSwitch.isOn = false
statusSwitch.isUserInteractionEnabled = false
busyIndicator.stopAnimating()
}
override func prepareForReuse() {
super.prepareForReuse()
reset()
}
}
@@ -0,0 +1,165 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
class LogViewController: UIViewController {
let textView: UITextView = {
let textView = UITextView()
textView.isEditable = false
textView.isSelectable = true
textView.font = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body)
textView.adjustsFontForContentSizeCategory = true
return textView
}()
let busyIndicator: UIActivityIndicatorView = {
if #available(iOS 13.0, *) {
let busyIndicator = UIActivityIndicatorView(style: .medium)
busyIndicator.hidesWhenStopped = true
return busyIndicator
} else {
let busyIndicator = UIActivityIndicatorView(style: .gray)
busyIndicator.hidesWhenStopped = true
return busyIndicator
}
}()
let paragraphStyle: NSParagraphStyle = {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.setParagraphStyle(NSParagraphStyle.default)
paragraphStyle.lineHeightMultiple = 1.2
return paragraphStyle
}()
var isNextLineHighlighted = false
var logViewHelper: LogViewHelper?
var isFetchingLogEntries = false
private var updateLogEntriesTimer: Timer?
override func loadView() {
view = UIView()
if #available(iOS 13.0, *) {
view.backgroundColor = .systemBackground
} else {
view.backgroundColor = .white
}
view.addSubview(textView)
textView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
textView.topAnchor.constraint(equalTo: view.topAnchor),
textView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
view.addSubview(busyIndicator)
busyIndicator.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
busyIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
busyIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
busyIndicator.startAnimating()
logViewHelper = LogViewHelper(logFilePath: FileManager.logFileURL?.path)
startUpdatingLogEntries()
}
override func viewDidLoad() {
title = tr("logViewTitle")
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(saveTapped(sender:)))
}
func updateLogEntries() {
guard !isFetchingLogEntries else { return }
isFetchingLogEntries = true
logViewHelper?.fetchLogEntriesSinceLastFetch { [weak self] fetchedLogEntries in
guard let self = self else { return }
defer {
self.isFetchingLogEntries = false
}
if self.busyIndicator.isAnimating {
self.busyIndicator.stopAnimating()
}
guard !fetchedLogEntries.isEmpty else { return }
let isScrolledToEnd = self.textView.contentSize.height - self.textView.bounds.height - self.textView.contentOffset.y < 1
let richText = NSMutableAttributedString()
let bodyFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body)
let captionFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.caption1)
for logEntry in fetchedLogEntries {
var bgColor: UIColor
var fgColor: UIColor
if #available(iOS 13.0, *) {
bgColor = self.isNextLineHighlighted ? .systemGray3 : .systemBackground
fgColor = .label
} else {
bgColor = self.isNextLineHighlighted ? UIColor(white: 0.88, alpha: 1.0) : UIColor.white
fgColor = .black
}
let timestampText = NSAttributedString(string: logEntry.timestamp + "\n", attributes: [.font: captionFont, .backgroundColor: bgColor, .foregroundColor: fgColor, .paragraphStyle: self.paragraphStyle])
let messageText = NSAttributedString(string: logEntry.message + "\n", attributes: [.font: bodyFont, .backgroundColor: bgColor, .foregroundColor: fgColor, .paragraphStyle: self.paragraphStyle])
richText.append(timestampText)
richText.append(messageText)
self.isNextLineHighlighted.toggle()
}
self.textView.textStorage.append(richText)
if isScrolledToEnd {
let endOfCurrentText = NSRange(location: (self.textView.text as NSString).length, length: 0)
self.textView.scrollRangeToVisible(endOfCurrentText)
}
}
}
func startUpdatingLogEntries() {
updateLogEntries()
updateLogEntriesTimer?.invalidate()
let timer = Timer(timeInterval: 1 /* second */, repeats: true) { [weak self] _ in
self?.updateLogEntries()
}
updateLogEntriesTimer = timer
RunLoop.main.add(timer, forMode: .common)
}
@objc func saveTapped(sender: AnyObject) {
guard let destinationDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withFullDate, .withTime, .withTimeZone] // Avoid ':' in the filename
let timeStampString = dateFormatter.string(from: Date())
let destinationURL = destinationDir.appendingPathComponent("wireguard-log-\(timeStampString).txt")
DispatchQueue.global(qos: .userInitiated).async {
if FileManager.default.fileExists(atPath: destinationURL.path) {
let isDeleted = FileManager.deleteFile(at: destinationURL)
if !isDeleted {
ErrorPresenter.showErrorAlert(title: tr("alertUnableToRemovePreviousLogTitle"), message: tr("alertUnableToRemovePreviousLogMessage"), from: self)
return
}
}
let isWritten = Logger.global?.writeLog(to: destinationURL.path) ?? false
DispatchQueue.main.async {
guard isWritten else {
ErrorPresenter.showErrorAlert(title: tr("alertUnableToWriteLogTitle"), message: tr("alertUnableToWriteLogMessage"), from: self)
return
}
let activityVC = UIActivityViewController(activityItems: [destinationURL], applicationActivities: nil)
if let sender = sender as? UIBarButtonItem {
activityVC.popoverPresentationController?.barButtonItem = sender
}
activityVC.completionWithItemsHandler = { _, _, _, _ in
// Remove the exported log file after the activity has completed
_ = FileManager.deleteFile(at: destinationURL)
}
self.present(activityVC, animated: true)
}
}
}
}
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
class MainViewController: UISplitViewController {
var tunnelsManager: TunnelsManager?
var onTunnelsManagerReady: ((TunnelsManager) -> Void)?
var tunnelsListVC: TunnelsListTableViewController?
init() {
let detailVC = UIViewController()
if #available(iOS 13.0, *) {
detailVC.view.backgroundColor = .systemBackground
} else {
detailVC.view.backgroundColor = .white
}
let detailNC = UINavigationController(rootViewController: detailVC)
let masterVC = TunnelsListTableViewController()
let masterNC = UINavigationController(rootViewController: masterVC)
tunnelsListVC = masterVC
super.init(nibName: nil, bundle: nil)
viewControllers = [ masterNC, detailNC ]
restorationIdentifier = "MainVC"
masterNC.restorationIdentifier = "MasterNC"
detailNC.restorationIdentifier = "DetailNC"
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
delegate = self
// On iPad, always show both masterVC and detailVC, even in portrait mode, like the Settings app
preferredDisplayMode = .allVisible
// Create the tunnels manager, and when it's ready, inform tunnelsListVC
TunnelsManager.create { [weak self] result in
guard let self = self else { return }
switch result {
case .failure(let error):
ErrorPresenter.showErrorAlert(error: error, from: self)
case .success(let tunnelsManager):
self.tunnelsManager = tunnelsManager
self.tunnelsListVC?.setTunnelsManager(tunnelsManager: tunnelsManager)
tunnelsManager.activationDelegate = self
self.onTunnelsManagerReady?(tunnelsManager)
self.onTunnelsManagerReady = nil
}
}
}
func allTunnelNames() -> [String]? {
guard let tunnelsManager = self.tunnelsManager else { return nil }
return tunnelsManager.mapTunnels { $0.name }
}
}
extension MainViewController: TunnelsManagerActivationDelegate {
func tunnelActivationAttemptFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationAttemptError) {
ErrorPresenter.showErrorAlert(error: error, from: self)
}
func tunnelActivationAttemptSucceeded(tunnel: TunnelContainer) {
// Nothing to do
}
func tunnelActivationFailed(tunnel: TunnelContainer, error: TunnelsManagerActivationError) {
ErrorPresenter.showErrorAlert(error: error, from: self)
}
func tunnelActivationSucceeded(tunnel: TunnelContainer) {
// Nothing to do
}
}
extension MainViewController {
func refreshTunnelConnectionStatuses() {
if let tunnelsManager = tunnelsManager {
tunnelsManager.refreshStatuses()
}
}
func showTunnelDetailForTunnel(named tunnelName: String, animated: Bool, shouldToggleStatus: Bool) {
let showTunnelDetailBlock: (TunnelsManager) -> Void = { [weak self] tunnelsManager in
guard let self = self else { return }
guard let tunnelsListVC = self.tunnelsListVC else { return }
if let tunnel = tunnelsManager.tunnel(named: tunnelName) {
tunnelsListVC.showTunnelDetail(for: tunnel, animated: false)
if shouldToggleStatus {
if tunnel.status == .inactive {
tunnelsManager.startActivation(of: tunnel)
} else if tunnel.status == .active {
tunnelsManager.startDeactivation(of: tunnel)
}
}
}
}
if let tunnelsManager = tunnelsManager {
showTunnelDetailBlock(tunnelsManager)
} else {
onTunnelsManagerReady = showTunnelDetailBlock
}
}
func importFromDisposableFile(url: URL) {
let importFromFileBlock: (TunnelsManager) -> Void = { [weak self] tunnelsManager in
TunnelImporter.importFromFile(urls: [url], into: tunnelsManager, sourceVC: self, errorPresenterType: ErrorPresenter.self) {
_ = FileManager.deleteFile(at: url)
}
}
if let tunnelsManager = tunnelsManager {
importFromFileBlock(tunnelsManager)
} else {
onTunnelsManagerReady = importFromFileBlock
}
}
}
extension MainViewController: UISplitViewControllerDelegate {
func splitViewController(_ splitViewController: UISplitViewController,
collapseSecondary secondaryViewController: UIViewController,
onto primaryViewController: UIViewController) -> Bool {
// On iPhone, if the secondaryVC (detailVC) is just a UIViewController, it indicates that it's empty,
// so just show the primaryVC (masterVC).
let detailVC = (secondaryViewController as? UINavigationController)?.viewControllers.first
let isDetailVCEmpty: Bool
if let detailVC = detailVC {
isDetailVCEmpty = (type(of: detailVC) == UIViewController.self)
} else {
isDetailVCEmpty = true
}
return isDetailVCEmpty
}
}
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import AVFoundation
import UIKit
import WireGuardKit
protocol QRScanViewControllerDelegate: class {
func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController, completionHandler: (() -> Void)?)
}
class QRScanViewController: UIViewController {
weak var delegate: QRScanViewControllerDelegate?
var captureSession: AVCaptureSession? = AVCaptureSession()
let metadataOutput = AVCaptureMetadataOutput()
var previewLayer: AVCaptureVideoPreviewLayer?
override func viewDidLoad() {
super.viewDidLoad()
title = tr("scanQRCodeViewTitle")
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped))
let tipLabel = UILabel()
tipLabel.text = tr("scanQRCodeTipText")
tipLabel.adjustsFontSizeToFitWidth = true
tipLabel.textColor = .lightGray
tipLabel.textAlignment = .center
view.addSubview(tipLabel)
tipLabel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tipLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
tipLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
tipLabel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -32)
])
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video),
let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice),
let captureSession = captureSession,
captureSession.canAddInput(videoInput),
captureSession.canAddOutput(metadataOutput) else {
scanDidEncounterError(title: tr("alertScanQRCodeCameraUnsupportedTitle"), message: tr("alertScanQRCodeCameraUnsupportedMessage"))
return
}
captureSession.addInput(videoInput)
captureSession.addOutput(metadataOutput)
metadataOutput.setMetadataObjectsDelegate(self, queue: .main)
metadataOutput.metadataObjectTypes = [.qr]
let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
previewLayer.frame = view.layer.bounds
previewLayer.videoGravity = .resizeAspectFill
view.layer.insertSublayer(previewLayer, at: 0)
self.previewLayer = previewLayer
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if captureSession?.isRunning == false {
captureSession?.startRunning()
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if captureSession?.isRunning == true {
captureSession?.stopRunning()
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if let connection = previewLayer?.connection {
let currentDevice = UIDevice.current
let orientation = currentDevice.orientation
let previewLayerConnection = connection
if previewLayerConnection.isVideoOrientationSupported {
switch orientation {
case .portrait:
previewLayerConnection.videoOrientation = .portrait
case .landscapeRight:
previewLayerConnection.videoOrientation = .landscapeLeft
case .landscapeLeft:
previewLayerConnection.videoOrientation = .landscapeRight
case .portraitUpsideDown:
previewLayerConnection.videoOrientation = .portraitUpsideDown
default:
previewLayerConnection.videoOrientation = .portrait
}
}
}
previewLayer?.frame = view.bounds
}
func scanDidComplete(withCode code: String) {
let scannedTunnelConfiguration = try? TunnelConfiguration(fromWgQuickConfig: code, called: "Scanned")
guard let tunnelConfiguration = scannedTunnelConfiguration else {
scanDidEncounterError(title: tr("alertScanQRCodeInvalidQRCodeTitle"), message: tr("alertScanQRCodeInvalidQRCodeMessage"))
return
}
let alert = UIAlertController(title: tr("alertScanQRCodeNamePromptTitle"), message: nil, preferredStyle: .alert)
alert.addTextField(configurationHandler: nil)
alert.addAction(UIAlertAction(title: tr("actionCancel"), style: .cancel) { [weak self] _ in
self?.dismiss(animated: true, completion: nil)
})
alert.addAction(UIAlertAction(title: tr("actionSave"), style: .default) { [weak self] _ in
guard let title = alert.textFields?[0].text?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else { return }
tunnelConfiguration.name = title
if let self = self {
self.delegate?.addScannedQRCode(tunnelConfiguration: tunnelConfiguration, qrScanViewController: self) {
self.dismiss(animated: true, completion: nil)
}
}
})
present(alert, animated: true)
}
func scanDidEncounterError(title: String, message: String) {
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: tr("actionOK"), style: .default) { [weak self] _ in
self?.dismiss(animated: true, completion: nil)
})
present(alertController, animated: true)
captureSession = nil
}
@objc func cancelTapped() {
dismiss(animated: true, completion: nil)
}
}
extension QRScanViewController: AVCaptureMetadataOutputObjectsDelegate {
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
captureSession?.stopRunning()
guard let metadataObject = metadataObjects.first,
let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject,
let stringValue = readableObject.stringValue else {
scanDidEncounterError(title: tr("alertScanQRCodeUnreadableQRCodeTitle"), message: tr("alertScanQRCodeUnreadableQRCodeMessage"))
return
}
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
scanDidComplete(withCode: stringValue)
}
}
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
class SSIDOptionDetailTableViewController: UITableViewController {
let selectedSSIDs: [String]
init(title: String, ssids: [String]) {
selectedSSIDs = ssids
super.init(style: .grouped)
self.title = title
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.estimatedRowHeight = 44
tableView.rowHeight = UITableView.automaticDimension
tableView.allowsSelection = false
tableView.register(TextCell.self)
}
}
extension SSIDOptionDetailTableViewController {
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return selectedSSIDs.count
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
return tr("tunnelOnDemandSectionTitleSelectedSSIDs")
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell: TextCell = tableView.dequeueReusableCell(for: indexPath)
cell.message = selectedSSIDs[indexPath.row]
return cell
}
}
@@ -0,0 +1,303 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
import SystemConfiguration.CaptiveNetwork
protocol SSIDOptionEditTableViewControllerDelegate: class {
func ssidOptionSaved(option: ActivateOnDemandViewModel.OnDemandSSIDOption, ssids: [String])
}
class SSIDOptionEditTableViewController: UITableViewController {
private enum Section {
case ssidOption
case selectedSSIDs
case addSSIDs
}
private enum AddSSIDRow {
case addConnectedSSID(connectedSSID: String)
case addNewSSID
}
weak var delegate: SSIDOptionEditTableViewControllerDelegate?
private var sections = [Section]()
private var addSSIDRows = [AddSSIDRow]()
let ssidOptionFields: [ActivateOnDemandViewModel.OnDemandSSIDOption] = [
.anySSID,
.onlySpecificSSIDs,
.exceptSpecificSSIDs
]
var selectedOption: ActivateOnDemandViewModel.OnDemandSSIDOption
var selectedSSIDs: [String]
var connectedSSID: String?
init(option: ActivateOnDemandViewModel.OnDemandSSIDOption, ssids: [String]) {
selectedOption = option
selectedSSIDs = ssids
super.init(style: .grouped)
connectedSSID = getConnectedSSID()
loadSections()
loadAddSSIDRows()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = tr("tunnelOnDemandSSIDViewTitle")
tableView.estimatedRowHeight = 44
tableView.rowHeight = UITableView.automaticDimension
tableView.register(CheckmarkCell.self)
tableView.register(EditableTextCell.self)
tableView.register(TextCell.self)
tableView.isEditing = true
tableView.allowsSelectionDuringEditing = true
}
func loadSections() {
sections.removeAll()
sections.append(.ssidOption)
if selectedOption != .anySSID {
sections.append(.selectedSSIDs)
sections.append(.addSSIDs)
}
}
func loadAddSSIDRows() {
addSSIDRows.removeAll()
if let connectedSSID = connectedSSID {
if !selectedSSIDs.contains(connectedSSID) {
addSSIDRows.append(.addConnectedSSID(connectedSSID: connectedSSID))
}
}
addSSIDRows.append(.addNewSSID)
}
func updateTableViewAddSSIDRows() {
guard let addSSIDSection = sections.firstIndex(of: .addSSIDs) else { return }
let numberOfAddSSIDRows = addSSIDRows.count
let numberOfAddSSIDRowsInTableView = tableView.numberOfRows(inSection: addSSIDSection)
switch (numberOfAddSSIDRowsInTableView, numberOfAddSSIDRows) {
case (1, 2):
tableView.insertRows(at: [IndexPath(row: 0, section: addSSIDSection)], with: .automatic)
case (2, 1):
tableView.deleteRows(at: [IndexPath(row: 0, section: addSSIDSection)], with: .automatic)
default:
break
}
}
override func viewWillDisappear(_ animated: Bool) {
delegate?.ssidOptionSaved(option: selectedOption, ssids: selectedSSIDs)
}
}
extension SSIDOptionEditTableViewController {
override func numberOfSections(in tableView: UITableView) -> Int {
return sections.count
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch sections[section] {
case .ssidOption:
return ssidOptionFields.count
case .selectedSSIDs:
return selectedSSIDs.isEmpty ? 1 : selectedSSIDs.count
case .addSSIDs:
return addSSIDRows.count
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch sections[indexPath.section] {
case .ssidOption:
return ssidOptionCell(for: tableView, at: indexPath)
case .selectedSSIDs:
if !selectedSSIDs.isEmpty {
return selectedSSIDCell(for: tableView, at: indexPath)
} else {
return noSSIDsCell(for: tableView, at: indexPath)
}
case .addSSIDs:
return addSSIDCell(for: tableView, at: indexPath)
}
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
switch sections[indexPath.section] {
case .ssidOption:
return false
case .selectedSSIDs:
return !selectedSSIDs.isEmpty
case .addSSIDs:
return true
}
}
override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
switch sections[indexPath.section] {
case .ssidOption:
return .none
case .selectedSSIDs:
return .delete
case .addSSIDs:
return .insert
}
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
switch sections[section] {
case .ssidOption:
return nil
case .selectedSSIDs:
return tr("tunnelOnDemandSectionTitleSelectedSSIDs")
case .addSSIDs:
return tr("tunnelOnDemandSectionTitleAddSSIDs")
}
}
private func ssidOptionCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
let field = ssidOptionFields[indexPath.row]
let cell: CheckmarkCell = tableView.dequeueReusableCell(for: indexPath)
cell.message = field.localizedUIString
cell.isChecked = selectedOption == field
cell.isEditing = false
return cell
}
private func noSSIDsCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
let cell: TextCell = tableView.dequeueReusableCell(for: indexPath)
cell.message = tr("tunnelOnDemandNoSSIDs")
if #available(iOS 13.0, *) {
cell.setTextColor(.secondaryLabel)
} else {
cell.setTextColor(.gray)
}
cell.setTextAlignment(.center)
return cell
}
private func selectedSSIDCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
let cell: EditableTextCell = tableView.dequeueReusableCell(for: indexPath)
cell.message = selectedSSIDs[indexPath.row]
cell.isEditing = true
cell.onValueBeingEdited = { [weak self, weak cell] text in
guard let self = self, let cell = cell else { return }
if let row = self.tableView.indexPath(for: cell)?.row {
self.selectedSSIDs[row] = text
self.loadAddSSIDRows()
self.updateTableViewAddSSIDRows()
}
}
return cell
}
private func addSSIDCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
let cell: TextCell = tableView.dequeueReusableCell(for: indexPath)
switch addSSIDRows[indexPath.row] {
case .addConnectedSSID:
cell.message = tr(format: "tunnelOnDemandAddMessageAddConnectedSSID (%@)", connectedSSID!)
case .addNewSSID:
cell.message = tr("tunnelOnDemandAddMessageAddNewSSID")
}
cell.isEditing = true
return cell
}
override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
switch sections[indexPath.section] {
case .ssidOption:
assertionFailure()
case .selectedSSIDs:
assert(editingStyle == .delete)
selectedSSIDs.remove(at: indexPath.row)
if !selectedSSIDs.isEmpty {
tableView.deleteRows(at: [indexPath], with: .automatic)
} else {
tableView.reloadRows(at: [indexPath], with: .automatic)
}
loadAddSSIDRows()
updateTableViewAddSSIDRows()
case .addSSIDs:
assert(editingStyle == .insert)
let newSSID: String
switch addSSIDRows[indexPath.row] {
case .addConnectedSSID(let connectedSSID):
newSSID = connectedSSID
case .addNewSSID:
newSSID = ""
}
selectedSSIDs.append(newSSID)
loadSections()
let selectedSSIDsSection = sections.firstIndex(of: .selectedSSIDs)!
let indexPath = IndexPath(row: selectedSSIDs.count - 1, section: selectedSSIDsSection)
if selectedSSIDs.count == 1 {
tableView.reloadRows(at: [indexPath], with: .automatic)
} else {
tableView.insertRows(at: [indexPath], with: .automatic)
}
loadAddSSIDRows()
updateTableViewAddSSIDRows()
if newSSID.isEmpty {
if let selectedSSIDCell = tableView.cellForRow(at: indexPath) as? EditableTextCell {
selectedSSIDCell.beginEditing()
}
}
}
}
}
extension SSIDOptionEditTableViewController {
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
switch sections[indexPath.section] {
case .ssidOption:
return indexPath
case .selectedSSIDs, .addSSIDs:
return nil
}
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
switch sections[indexPath.section] {
case .ssidOption:
let previousOption = selectedOption
selectedOption = ssidOptionFields[indexPath.row]
guard previousOption != selectedOption else {
tableView.deselectRow(at: indexPath, animated: true)
return
}
loadSections()
if previousOption == .anySSID {
let indexSet = IndexSet(1 ... 2)
tableView.insertSections(indexSet, with: .fade)
}
if selectedOption == .anySSID {
let indexSet = IndexSet(1 ... 2)
tableView.deleteSections(indexSet, with: .fade)
}
tableView.reloadSections(IndexSet(integer: indexPath.section), with: .none)
case .selectedSSIDs, .addSSIDs:
assertionFailure()
}
}
}
private func getConnectedSSID() -> String? {
guard let supportedInterfaces = CNCopySupportedInterfaces() as? [CFString] else { return nil }
for interface in supportedInterfaces {
if let networkInfo = CNCopyCurrentNetworkInfo(interface) {
if let ssid = (networkInfo as NSDictionary)[kCNNetworkInfoKeySSID as String] as? String {
return !ssid.isEmpty ? ssid : nil
}
}
}
return nil
}
@@ -0,0 +1,185 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
import os.log
import WireGuardKit
class SettingsTableViewController: UITableViewController {
enum SettingsFields {
case iosAppVersion
case goBackendVersion
case exportZipArchive
case viewLog
case donateLink
var localizedUIString: String {
switch self {
case .iosAppVersion: return tr("settingsVersionKeyWireGuardForIOS")
case .goBackendVersion: return tr("settingsVersionKeyWireGuardGoBackend")
case .exportZipArchive: return tr("settingsExportZipButtonTitle")
case .viewLog: return tr("settingsViewLogButtonTitle")
case .donateLink: return tr("donateLink")
}
}
}
let settingsFieldsBySection: [[SettingsFields]] = [
[.iosAppVersion, .goBackendVersion, .donateLink],
[.exportZipArchive],
[.viewLog]
]
let tunnelsManager: TunnelsManager?
var wireguardCaptionedImage: (view: UIView, size: CGSize)?
init(tunnelsManager: TunnelsManager?) {
self.tunnelsManager = tunnelsManager
super.init(style: .grouped)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = tr("settingsViewTitle")
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneTapped))
tableView.estimatedRowHeight = 44
tableView.rowHeight = UITableView.automaticDimension
tableView.allowsSelection = false
tableView.register(KeyValueCell.self)
tableView.register(ButtonCell.self)
tableView.tableFooterView = UIImageView(image: UIImage(named: "wireguard.pdf"))
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard let logo = tableView.tableFooterView else { return }
let bottomPadding = max(tableView.layoutMargins.bottom, 10)
let fullHeight = max(tableView.contentSize.height, tableView.bounds.size.height - tableView.layoutMargins.top - bottomPadding)
let imageAspectRatio = logo.intrinsicContentSize.width / logo.intrinsicContentSize.height
var height = tableView.estimatedRowHeight * 1.5
var width = height * imageAspectRatio
let maxWidth = view.bounds.size.width - max(tableView.layoutMargins.left + tableView.layoutMargins.right, 20)
if width > maxWidth {
width = maxWidth
height = width / imageAspectRatio
}
let needsReload = height != logo.frame.height
logo.frame = CGRect(x: (view.bounds.size.width - width) / 2, y: fullHeight - height, width: width, height: height)
if needsReload {
tableView.tableFooterView = logo
}
}
@objc func doneTapped() {
dismiss(animated: true, completion: nil)
}
func exportConfigurationsAsZipFile(sourceView: UIView) {
PrivateDataConfirmation.confirmAccess(to: tr("iosExportPrivateData")) { [weak self] in
guard let self = self else { return }
guard let tunnelsManager = self.tunnelsManager else { return }
guard let destinationDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
let destinationURL = destinationDir.appendingPathComponent("wireguard-export.zip")
_ = FileManager.deleteFile(at: destinationURL)
let count = tunnelsManager.numberOfTunnels()
let tunnelConfigurations = (0 ..< count).compactMap { tunnelsManager.tunnel(at: $0).tunnelConfiguration }
ZipExporter.exportConfigFiles(tunnelConfigurations: tunnelConfigurations, to: destinationURL) { [weak self] error in
if let error = error {
ErrorPresenter.showErrorAlert(error: error, from: self)
return
}
let fileExportVC = UIDocumentPickerViewController(url: destinationURL, in: .exportToService)
self?.present(fileExportVC, animated: true, completion: nil)
}
}
}
func presentLogView() {
let logVC = LogViewController()
navigationController?.pushViewController(logVC, animated: true)
}
}
extension SettingsTableViewController {
override func numberOfSections(in tableView: UITableView) -> Int {
return settingsFieldsBySection.count
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return settingsFieldsBySection[section].count
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
switch section {
case 0:
return tr("settingsSectionTitleAbout")
case 1:
return tr("settingsSectionTitleExportConfigurations")
case 2:
return tr("settingsSectionTitleTunnelLog")
default:
return nil
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let field = settingsFieldsBySection[indexPath.section][indexPath.row]
if field == .iosAppVersion || field == .goBackendVersion {
let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath)
cell.copyableGesture = false
cell.key = field.localizedUIString
if field == .iosAppVersion {
var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown version"
if let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
appVersion += " (\(appBuild))"
}
cell.value = appVersion
} else if field == .goBackendVersion {
cell.value = getWireGuardVersion()
}
return cell
} else if field == .exportZipArchive {
let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath)
cell.buttonText = field.localizedUIString
cell.onTapped = { [weak self] in
self?.exportConfigurationsAsZipFile(sourceView: cell.button)
}
return cell
} else if field == .viewLog {
let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath)
cell.buttonText = field.localizedUIString
cell.onTapped = { [weak self] in
self?.presentLogView()
}
return cell
} else if field == .donateLink {
let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath)
cell.buttonText = field.localizedUIString
cell.onTapped = {
if let url = URL(string: "https://www.wireguard.com/donations/"), UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:])
}
}
return cell
}
fatalError()
}
}
@@ -0,0 +1,458 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
import WireGuardKit
class TunnelDetailTableViewController: UITableViewController {
private enum Section {
case status
case interface
case peer(index: Int, peer: TunnelViewModel.PeerData)
case onDemand
case delete
}
static let interfaceFields: [TunnelViewModel.InterfaceField] = [
.name, .publicKey, .addresses,
.listenPort, .mtu, .dns
]
static let peerFields: [TunnelViewModel.PeerField] = [
.publicKey, .preSharedKey, .endpoint,
.allowedIPs, .persistentKeepAlive,
.rxBytes, .txBytes, .lastHandshakeTime
]
static let onDemandFields: [ActivateOnDemandViewModel.OnDemandField] = [
.onDemand, .ssid
]
let tunnelsManager: TunnelsManager
let tunnel: TunnelContainer
var tunnelViewModel: TunnelViewModel
var onDemandViewModel: ActivateOnDemandViewModel
private var sections = [Section]()
private var interfaceFieldIsVisible = [Bool]()
private var peerFieldIsVisible = [[Bool]]()
private var statusObservationToken: AnyObject?
private var onDemandObservationToken: AnyObject?
private var reloadRuntimeConfigurationTimer: Timer?
init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) {
self.tunnelsManager = tunnelsManager
self.tunnel = tunnel
tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration)
onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel)
super.init(style: .grouped)
loadSections()
loadVisibleFields()
statusObservationToken = tunnel.observe(\.status) { [weak self] _, _ in
guard let self = self else { return }
if tunnel.status == .active {
self.startUpdatingRuntimeConfiguration()
} else if tunnel.status == .inactive {
self.reloadRuntimeConfiguration()
self.stopUpdatingRuntimeConfiguration()
}
}
onDemandObservationToken = tunnel.observe(\.isActivateOnDemandEnabled) { [weak self] tunnel, _ in
// Handle On-Demand getting turned on/off outside of the app
self?.onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel)
self?.updateActivateOnDemandFields()
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = tunnelViewModel.interfaceData[.name]
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(editTapped))
tableView.estimatedRowHeight = 44
tableView.rowHeight = UITableView.automaticDimension
tableView.register(SwitchCell.self)
tableView.register(KeyValueCell.self)
tableView.register(ButtonCell.self)
tableView.register(ChevronCell.self)
restorationIdentifier = "TunnelDetailVC:\(tunnel.name)"
}
private func loadSections() {
sections.removeAll()
sections.append(.status)
sections.append(.interface)
for (index, peer) in tunnelViewModel.peersData.enumerated() {
sections.append(.peer(index: index, peer: peer))
}
sections.append(.onDemand)
sections.append(.delete)
}
private func loadVisibleFields() {
let visibleInterfaceFields = tunnelViewModel.interfaceData.filterFieldsWithValueOrControl(interfaceFields: TunnelDetailTableViewController.interfaceFields)
interfaceFieldIsVisible = TunnelDetailTableViewController.interfaceFields.map { visibleInterfaceFields.contains($0) }
peerFieldIsVisible = tunnelViewModel.peersData.map { peer in
let visiblePeerFields = peer.filterFieldsWithValueOrControl(peerFields: TunnelDetailTableViewController.peerFields)
return TunnelDetailTableViewController.peerFields.map { visiblePeerFields.contains($0) }
}
}
override func viewWillAppear(_ animated: Bool) {
if tunnel.status == .active {
self.startUpdatingRuntimeConfiguration()
}
}
override func viewDidDisappear(_ animated: Bool) {
stopUpdatingRuntimeConfiguration()
}
@objc func editTapped() {
PrivateDataConfirmation.confirmAccess(to: tr("iosViewPrivateData")) { [weak self] in
guard let self = self else { return }
let editVC = TunnelEditTableViewController(tunnelsManager: self.tunnelsManager, tunnel: self.tunnel)
editVC.delegate = self
let editNC = UINavigationController(rootViewController: editVC)
editNC.modalPresentationStyle = .fullScreen
self.present(editNC, animated: true)
}
}
func startUpdatingRuntimeConfiguration() {
reloadRuntimeConfiguration()
reloadRuntimeConfigurationTimer?.invalidate()
let reloadTimer = Timer(timeInterval: 1 /* second */, repeats: true) { [weak self] _ in
self?.reloadRuntimeConfiguration()
}
reloadRuntimeConfigurationTimer = reloadTimer
RunLoop.main.add(reloadTimer, forMode: .common)
}
func stopUpdatingRuntimeConfiguration() {
reloadRuntimeConfigurationTimer?.invalidate()
reloadRuntimeConfigurationTimer = nil
}
func applyTunnelConfiguration(tunnelConfiguration: TunnelConfiguration) {
// Incorporates changes from tunnelConfiguation. Ignores any changes in peer ordering.
guard let tableView = self.tableView else { return }
let sections = self.sections
let interfaceSectionIndex = sections.firstIndex {
if case .interface = $0 {
return true
} else {
return false
}
}!
let firstPeerSectionIndex = interfaceSectionIndex + 1
var interfaceFieldIsVisible = self.interfaceFieldIsVisible
var peerFieldIsVisible = self.peerFieldIsVisible
func handleSectionFieldsModified<T>(fields: [T], fieldIsVisible: [Bool], section: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) {
for (index, field) in fields.enumerated() {
guard let change = changes[field] else { continue }
if case .modified(let newValue) = change {
let row = fieldIsVisible[0 ..< index].filter { $0 }.count
let indexPath = IndexPath(row: row, section: section)
if let cell = tableView.cellForRow(at: indexPath) as? KeyValueCell {
cell.value = newValue
}
}
}
}
func handleSectionRowsInsertedOrRemoved<T>(fields: [T], fieldIsVisible fieldIsVisibleInput: [Bool], section: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) {
var fieldIsVisible = fieldIsVisibleInput
var removedIndexPaths = [IndexPath]()
for (index, field) in fields.enumerated().reversed() where changes[field] == .removed {
let row = fieldIsVisible[0 ..< index].filter { $0 }.count
removedIndexPaths.append(IndexPath(row: row, section: section))
fieldIsVisible[index] = false
}
if !removedIndexPaths.isEmpty {
tableView.deleteRows(at: removedIndexPaths, with: .automatic)
}
var addedIndexPaths = [IndexPath]()
for (index, field) in fields.enumerated() where changes[field] == .added {
let row = fieldIsVisible[0 ..< index].filter { $0 }.count
addedIndexPaths.append(IndexPath(row: row, section: section))
fieldIsVisible[index] = true
}
if !addedIndexPaths.isEmpty {
tableView.insertRows(at: addedIndexPaths, with: .automatic)
}
}
let changes = self.tunnelViewModel.applyConfiguration(other: tunnelConfiguration)
if !changes.interfaceChanges.isEmpty {
handleSectionFieldsModified(fields: TunnelDetailTableViewController.interfaceFields, fieldIsVisible: interfaceFieldIsVisible,
section: interfaceSectionIndex, changes: changes.interfaceChanges)
}
for (peerIndex, peerChanges) in changes.peerChanges {
handleSectionFieldsModified(fields: TunnelDetailTableViewController.peerFields, fieldIsVisible: peerFieldIsVisible[peerIndex], section: firstPeerSectionIndex + peerIndex, changes: peerChanges)
}
let isAnyInterfaceFieldAddedOrRemoved = changes.interfaceChanges.contains { $0.value == .added || $0.value == .removed }
let isAnyPeerFieldAddedOrRemoved = changes.peerChanges.contains { $0.changes.contains { $0.value == .added || $0.value == .removed } }
let peersRemovedSectionIndices = changes.peersRemovedIndices.map { firstPeerSectionIndex + $0 }
let peersInsertedSectionIndices = changes.peersInsertedIndices.map { firstPeerSectionIndex + $0 }
if isAnyInterfaceFieldAddedOrRemoved || isAnyPeerFieldAddedOrRemoved || !peersRemovedSectionIndices.isEmpty || !peersInsertedSectionIndices.isEmpty {
tableView.beginUpdates()
if isAnyInterfaceFieldAddedOrRemoved {
handleSectionRowsInsertedOrRemoved(fields: TunnelDetailTableViewController.interfaceFields, fieldIsVisible: interfaceFieldIsVisible, section: interfaceSectionIndex, changes: changes.interfaceChanges)
}
if isAnyPeerFieldAddedOrRemoved {
for (peerIndex, peerChanges) in changes.peerChanges {
handleSectionRowsInsertedOrRemoved(fields: TunnelDetailTableViewController.peerFields, fieldIsVisible: peerFieldIsVisible[peerIndex], section: firstPeerSectionIndex + peerIndex, changes: peerChanges)
}
}
if !peersRemovedSectionIndices.isEmpty {
tableView.deleteSections(IndexSet(peersRemovedSectionIndices), with: .automatic)
}
if !peersInsertedSectionIndices.isEmpty {
tableView.insertSections(IndexSet(peersInsertedSectionIndices), with: .automatic)
}
self.loadSections()
self.loadVisibleFields()
tableView.endUpdates()
} else {
self.loadSections()
self.loadVisibleFields()
}
}
private func reloadRuntimeConfiguration() {
tunnel.getRuntimeTunnelConfiguration { [weak self] tunnelConfiguration in
guard let tunnelConfiguration = tunnelConfiguration else { return }
guard let self = self else { return }
self.applyTunnelConfiguration(tunnelConfiguration: tunnelConfiguration)
}
}
private func updateActivateOnDemandFields() {
guard let onDemandSection = sections.firstIndex(where: { if case .onDemand = $0 { return true } else { return false } }) else { return }
let numberOfTableViewOnDemandRows = tableView.numberOfRows(inSection: onDemandSection)
let ssidRowIndexPath = IndexPath(row: 1, section: onDemandSection)
switch (numberOfTableViewOnDemandRows, onDemandViewModel.isWiFiInterfaceEnabled) {
case (1, true):
tableView.insertRows(at: [ssidRowIndexPath], with: .automatic)
case (2, false):
tableView.deleteRows(at: [ssidRowIndexPath], with: .automatic)
default:
break
}
tableView.reloadSections(IndexSet(integer: onDemandSection), with: .automatic)
}
}
extension TunnelDetailTableViewController: TunnelEditTableViewControllerDelegate {
func tunnelSaved(tunnel: TunnelContainer) {
tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration)
onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel)
loadSections()
loadVisibleFields()
title = tunnel.name
restorationIdentifier = "TunnelDetailVC:\(tunnel.name)"
tableView.reloadData()
}
func tunnelEditingCancelled() {
// Nothing to do
}
}
extension TunnelDetailTableViewController {
override func numberOfSections(in tableView: UITableView) -> Int {
return sections.count
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch sections[section] {
case .status:
return 1
case .interface:
return interfaceFieldIsVisible.filter { $0 }.count
case .peer(let peerIndex, _):
return peerFieldIsVisible[peerIndex].filter { $0 }.count
case .onDemand:
return onDemandViewModel.isWiFiInterfaceEnabled ? 2 : 1
case .delete:
return 1
}
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
switch sections[section] {
case .status:
return tr("tunnelSectionTitleStatus")
case .interface:
return tr("tunnelSectionTitleInterface")
case .peer:
return tr("tunnelSectionTitlePeer")
case .onDemand:
return tr("tunnelSectionTitleOnDemand")
case .delete:
return nil
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch sections[indexPath.section] {
case .status:
return statusCell(for: tableView, at: indexPath)
case .interface:
return interfaceCell(for: tableView, at: indexPath)
case .peer(let index, let peer):
return peerCell(for: tableView, at: indexPath, with: peer, peerIndex: index)
case .onDemand:
return onDemandCell(for: tableView, at: indexPath)
case .delete:
return deleteConfigurationCell(for: tableView, at: indexPath)
}
}
private func statusCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath)
let statusUpdate: (SwitchCell, TunnelStatus) -> Void = { cell, status in
let text: String
switch status {
case .inactive:
text = tr("tunnelStatusInactive")
case .activating:
text = tr("tunnelStatusActivating")
case .active:
text = tr("tunnelStatusActive")
case .deactivating:
text = tr("tunnelStatusDeactivating")
case .reasserting:
text = tr("tunnelStatusReasserting")
case .restarting:
text = tr("tunnelStatusRestarting")
case .waiting:
text = tr("tunnelStatusWaiting")
}
cell.textLabel?.text = text
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak cell] in
cell?.switchView.isOn = !(status == .deactivating || status == .inactive)
cell?.switchView.isUserInteractionEnabled = (status == .inactive || status == .active)
}
cell.isEnabled = status == .active || status == .inactive
}
statusUpdate(cell, tunnel.status)
cell.observationToken = tunnel.observe(\.status) { [weak cell] tunnel, _ in
guard let cell = cell else { return }
statusUpdate(cell, tunnel.status)
}
cell.onSwitchToggled = { [weak self] isOn in
guard let self = self else { return }
if isOn {
self.tunnelsManager.startActivation(of: self.tunnel)
} else {
self.tunnelsManager.startDeactivation(of: self.tunnel)
}
}
return cell
}
private func interfaceCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
let visibleInterfaceFields = TunnelDetailTableViewController.interfaceFields.enumerated().filter { interfaceFieldIsVisible[$0.offset] }.map { $0.element }
let field = visibleInterfaceFields[indexPath.row]
let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath)
cell.key = field.localizedUIString
cell.value = tunnelViewModel.interfaceData[field]
return cell
}
private func peerCell(for tableView: UITableView, at indexPath: IndexPath, with peerData: TunnelViewModel.PeerData, peerIndex: Int) -> UITableViewCell {
let visiblePeerFields = TunnelDetailTableViewController.peerFields.enumerated().filter { peerFieldIsVisible[peerIndex][$0.offset] }.map { $0.element }
let field = visiblePeerFields[indexPath.row]
let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath)
cell.key = field.localizedUIString
if field == .persistentKeepAlive {
cell.value = tr(format: "tunnelPeerPersistentKeepaliveValue (%@)", peerData[field])
} else if field == .preSharedKey {
cell.value = tr("tunnelPeerPresharedKeyEnabled")
} else {
cell.value = peerData[field]
}
return cell
}
private func onDemandCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
let field = TunnelDetailTableViewController.onDemandFields[indexPath.row]
if field == .onDemand {
let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath)
cell.key = field.localizedUIString
cell.value = onDemandViewModel.localizedInterfaceDescription
return cell
} else {
assert(field == .ssid)
if onDemandViewModel.ssidOption == .anySSID {
let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath)
cell.key = field.localizedUIString
cell.value = onDemandViewModel.ssidOption.localizedUIString
return cell
} else {
let cell: ChevronCell = tableView.dequeueReusableCell(for: indexPath)
cell.message = field.localizedUIString
cell.detailMessage = onDemandViewModel.localizedSSIDDescription
return cell
}
}
}
private func deleteConfigurationCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath)
cell.buttonText = tr("deleteTunnelButtonTitle")
cell.hasDestructiveAction = true
cell.onTapped = { [weak self] in
guard let self = self else { return }
ConfirmationAlertPresenter.showConfirmationAlert(message: tr("deleteTunnelConfirmationAlertMessage"),
buttonTitle: tr("deleteTunnelConfirmationAlertButtonTitle"),
from: cell, presentingVC: self) { [weak self] in
guard let self = self else { return }
self.tunnelsManager.remove(tunnel: self.tunnel) { error in
if error != nil {
print("Error removing tunnel: \(String(describing: error))")
return
}
}
}
}
return cell
}
}
extension TunnelDetailTableViewController {
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
if case .onDemand = sections[indexPath.section],
case .ssid = TunnelDetailTableViewController.onDemandFields[indexPath.row] {
return indexPath
}
return nil
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if case .onDemand = sections[indexPath.section],
case .ssid = TunnelDetailTableViewController.onDemandFields[indexPath.row] {
let ssidDetailVC = SSIDOptionDetailTableViewController(title: onDemandViewModel.ssidOption.localizedUIString, ssids: onDemandViewModel.selectedSSIDs)
navigationController?.pushViewController(ssidDetailVC, animated: true)
}
tableView.deselectRow(at: indexPath, animated: true)
}
}
@@ -0,0 +1,504 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
import WireGuardKit
protocol TunnelEditTableViewControllerDelegate: class {
func tunnelSaved(tunnel: TunnelContainer)
func tunnelEditingCancelled()
}
class TunnelEditTableViewController: UITableViewController {
private enum Section {
case interface
case peer(_ peer: TunnelViewModel.PeerData)
case addPeer
case onDemand
static func == (lhs: Section, rhs: Section) -> Bool {
switch (lhs, rhs) {
case (.interface, .interface),
(.addPeer, .addPeer),
(.onDemand, .onDemand):
return true
case let (.peer(peerA), .peer(peerB)):
return peerA.index == peerB.index
default:
return false
}
}
}
weak var delegate: TunnelEditTableViewControllerDelegate?
let interfaceFieldsBySection: [[TunnelViewModel.InterfaceField]] = [
[.name],
[.privateKey, .publicKey, .generateKeyPair],
[.addresses, .listenPort, .mtu, .dns]
]
let peerFields: [TunnelViewModel.PeerField] = [
.publicKey, .preSharedKey, .endpoint,
.allowedIPs, .excludePrivateIPs, .persistentKeepAlive,
.deletePeer
]
let onDemandFields: [ActivateOnDemandViewModel.OnDemandField] = [
.nonWiFiInterface,
.wiFiInterface,
.ssid
]
let tunnelsManager: TunnelsManager
let tunnel: TunnelContainer?
let tunnelViewModel: TunnelViewModel
var onDemandViewModel: ActivateOnDemandViewModel
private var sections = [Section]()
// Use this initializer to edit an existing tunnel.
init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) {
self.tunnelsManager = tunnelsManager
self.tunnel = tunnel
tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration)
onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel)
super.init(style: .grouped)
loadSections()
}
// Use this initializer to create a new tunnel.
init(tunnelsManager: TunnelsManager) {
self.tunnelsManager = tunnelsManager
tunnel = nil
tunnelViewModel = TunnelViewModel(tunnelConfiguration: nil)
onDemandViewModel = ActivateOnDemandViewModel()
super.init(style: .grouped)
loadSections()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
title = tunnel == nil ? tr("newTunnelViewTitle") : tr("editTunnelViewTitle")
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(saveTapped))
navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped))
tableView.estimatedRowHeight = 44
tableView.rowHeight = UITableView.automaticDimension
tableView.register(TunnelEditKeyValueCell.self)
tableView.register(TunnelEditEditableKeyValueCell.self)
tableView.register(ButtonCell.self)
tableView.register(SwitchCell.self)
tableView.register(ChevronCell.self)
}
private func loadSections() {
sections.removeAll()
interfaceFieldsBySection.forEach { _ in sections.append(.interface) }
tunnelViewModel.peersData.forEach { sections.append(.peer($0)) }
sections.append(.addPeer)
sections.append(.onDemand)
}
@objc func saveTapped() {
tableView.endEditing(false)
let tunnelSaveResult = tunnelViewModel.save()
switch tunnelSaveResult {
case .error(let errorMessage):
let alertTitle = (tunnelViewModel.interfaceData.validatedConfiguration == nil || tunnelViewModel.interfaceData.validatedName == nil) ?
tr("alertInvalidInterfaceTitle") : tr("alertInvalidPeerTitle")
ErrorPresenter.showErrorAlert(title: alertTitle, message: errorMessage, from: self)
tableView.reloadData() // Highlight erroring fields
case .saved(let tunnelConfiguration):
let onDemandOption = onDemandViewModel.toOnDemandOption()
if let tunnel = tunnel {
// We're modifying an existing tunnel
tunnelsManager.modify(tunnel: tunnel, tunnelConfiguration: tunnelConfiguration, onDemandOption: onDemandOption) { [weak self] error in
if let error = error {
ErrorPresenter.showErrorAlert(error: error, from: self)
} else {
self?.dismiss(animated: true, completion: nil)
self?.delegate?.tunnelSaved(tunnel: tunnel)
}
}
} else {
// We're adding a new tunnel
tunnelsManager.add(tunnelConfiguration: tunnelConfiguration, onDemandOption: onDemandOption) { [weak self] result in
switch result {
case .failure(let error):
ErrorPresenter.showErrorAlert(error: error, from: self)
case .success(let tunnel):
self?.dismiss(animated: true, completion: nil)
self?.delegate?.tunnelSaved(tunnel: tunnel)
}
}
}
}
}
@objc func cancelTapped() {
dismiss(animated: true, completion: nil)
delegate?.tunnelEditingCancelled()
}
}
// MARK: UITableViewDataSource
extension TunnelEditTableViewController {
override func numberOfSections(in tableView: UITableView) -> Int {
return sections.count
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
switch sections[section] {
case .interface:
return interfaceFieldsBySection[section].count
case .peer(let peerData):
let peerFieldsToShow = peerData.shouldAllowExcludePrivateIPsControl ? peerFields : peerFields.filter { $0 != .excludePrivateIPs }
return peerFieldsToShow.count
case .addPeer:
return 1
case .onDemand:
if onDemandViewModel.isWiFiInterfaceEnabled {
return 3
} else {
return 2
}
}
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
switch sections[section] {
case .interface:
return section == 0 ? tr("tunnelSectionTitleInterface") : nil
case .peer:
return tr("tunnelSectionTitlePeer")
case .addPeer:
return nil
case .onDemand:
return tr("tunnelSectionTitleOnDemand")
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
switch sections[indexPath.section] {
case .interface:
return interfaceFieldCell(for: tableView, at: indexPath)
case .peer(let peerData):
return peerCell(for: tableView, at: indexPath, with: peerData)
case .addPeer:
return addPeerCell(for: tableView, at: indexPath)
case .onDemand:
return onDemandCell(for: tableView, at: indexPath)
}
}
private func interfaceFieldCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
let field = interfaceFieldsBySection[indexPath.section][indexPath.row]
switch field {
case .generateKeyPair:
return generateKeyPairCell(for: tableView, at: indexPath, with: field)
case .publicKey:
return publicKeyCell(for: tableView, at: indexPath, with: field)
default:
return interfaceFieldKeyValueCell(for: tableView, at: indexPath, with: field)
}
}
private func generateKeyPairCell(for tableView: UITableView, at indexPath: IndexPath, with field: TunnelViewModel.InterfaceField) -> UITableViewCell {
let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath)
cell.buttonText = field.localizedUIString
cell.onTapped = { [weak self] in
guard let self = self else { return }
self.tunnelViewModel.interfaceData[.privateKey] = PrivateKey().base64Key
if let privateKeyRow = self.interfaceFieldsBySection[indexPath.section].firstIndex(of: .privateKey),
let publicKeyRow = self.interfaceFieldsBySection[indexPath.section].firstIndex(of: .publicKey) {
let privateKeyIndex = IndexPath(row: privateKeyRow, section: indexPath.section)
let publicKeyIndex = IndexPath(row: publicKeyRow, section: indexPath.section)
self.tableView.reloadRows(at: [privateKeyIndex, publicKeyIndex], with: .fade)
}
}
return cell
}
private func publicKeyCell(for tableView: UITableView, at indexPath: IndexPath, with field: TunnelViewModel.InterfaceField) -> UITableViewCell {
let cell: TunnelEditKeyValueCell = tableView.dequeueReusableCell(for: indexPath)
cell.key = field.localizedUIString
cell.value = tunnelViewModel.interfaceData[field]
return cell
}
private func interfaceFieldKeyValueCell(for tableView: UITableView, at indexPath: IndexPath, with field: TunnelViewModel.InterfaceField) -> UITableViewCell {
let cell: TunnelEditEditableKeyValueCell = tableView.dequeueReusableCell(for: indexPath)
cell.key = field.localizedUIString
switch field {
case .name, .privateKey:
cell.placeholderText = tr("tunnelEditPlaceholderTextRequired")
cell.keyboardType = .default
case .addresses:
cell.placeholderText = tr("tunnelEditPlaceholderTextStronglyRecommended")
cell.keyboardType = .numbersAndPunctuation
case .dns:
cell.placeholderText = tunnelViewModel.peersData.contains(where: { $0.shouldStronglyRecommendDNS }) ? tr("tunnelEditPlaceholderTextStronglyRecommended") : tr("tunnelEditPlaceholderTextOptional")
cell.keyboardType = .numbersAndPunctuation
case .listenPort, .mtu:
cell.placeholderText = tr("tunnelEditPlaceholderTextAutomatic")
cell.keyboardType = .numberPad
case .publicKey, .generateKeyPair:
cell.keyboardType = .default
case .status, .toggleStatus:
fatalError("Unexpected interface field")
}
cell.isValueValid = (!tunnelViewModel.interfaceData.fieldsWithError.contains(field))
// Bind values to view model
cell.value = tunnelViewModel.interfaceData[field]
if field == .dns { // While editing DNS, you might directly set exclude private IPs
cell.onValueBeingEdited = { [weak self] value in
self?.tunnelViewModel.interfaceData[field] = value
}
cell.onValueChanged = { [weak self] oldValue, newValue in
guard let self = self else { return }
let isAllowedIPsChanged = self.tunnelViewModel.updateDNSServersInAllowedIPsIfRequired(oldDNSServers: oldValue, newDNSServers: newValue)
if isAllowedIPsChanged {
let section = self.sections.firstIndex { if case .peer(_) = $0 { return true } else { return false } }
if let section = section, let row = self.peerFields.firstIndex(of: .allowedIPs) {
self.tableView.reloadRows(at: [IndexPath(row: row, section: section)], with: .none)
}
}
}
} else {
cell.onValueChanged = { [weak self] _, value in
self?.tunnelViewModel.interfaceData[field] = value
}
}
// Compute public key live
if field == .privateKey {
cell.onValueBeingEdited = { [weak self] value in
guard let self = self else { return }
self.tunnelViewModel.interfaceData[.privateKey] = value
if let row = self.interfaceFieldsBySection[indexPath.section].firstIndex(of: .publicKey) {
self.tableView.reloadRows(at: [IndexPath(row: row, section: indexPath.section)], with: .none)
}
}
}
return cell
}
private func peerCell(for tableView: UITableView, at indexPath: IndexPath, with peerData: TunnelViewModel.PeerData) -> UITableViewCell {
let peerFieldsToShow = peerData.shouldAllowExcludePrivateIPsControl ? peerFields : peerFields.filter { $0 != .excludePrivateIPs }
let field = peerFieldsToShow[indexPath.row]
switch field {
case .deletePeer:
return deletePeerCell(for: tableView, at: indexPath, peerData: peerData, field: field)
case .excludePrivateIPs:
return excludePrivateIPsCell(for: tableView, at: indexPath, peerData: peerData, field: field)
default:
return peerFieldKeyValueCell(for: tableView, at: indexPath, peerData: peerData, field: field)
}
}
private func deletePeerCell(for tableView: UITableView, at indexPath: IndexPath, peerData: TunnelViewModel.PeerData, field: TunnelViewModel.PeerField) -> UITableViewCell {
let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath)
cell.buttonText = field.localizedUIString
cell.hasDestructiveAction = true
cell.onTapped = { [weak self, weak peerData] in
guard let self = self, let peerData = peerData else { return }
ConfirmationAlertPresenter.showConfirmationAlert(message: tr("deletePeerConfirmationAlertMessage"),
buttonTitle: tr("deletePeerConfirmationAlertButtonTitle"),
from: cell, presentingVC: self) { [weak self] in
guard let self = self else { return }
let removedSectionIndices = self.deletePeer(peer: peerData)
let shouldShowExcludePrivateIPs = (self.tunnelViewModel.peersData.count == 1 && self.tunnelViewModel.peersData[0].shouldAllowExcludePrivateIPsControl)
//swiftlint:disable:next trailing_closure
tableView.performBatchUpdates({
self.tableView.deleteSections(removedSectionIndices, with: .fade)
if shouldShowExcludePrivateIPs {
if let row = self.peerFields.firstIndex(of: .excludePrivateIPs) {
let rowIndexPath = IndexPath(row: row, section: self.interfaceFieldsBySection.count /* First peer section */)
self.tableView.insertRows(at: [rowIndexPath], with: .fade)
}
}
})
}
}
return cell
}
private func excludePrivateIPsCell(for tableView: UITableView, at indexPath: IndexPath, peerData: TunnelViewModel.PeerData, field: TunnelViewModel.PeerField) -> UITableViewCell {
let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath)
cell.message = field.localizedUIString
cell.isEnabled = peerData.shouldAllowExcludePrivateIPsControl
cell.isOn = peerData.excludePrivateIPsValue
cell.onSwitchToggled = { [weak self] isOn in
guard let self = self else { return }
peerData.excludePrivateIPsValueChanged(isOn: isOn, dnsServers: self.tunnelViewModel.interfaceData[.dns])
if let row = self.peerFields.firstIndex(of: .allowedIPs) {
self.tableView.reloadRows(at: [IndexPath(row: row, section: indexPath.section)], with: .none)
}
}
return cell
}
private func peerFieldKeyValueCell(for tableView: UITableView, at indexPath: IndexPath, peerData: TunnelViewModel.PeerData, field: TunnelViewModel.PeerField) -> UITableViewCell {
let cell: TunnelEditEditableKeyValueCell = tableView.dequeueReusableCell(for: indexPath)
cell.key = field.localizedUIString
switch field {
case .publicKey:
cell.placeholderText = tr("tunnelEditPlaceholderTextRequired")
cell.keyboardType = .default
case .preSharedKey, .endpoint:
cell.placeholderText = tr("tunnelEditPlaceholderTextOptional")
cell.keyboardType = .default
case .allowedIPs:
cell.placeholderText = tr("tunnelEditPlaceholderTextOptional")
cell.keyboardType = .numbersAndPunctuation
case .persistentKeepAlive:
cell.placeholderText = tr("tunnelEditPlaceholderTextOff")
cell.keyboardType = .numberPad
case .excludePrivateIPs, .deletePeer:
cell.keyboardType = .default
case .rxBytes, .txBytes, .lastHandshakeTime:
fatalError()
}
cell.isValueValid = !peerData.fieldsWithError.contains(field)
cell.value = peerData[field]
if field == .allowedIPs {
let firstInterfaceSection = sections.firstIndex { $0 == .interface }!
let interfaceSubSection = interfaceFieldsBySection.firstIndex { $0.contains(.dns) }!
let dnsRow = interfaceFieldsBySection[interfaceSubSection].firstIndex { $0 == .dns }!
cell.onValueBeingEdited = { [weak self, weak peerData] value in
guard let self = self, let peerData = peerData else { return }
let oldValue = peerData.shouldAllowExcludePrivateIPsControl
peerData[.allowedIPs] = value
if oldValue != peerData.shouldAllowExcludePrivateIPsControl, let row = self.peerFields.firstIndex(of: .excludePrivateIPs) {
if peerData.shouldAllowExcludePrivateIPsControl {
self.tableView.insertRows(at: [IndexPath(row: row, section: indexPath.section)], with: .fade)
} else {
self.tableView.deleteRows(at: [IndexPath(row: row, section: indexPath.section)], with: .fade)
}
}
tableView.reloadRows(at: [IndexPath(row: dnsRow, section: firstInterfaceSection + interfaceSubSection)], with: .none)
}
} else {
cell.onValueChanged = { [weak peerData] _, value in
peerData?[field] = value
}
}
return cell
}
private func addPeerCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath)
cell.buttonText = tr("addPeerButtonTitle")
cell.onTapped = { [weak self] in
guard let self = self else { return }
let shouldHideExcludePrivateIPs = (self.tunnelViewModel.peersData.count == 1 && self.tunnelViewModel.peersData[0].shouldAllowExcludePrivateIPsControl)
let addedSectionIndices = self.appendEmptyPeer()
tableView.performBatchUpdates({
tableView.insertSections(addedSectionIndices, with: .fade)
if shouldHideExcludePrivateIPs {
if let row = self.peerFields.firstIndex(of: .excludePrivateIPs) {
let rowIndexPath = IndexPath(row: row, section: self.interfaceFieldsBySection.count /* First peer section */)
self.tableView.deleteRows(at: [rowIndexPath], with: .fade)
}
}
}, completion: nil)
}
return cell
}
private func onDemandCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell {
let field = onDemandFields[indexPath.row]
if indexPath.row < 2 {
let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath)
cell.message = field.localizedUIString
cell.isOn = onDemandViewModel.isEnabled(field: field)
cell.onSwitchToggled = { [weak self] isOn in
guard let self = self else { return }
self.onDemandViewModel.setEnabled(field: field, isEnabled: isOn)
let section = self.sections.firstIndex { $0 == .onDemand }!
let indexPath = IndexPath(row: 2, section: section)
if field == .wiFiInterface {
if isOn {
tableView.insertRows(at: [indexPath], with: .fade)
} else {
tableView.deleteRows(at: [indexPath], with: .fade)
}
}
}
return cell
} else {
let cell: ChevronCell = tableView.dequeueReusableCell(for: indexPath)
cell.message = field.localizedUIString
cell.detailMessage = onDemandViewModel.localizedSSIDDescription
return cell
}
}
func appendEmptyPeer() -> IndexSet {
tunnelViewModel.appendEmptyPeer()
loadSections()
let addedPeerIndex = tunnelViewModel.peersData.count - 1
return IndexSet(integer: interfaceFieldsBySection.count + addedPeerIndex)
}
func deletePeer(peer: TunnelViewModel.PeerData) -> IndexSet {
tunnelViewModel.deletePeer(peer: peer)
loadSections()
return IndexSet(integer: interfaceFieldsBySection.count + peer.index)
}
}
extension TunnelEditTableViewController {
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
if case .onDemand = sections[indexPath.section], indexPath.row == 2 {
return indexPath
} else {
return nil
}
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
switch sections[indexPath.section] {
case .onDemand:
assert(indexPath.row == 2)
tableView.deselectRow(at: indexPath, animated: true)
let ssidOptionVC = SSIDOptionEditTableViewController(option: onDemandViewModel.ssidOption, ssids: onDemandViewModel.selectedSSIDs)
ssidOptionVC.delegate = self
navigationController?.pushViewController(ssidOptionVC, animated: true)
default:
assertionFailure()
}
}
}
extension TunnelEditTableViewController: SSIDOptionEditTableViewControllerDelegate {
func ssidOptionSaved(option: ActivateOnDemandViewModel.OnDemandSSIDOption, ssids: [String]) {
onDemandViewModel.selectedSSIDs = ssids
onDemandViewModel.ssidOption = option
onDemandViewModel.fixSSIDOption()
if let onDemandSection = sections.firstIndex(where: { $0 == .onDemand }) {
if let ssidRowIndex = onDemandFields.firstIndex(of: .ssid) {
let indexPath = IndexPath(row: ssidRowIndex, section: onDemandSection)
tableView.reloadRows(at: [indexPath], with: .none)
}
}
}
}
@@ -0,0 +1,429 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import UIKit
import MobileCoreServices
import UserNotifications
import WireGuardKit
class TunnelsListTableViewController: UIViewController {
var tunnelsManager: TunnelsManager?
enum TableState: Equatable {
case normal
case rowSwiped
case multiSelect(selectionCount: Int)
}
let tableView: UITableView = {
let tableView = UITableView(frame: CGRect.zero, style: .plain)
tableView.estimatedRowHeight = 60
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .none
tableView.register(TunnelListCell.self)
return tableView
}()
let centeredAddButton: BorderedTextButton = {
let button = BorderedTextButton()
button.title = tr("tunnelsListCenteredAddTunnelButtonTitle")
button.isHidden = true
return button
}()
let busyIndicator: UIActivityIndicatorView = {
if #available(iOS 13.0, *) {
let busyIndicator = UIActivityIndicatorView(style: .medium)
busyIndicator.hidesWhenStopped = true
return busyIndicator
} else {
let busyIndicator = UIActivityIndicatorView(style: .gray)
busyIndicator.hidesWhenStopped = true
return busyIndicator
}
}()
var detailDisplayedTunnel: TunnelContainer?
var tableState: TableState = .normal {
didSet {
handleTableStateChange()
}
}
override func loadView() {
view = UIView()
if #available(iOS 13.0, *) {
view.backgroundColor = .systemBackground
} else {
view.backgroundColor = .white
}
tableView.dataSource = self
tableView.delegate = self
view.addSubview(tableView)
tableView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
tableView.topAnchor.constraint(equalTo: view.topAnchor),
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
])
view.addSubview(busyIndicator)
busyIndicator.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
busyIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
busyIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
view.addSubview(centeredAddButton)
centeredAddButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
centeredAddButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
centeredAddButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
])
centeredAddButton.onTapped = { [weak self] in
guard let self = self else { return }
self.addButtonTapped(sender: self.centeredAddButton)
}
busyIndicator.startAnimating()
}
override func viewDidLoad() {
super.viewDidLoad()
tableState = .normal
restorationIdentifier = "TunnelsListVC"
}
func handleTableStateChange() {
switch tableState {
case .normal:
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped(sender:)))
navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSettingsButtonTitle"), style: .plain, target: self, action: #selector(settingsButtonTapped(sender:)))
case .rowSwiped:
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButtonTapped))
navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSelectButtonTitle"), style: .plain, target: self, action: #selector(selectButtonTapped))
case .multiSelect(let selectionCount):
if selectionCount > 0 {
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonTapped))
navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListDeleteButtonTitle"), style: .plain, target: self, action: #selector(deleteButtonTapped(sender:)))
} else {
navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonTapped))
navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSelectAllButtonTitle"), style: .plain, target: self, action: #selector(selectAllButtonTapped))
}
}
if case .multiSelect(let selectionCount) = tableState, selectionCount > 0 {
navigationItem.title = tr(format: "tunnelsListSelectedTitle (%d)", selectionCount)
} else {
navigationItem.title = tr("tunnelsListTitle")
}
if case .multiSelect = tableState {
tableView.allowsMultipleSelectionDuringEditing = true
} else {
tableView.allowsMultipleSelectionDuringEditing = false
}
}
func setTunnelsManager(tunnelsManager: TunnelsManager) {
self.tunnelsManager = tunnelsManager
tunnelsManager.tunnelsListDelegate = self
busyIndicator.stopAnimating()
tableView.reloadData()
centeredAddButton.isHidden = tunnelsManager.numberOfTunnels() > 0
}
override func viewWillAppear(_: Bool) {
if let selectedRowIndexPath = tableView.indexPathForSelectedRow {
tableView.deselectRow(at: selectedRowIndexPath, animated: false)
}
}
@objc func addButtonTapped(sender: AnyObject) {
guard tunnelsManager != nil else { return }
let alert = UIAlertController(title: "", message: tr("addTunnelMenuHeader"), preferredStyle: .actionSheet)
let importFileAction = UIAlertAction(title: tr("addTunnelMenuImportFile"), style: .default) { [weak self] _ in
self?.presentViewControllerForFileImport()
}
alert.addAction(importFileAction)
let scanQRCodeAction = UIAlertAction(title: tr("addTunnelMenuQRCode"), style: .default) { [weak self] _ in
self?.presentViewControllerForScanningQRCode()
}
alert.addAction(scanQRCodeAction)
let createFromScratchAction = UIAlertAction(title: tr("addTunnelMenuFromScratch"), style: .default) { [weak self] _ in
if let self = self, let tunnelsManager = self.tunnelsManager {
self.presentViewControllerForTunnelCreation(tunnelsManager: tunnelsManager)
}
}
alert.addAction(createFromScratchAction)
let cancelAction = UIAlertAction(title: tr("actionCancel"), style: .cancel)
alert.addAction(cancelAction)
if let sender = sender as? UIBarButtonItem {
alert.popoverPresentationController?.barButtonItem = sender
} else if let sender = sender as? UIView {
alert.popoverPresentationController?.sourceView = sender
alert.popoverPresentationController?.sourceRect = sender.bounds
}
present(alert, animated: true, completion: nil)
}
@objc func settingsButtonTapped(sender: UIBarButtonItem) {
guard tunnelsManager != nil else { return }
let settingsVC = SettingsTableViewController(tunnelsManager: tunnelsManager)
let settingsNC = UINavigationController(rootViewController: settingsVC)
settingsNC.modalPresentationStyle = .formSheet
present(settingsNC, animated: true)
}
func presentViewControllerForTunnelCreation(tunnelsManager: TunnelsManager) {
let editVC = TunnelEditTableViewController(tunnelsManager: tunnelsManager)
let editNC = UINavigationController(rootViewController: editVC)
editNC.modalPresentationStyle = .fullScreen
present(editNC, animated: true)
}
func presentViewControllerForFileImport() {
let documentTypes = ["com.wireguard.config.quick", String(kUTTypeText), String(kUTTypeZipArchive)]
let filePicker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import)
filePicker.delegate = self
present(filePicker, animated: true)
}
func presentViewControllerForScanningQRCode() {
let scanQRCodeVC = QRScanViewController()
scanQRCodeVC.delegate = self
let scanQRCodeNC = UINavigationController(rootViewController: scanQRCodeVC)
scanQRCodeNC.modalPresentationStyle = .fullScreen
present(scanQRCodeNC, animated: true)
}
@objc func selectButtonTapped() {
let shouldCancelSwipe = tableState == .rowSwiped
tableState = .multiSelect(selectionCount: 0)
if shouldCancelSwipe {
tableView.setEditing(false, animated: false)
}
tableView.setEditing(true, animated: true)
}
@objc func doneButtonTapped() {
tableState = .normal
tableView.setEditing(false, animated: true)
}
@objc func selectAllButtonTapped() {
guard tableView.isEditing else { return }
guard let tunnelsManager = tunnelsManager else { return }
for index in 0 ..< tunnelsManager.numberOfTunnels() {
tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none)
}
tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0)
}
@objc func cancelButtonTapped() {
tableState = .normal
tableView.setEditing(false, animated: true)
}
@objc func deleteButtonTapped(sender: AnyObject?) {
guard let sender = sender as? UIBarButtonItem else { return }
guard let tunnelsManager = tunnelsManager else { return }
let selectedTunnelIndices = tableView.indexPathsForSelectedRows?.map { $0.row } ?? []
let selectedTunnels = selectedTunnelIndices.compactMap { tunnelIndex in
tunnelIndex >= 0 && tunnelIndex < tunnelsManager.numberOfTunnels() ? tunnelsManager.tunnel(at: tunnelIndex) : nil
}
guard !selectedTunnels.isEmpty else { return }
let message = selectedTunnels.count == 1 ?
tr(format: "deleteTunnelConfirmationAlertButtonMessage (%d)", selectedTunnels.count) :
tr(format: "deleteTunnelsConfirmationAlertButtonMessage (%d)", selectedTunnels.count)
let title = tr("deleteTunnelsConfirmationAlertButtonTitle")
ConfirmationAlertPresenter.showConfirmationAlert(message: message, buttonTitle: title,
from: sender, presentingVC: self) { [weak self] in
self?.tunnelsManager?.removeMultiple(tunnels: selectedTunnels) { [weak self] error in
guard let self = self else { return }
if let error = error {
ErrorPresenter.showErrorAlert(error: error, from: self)
return
}
self.tableState = .normal
self.tableView.setEditing(false, animated: true)
}
}
}
func showTunnelDetail(for tunnel: TunnelContainer, animated: Bool) {
guard let tunnelsManager = tunnelsManager else { return }
guard let splitViewController = splitViewController else { return }
guard let navController = navigationController else { return }
let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager,
tunnel: tunnel)
let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC)
tunnelDetailNC.restorationIdentifier = "DetailNC"
if splitViewController.isCollapsed && navController.viewControllers.count > 1 {
navController.setViewControllers([self, tunnelDetailNC], animated: animated)
} else {
splitViewController.showDetailViewController(tunnelDetailNC, sender: self, animated: animated)
}
detailDisplayedTunnel = tunnel
self.presentedViewController?.dismiss(animated: false, completion: nil)
}
}
extension TunnelsListTableViewController: UIDocumentPickerDelegate {
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let tunnelsManager = tunnelsManager else { return }
TunnelImporter.importFromFile(urls: urls, into: tunnelsManager, sourceVC: self, errorPresenterType: ErrorPresenter.self)
}
}
extension TunnelsListTableViewController: QRScanViewControllerDelegate {
func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController,
completionHandler: (() -> Void)?) {
tunnelsManager?.add(tunnelConfiguration: tunnelConfiguration) { result in
switch result {
case .failure(let error):
ErrorPresenter.showErrorAlert(error: error, from: qrScanViewController, onDismissal: completionHandler)
case .success:
completionHandler?()
}
}
}
}
extension TunnelsListTableViewController: UITableViewDataSource {
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return (tunnelsManager?.numberOfTunnels() ?? 0)
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell: TunnelListCell = tableView.dequeueReusableCell(for: indexPath)
if let tunnelsManager = tunnelsManager {
let tunnel = tunnelsManager.tunnel(at: indexPath.row)
cell.tunnel = tunnel
cell.onSwitchToggled = { [weak self] isOn in
guard let self = self, let tunnelsManager = self.tunnelsManager else { return }
if isOn {
tunnelsManager.startActivation(of: tunnel)
} else {
tunnelsManager.startDeactivation(of: tunnel)
}
}
}
return cell
}
}
extension TunnelsListTableViewController: UITableViewDelegate {
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard !tableView.isEditing else {
tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0)
return
}
guard let tunnelsManager = tunnelsManager else { return }
let tunnel = tunnelsManager.tunnel(at: indexPath.row)
showTunnelDetail(for: tunnel, animated: true)
}
func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
guard !tableView.isEditing else {
tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0)
return
}
}
func tableView(_ tableView: UITableView,
trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let deleteAction = UIContextualAction(style: .destructive, title: tr("tunnelsListSwipeDeleteButtonTitle")) { [weak self] _, _, completionHandler in
guard let tunnelsManager = self?.tunnelsManager else { return }
let tunnel = tunnelsManager.tunnel(at: indexPath.row)
tunnelsManager.remove(tunnel: tunnel) { error in
if error != nil {
ErrorPresenter.showErrorAlert(error: error!, from: self)
completionHandler(false)
} else {
completionHandler(true)
}
}
}
return UISwipeActionsConfiguration(actions: [deleteAction])
}
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
if tableState == .normal {
tableState = .rowSwiped
}
}
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
if tableState == .rowSwiped {
tableState = .normal
}
}
}
extension TunnelsListTableViewController: TunnelsManagerListDelegate {
func tunnelAdded(at index: Int) {
tableView.insertRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
centeredAddButton.isHidden = (tunnelsManager?.numberOfTunnels() ?? 0 > 0)
}
func tunnelModified(at index: Int) {
tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
}
func tunnelMoved(from oldIndex: Int, to newIndex: Int) {
tableView.moveRow(at: IndexPath(row: oldIndex, section: 0), to: IndexPath(row: newIndex, section: 0))
}
func tunnelRemoved(at index: Int, tunnel: TunnelContainer) {
tableView.deleteRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
centeredAddButton.isHidden = tunnelsManager?.numberOfTunnels() ?? 0 > 0
if detailDisplayedTunnel == tunnel, let splitViewController = splitViewController {
if splitViewController.isCollapsed != false {
(splitViewController.viewControllers[0] as? UINavigationController)?.popToRootViewController(animated: false)
} else {
let detailVC = UIViewController()
if #available(iOS 13.0, *) {
detailVC.view.backgroundColor = .systemBackground
} else {
detailVC.view.backgroundColor = .white
}
let detailNC = UINavigationController(rootViewController: detailVC)
splitViewController.showDetailViewController(detailNC, sender: self)
}
detailDisplayedTunnel = nil
if let presentedNavController = self.presentedViewController as? UINavigationController, presentedNavController.viewControllers.first is TunnelEditTableViewController {
self.presentedViewController?.dismiss(animated: false, completion: nil)
}
}
}
}
extension UISplitViewController {
func showDetailViewController(_ vc: UIViewController, sender: Any?, animated: Bool) {
if animated {
showDetailViewController(vc, sender: sender)
} else {
UIView.performWithoutAnimation {
showDetailViewController(vc, sender: sender)
}
}
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.networking.networkextension</key>
<array>
<string>packet-tunnel-provider</string>
</array>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.security.application-groups</key>
<array>
<string>group.$(APP_ID_IOS)</string>
</array>
</dict>
</plist>
@@ -0,0 +1,252 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import Cocoa
import ServiceManagement
import WireGuardKit
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
var tunnelsManager: TunnelsManager?
var tunnelsTracker: TunnelsTracker?
var statusItemController: StatusItemController?
var manageTunnelsRootVC: ManageTunnelsRootViewController?
var manageTunnelsWindowObject: NSWindow?
var onAppDeactivation: (() -> Void)?
func applicationWillFinishLaunching(_ notification: Notification) {
// To workaround a possible AppKit bug that causes the main menu to become unresponsive sometimes
// (especially when launched through Xcode) if we call setActivationPolicy(.regular) in
// in applicationDidFinishLaunching, we set it to .prohibited here.
// Setting it to .regular would fix that problem too, but at this point, we don't know
// whether the app was launched at login or not, so we're not sure whether we should
// show the app icon in the dock or not.
NSApp.setActivationPolicy(.prohibited)
}
func applicationDidFinishLaunching(_ aNotification: Notification) {
Logger.configureGlobal(tagged: "APP", withFilePath: FileManager.logFileURL?.path)
registerLoginItem(shouldLaunchAtLogin: true)
var isLaunchedAtLogin = false
if let appleEvent = NSAppleEventManager.shared().currentAppleEvent {
isLaunchedAtLogin = LaunchedAtLoginDetector.isLaunchedAtLogin(openAppleEvent: appleEvent)
}
NSApp.mainMenu = MainMenu()
setDockIconAndMainMenuVisibility(isVisible: !isLaunchedAtLogin)
TunnelsManager.create { [weak self] result in
guard let self = self else { return }
switch result {
case .failure(let error):
ErrorPresenter.showErrorAlert(error: error, from: nil)
case .success(let tunnelsManager):
let statusMenu = StatusMenu(tunnelsManager: tunnelsManager)
statusMenu.windowDelegate = self
let statusItemController = StatusItemController()
statusItemController.statusItem.menu = statusMenu
let tunnelsTracker = TunnelsTracker(tunnelsManager: tunnelsManager)
tunnelsTracker.statusMenu = statusMenu
tunnelsTracker.statusItemController = statusItemController
self.tunnelsManager = tunnelsManager
self.tunnelsTracker = tunnelsTracker
self.statusItemController = statusItemController
if !isLaunchedAtLogin {
self.showManageTunnelsWindow(completion: nil)
}
}
}
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool {
if let appleEvent = NSAppleEventManager.shared().currentAppleEvent {
if LaunchedAtLoginDetector.isReopenedByLoginItemHelper(reopenAppleEvent: appleEvent) {
return false
}
}
if hasVisibleWindows {
return true
}
showManageTunnelsWindow(completion: nil)
return false
}
@objc func confirmAndQuit() {
let alert = NSAlert()
alert.messageText = tr("macConfirmAndQuitAlertMessage")
if let currentTunnel = tunnelsTracker?.currentTunnel, currentTunnel.status == .active || currentTunnel.status == .activating {
alert.informativeText = tr(format: "macConfirmAndQuitInfoWithActiveTunnel (%@)", currentTunnel.name)
} else {
alert.informativeText = tr("macConfirmAndQuitAlertInfo")
}
alert.addButton(withTitle: tr("macConfirmAndQuitAlertCloseWindow"))
alert.addButton(withTitle: tr("macConfirmAndQuitAlertQuitWireGuard"))
NSApp.activate(ignoringOtherApps: true)
if let manageWindow = manageTunnelsWindowObject {
manageWindow.orderFront(self)
alert.beginSheetModal(for: manageWindow) { response in
switch response {
case .alertFirstButtonReturn:
manageWindow.close()
case .alertSecondButtonReturn:
NSApp.terminate(nil)
default:
break
}
}
}
}
@objc func quit() {
if let manageWindow = manageTunnelsWindowObject, manageWindow.attachedSheet != nil {
NSApp.activate(ignoringOtherApps: true)
manageWindow.orderFront(self)
return
}
registerLoginItem(shouldLaunchAtLogin: false)
guard let currentTunnel = tunnelsTracker?.currentTunnel, currentTunnel.status == .active || currentTunnel.status == .activating else {
NSApp.terminate(nil)
return
}
let alert = NSAlert()
alert.messageText = tr("macAppExitingWithActiveTunnelMessage")
alert.informativeText = tr("macAppExitingWithActiveTunnelInfo")
NSApp.activate(ignoringOtherApps: true)
if let manageWindow = manageTunnelsWindowObject {
manageWindow.orderFront(self)
alert.beginSheetModal(for: manageWindow) { _ in
NSApp.terminate(nil)
}
} else {
alert.runModal()
NSApp.terminate(nil)
}
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard let currentTunnel = tunnelsTracker?.currentTunnel, currentTunnel.status == .active || currentTunnel.status == .activating else {
return .terminateNow
}
guard let appleEvent = NSAppleEventManager.shared().currentAppleEvent else {
return .terminateNow
}
guard MacAppStoreUpdateDetector.isUpdatingFromMacAppStore(quitAppleEvent: appleEvent) else {
return .terminateNow
}
let alert = NSAlert()
alert.messageText = tr("macAppStoreUpdatingAlertMessage")
if currentTunnel.isActivateOnDemandEnabled {
alert.informativeText = tr(format: "macAppStoreUpdatingAlertInfoWithOnDemand (%@)", currentTunnel.name)
} else {
alert.informativeText = tr(format: "macAppStoreUpdatingAlertInfoWithoutOnDemand (%@)", currentTunnel.name)
}
NSApp.activate(ignoringOtherApps: true)
if let manageWindow = manageTunnelsWindowObject {
alert.beginSheetModal(for: manageWindow) { _ in }
} else {
alert.runModal()
}
return .terminateCancel
}
func applicationShouldTerminateAfterLastWindowClosed(_ application: NSApplication) -> Bool {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak self] in
self?.setDockIconAndMainMenuVisibility(isVisible: false)
}
return false
}
private func setDockIconAndMainMenuVisibility(isVisible: Bool, completion: (() -> Void)? = nil) {
let currentActivationPolicy = NSApp.activationPolicy()
let newActivationPolicy: NSApplication.ActivationPolicy = isVisible ? .regular : .accessory
guard currentActivationPolicy != newActivationPolicy else {
if newActivationPolicy == .regular {
NSApp.activate(ignoringOtherApps: true)
}
completion?()
return
}
if newActivationPolicy == .regular && NSApp.isActive {
// To workaround a possible AppKit bug that causes the main menu to become unresponsive,
// we should deactivate the app first and then set the activation policy.
// NSApp.deactivate() doesn't always deactivate the app, so we instead use
// setActivationPolicy(.prohibited).
onAppDeactivation = {
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
completion?()
}
NSApp.setActivationPolicy(.prohibited)
} else {
NSApp.setActivationPolicy(newActivationPolicy)
if newActivationPolicy == .regular {
NSApp.activate(ignoringOtherApps: true)
}
completion?()
}
}
func applicationDidResignActive(_ notification: Notification) {
onAppDeactivation?()
onAppDeactivation = nil
}
}
extension AppDelegate {
@objc func aboutClicked() {
var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
if let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String {
appVersion += " (\(appBuild))"
}
let appVersionString = [
tr(format: "macAppVersion (%@)", appVersion),
tr(format: "macGoBackendVersion (%@)", wireGuardVersion)
].joined(separator: "\n")
let donateString = NSMutableAttributedString(string: tr("donateLink"))
donateString.addAttribute(.link, value: "https://www.wireguard.com/donations/", range: NSRange(location: 0, length: donateString.length))
NSApp.activate(ignoringOtherApps: true)
NSApp.orderFrontStandardAboutPanel(options: [
.applicationVersion: appVersionString,
.version: "",
.credits: donateString
])
}
}
extension AppDelegate: StatusMenuWindowDelegate {
func showManageTunnelsWindow(completion: ((NSWindow?) -> Void)?) {
guard let tunnelsManager = tunnelsManager else {
completion?(nil)
return
}
if manageTunnelsWindowObject == nil {
manageTunnelsRootVC = ManageTunnelsRootViewController(tunnelsManager: tunnelsManager)
let window = NSWindow(contentViewController: manageTunnelsRootVC!)
window.title = tr("macWindowTitleManageTunnels")
window.setContentSize(NSSize(width: 800, height: 480))
window.setFrameAutosaveName(NSWindow.FrameAutosaveName("ManageTunnelsWindow")) // Auto-save window position and size
manageTunnelsWindowObject = window
tunnelsTracker?.manageTunnelsRootVC = manageTunnelsRootVC
}
setDockIconAndMainMenuVisibility(isVisible: true) { [weak manageTunnelsWindowObject] in
manageTunnelsWindowObject?.makeKeyAndOrderFront(self)
completion?(manageTunnelsWindowObject)
}
}
}
@discardableResult
func registerLoginItem(shouldLaunchAtLogin: Bool) -> Bool {
let appId = Bundle.main.bundleIdentifier!
let helperBundleId = "\(appId).login-item-helper"
return SMLoginItemSetEnabled(helperBundleId as CFString, shouldLaunchAtLogin)
}
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
// Copyright © 2018-2019 WireGuard LLC. All Rights Reserved.
import Cocoa
class Application: NSApplication {
private var appDelegate: AppDelegate? //swiftlint:disable:this weak_delegate
override init() {
super.init()
appDelegate = AppDelegate() // Keep a strong reference to the app delegate
delegate = appDelegate // Set delegate before app.run() gets called in NSApplicationMain()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
@@ -0,0 +1,68 @@
{
"images" : [
{
"size" : "16x16",
"idiom" : "mac",
"filename" : "WireGuardMacAppIcon16.png",
"scale" : "1x"
},
{
"size" : "16x16",
"idiom" : "mac",
"filename" : "WireGuardMacAppIcon32.png",
"scale" : "2x"
},
{
"size" : "32x32",
"idiom" : "mac",
"filename" : "WireGuardMacAppIcon32-1.png",
"scale" : "1x"
},
{
"size" : "32x32",
"idiom" : "mac",
"filename" : "WireGuardMacAppIcon64.png",
"scale" : "2x"
},
{
"size" : "128x128",
"idiom" : "mac",
"filename" : "WireGuardMacAppIcon128.png",
"scale" : "1x"
},
{
"size" : "128x128",
"idiom" : "mac",
"filename" : "WireGuardMacAppIcon256.png",
"scale" : "2x"
},
{
"size" : "256x256",
"idiom" : "mac",
"filename" : "WireGuardMacAppIcon256-1.png",
"scale" : "1x"
},
{
"size" : "256x256",
"idiom" : "mac",
"filename" : "WireGuardMacAppIcon512.png",
"scale" : "2x"
},
{
"size" : "512x512",
"idiom" : "mac",
"filename" : "WireGuardMacAppIcon512-1.png",
"scale" : "1x"
},
{
"size" : "512x512",
"idiom" : "mac",
"filename" : "WireGuardMacAppIcon.png",
"scale" : "2x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,26 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "StatusBarIcon@1x.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "StatusBarIcon@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "StatusBarIcon@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 978 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,26 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "StatusBarIconDimmed@1x.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "StatusBarIconDimmed@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "StatusBarIconDimmed@3x.png",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
},
"properties" : {
"template-rendering-intent" : "template"
}
}

Some files were not shown because too many files have changed in this diff Show More