From cb051f695db44e7a52e3f423fa27de00c493a9ac Mon Sep 17 00:00:00 2001 From: Eric Kuck Date: Fri, 14 Dec 2018 17:27:11 -0600 Subject: Reorganized project structure Signed-off-by: Eric Kuck --- WireGuard/WireGuard.xcodeproj/project.pbxproj | 46 +- WireGuard/WireGuard/UI/TunnelViewModel.swift | 1 + .../TunnelEditReadOnlyKeyValueCell.swift | 74 ---- .../EditTunnel/TunnelEditTableViewController.swift | 488 --------------------- .../WireGuard/UI/iOS/MainViewController.swift | 129 ------ .../WireGuard/UI/iOS/QRScanViewController.swift | 159 ------- .../UI/iOS/SettingsTableViewController.swift | 200 --------- .../UI/iOS/SharedViews/BorderedTextButton.swift | 50 --- .../WireGuard/UI/iOS/SharedViews/ButtonCell.swift | 55 --- .../UI/iOS/SharedViews/CheckmarkCell.swift | 31 -- .../SharedViews/CopyableLabelTableViewCell.swift | 55 --- .../UI/iOS/SharedViews/EditableKeyValueCell.swift | 158 ------- .../UI/iOS/SharedViews/KeyValueCell.swift | 107 ----- .../UI/iOS/SharedViews/ScrollableLabel.swift | 48 -- .../WireGuard/UI/iOS/SharedViews/SwitchCell.swift | 48 -- .../TunnelDetailTableViewController.swift | 260 ----------- .../UI/iOS/TunnelList/TunnelListCell.swift | 111 ----- .../TunnelsListTableViewController.swift | 307 ------------- .../WireGuard/UI/iOS/View/BorderedTextButton.swift | 50 +++ WireGuard/WireGuard/UI/iOS/View/ButtonCell.swift | 55 +++ .../WireGuard/UI/iOS/View/CheckmarkCell.swift | 31 ++ .../UI/iOS/View/CopyableLabelTableViewCell.swift | 55 +++ .../UI/iOS/View/EditableKeyValueCell.swift | 158 +++++++ WireGuard/WireGuard/UI/iOS/View/KeyValueCell.swift | 107 +++++ .../WireGuard/UI/iOS/View/ScrollableLabel.swift | 48 ++ WireGuard/WireGuard/UI/iOS/View/SwitchCell.swift | 48 ++ .../iOS/View/TunnelEditReadOnlyKeyValueCell.swift | 74 ++++ .../WireGuard/UI/iOS/View/TunnelListCell.swift | 111 +++++ .../UI/iOS/ViewController/MainViewController.swift | 129 ++++++ .../iOS/ViewController/QRScanViewController.swift | 159 +++++++ .../SettingsTableViewController.swift | 200 +++++++++ .../TunnelDetailTableViewController.swift | 260 +++++++++++ .../TunnelEditTableViewController.swift | 488 +++++++++++++++++++++ .../TunnelsListTableViewController.swift | 307 +++++++++++++ 34 files changed, 2300 insertions(+), 2307 deletions(-) delete mode 100644 WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditReadOnlyKeyValueCell.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditTableViewController.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/MainViewController.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/QRScanViewController.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/SettingsTableViewController.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/SharedViews/BorderedTextButton.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/SharedViews/ButtonCell.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/SharedViews/CheckmarkCell.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/SharedViews/CopyableLabelTableViewCell.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/SharedViews/EditableKeyValueCell.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/SharedViews/KeyValueCell.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/SharedViews/ScrollableLabel.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/SharedViews/SwitchCell.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailTableViewController.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/TunnelList/TunnelListCell.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/TunnelList/TunnelsListTableViewController.swift create mode 100644 WireGuard/WireGuard/UI/iOS/View/BorderedTextButton.swift create mode 100644 WireGuard/WireGuard/UI/iOS/View/ButtonCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/View/CheckmarkCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/View/CopyableLabelTableViewCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/View/EditableKeyValueCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/View/KeyValueCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/View/ScrollableLabel.swift create mode 100644 WireGuard/WireGuard/UI/iOS/View/SwitchCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/View/TunnelEditReadOnlyKeyValueCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/View/TunnelListCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/ViewController/MainViewController.swift create mode 100644 WireGuard/WireGuard/UI/iOS/ViewController/QRScanViewController.swift create mode 100644 WireGuard/WireGuard/UI/iOS/ViewController/SettingsTableViewController.swift create mode 100644 WireGuard/WireGuard/UI/iOS/ViewController/TunnelDetailTableViewController.swift create mode 100644 WireGuard/WireGuard/UI/iOS/ViewController/TunnelEditTableViewController.swift create mode 100644 WireGuard/WireGuard/UI/iOS/ViewController/TunnelsListTableViewController.swift (limited to 'WireGuard') diff --git a/WireGuard/WireGuard.xcodeproj/project.pbxproj b/WireGuard/WireGuard.xcodeproj/project.pbxproj index 311a1b8..dec7701 100644 --- a/WireGuard/WireGuard.xcodeproj/project.pbxproj +++ b/WireGuard/WireGuard.xcodeproj/project.pbxproj @@ -128,7 +128,7 @@ 6F61F1EA21B937EF00483816 /* WireGuardResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireGuardResult.swift; sourceTree = ""; }; 6F628C3C217F09E9003482A3 /* TunnelViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelViewModel.swift; sourceTree = ""; }; 6F628C3E217F3413003482A3 /* DNSServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DNSServer.swift; sourceTree = ""; }; - 6F628C40217F47DB003482A3 /* TunnelDetailTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = TunnelDetailTableViewController.swift; path = TunnelDetail/TunnelDetailTableViewController.swift; sourceTree = ""; }; + 6F628C40217F47DB003482A3 /* TunnelDetailTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TunnelDetailTableViewController.swift; sourceTree = ""; }; 6F689999218043390012E523 /* WireGuard-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "WireGuard-Bridging-Header.h"; sourceTree = ""; }; 6F6899A42180447E0012E523 /* x25519.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = x25519.h; sourceTree = ""; }; 6F6899A52180447E0012E523 /* x25519.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = x25519.c; sourceTree = ""; }; @@ -194,27 +194,11 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 5F45418621C2C6B400994C13 /* EditTunnel */ = { + 5F4541A721C44F5B00994C13 /* View */ = { isa = PBXGroup; children = ( - 6F7774F221774263006A79B3 /* TunnelEditTableViewController.swift */, - 5F45418B21C2D48200994C13 /* TunnelEditReadOnlyKeyValueCell.swift */, - ); - path = EditTunnel; - sourceTree = ""; - }; - 5F45418821C2C6CC00994C13 /* TunnelList */ = { - isa = PBXGroup; - children = ( - 6F7774E321718281006A79B3 /* TunnelsListTableViewController.swift */, 5F45419F21C2D6B700994C13 /* TunnelListCell.swift */, - ); - path = TunnelList; - sourceTree = ""; - }; - 5F4541A721C44F5B00994C13 /* SharedViews */ = { - isa = PBXGroup; - children = ( + 5F45418B21C2D48200994C13 /* TunnelEditReadOnlyKeyValueCell.swift */, 6BB8400321892C920003598F /* CopyableLabelTableViewCell.swift */, 6F0068562191AFD200419BE9 /* ScrollableLabel.swift */, 5F45418F21C2D53800994C13 /* SwitchCell.swift */, @@ -224,7 +208,20 @@ 5F45418921C2D45B00994C13 /* EditableKeyValueCell.swift */, 5F4541A121C2D6DF00994C13 /* BorderedTextButton.swift */, ); - path = SharedViews; + path = View; + sourceTree = ""; + }; + 5F4541AC21C4720B00994C13 /* ViewController */ = { + isa = PBXGroup; + children = ( + 6F7774E321718281006A79B3 /* TunnelsListTableViewController.swift */, + 6F7774F221774263006A79B3 /* TunnelEditTableViewController.swift */, + 6FDEF7E52185EFAF00D8FBF6 /* QRScanViewController.swift */, + 6F628C40217F47DB003482A3 /* TunnelDetailTableViewController.swift */, + 6FDEF805218725D200D8FBF6 /* SettingsTableViewController.swift */, + 6F7774DF217181B1006A79B3 /* MainViewController.swift */, + ); + path = ViewController; sourceTree = ""; }; 6F5D0C1B218352EF000F85AD /* WireGuardNetworkExtension */ = { @@ -283,14 +280,9 @@ 6F7774DE217181B1006A79B3 /* iOS */ = { isa = PBXGroup; children = ( - 5F4541A721C44F5B00994C13 /* SharedViews */, - 5F45418821C2C6CC00994C13 /* TunnelList */, - 5F45418621C2C6B400994C13 /* EditTunnel */, - 6FDEF7E52185EFAF00D8FBF6 /* QRScanViewController.swift */, - 6F628C40217F47DB003482A3 /* TunnelDetailTableViewController.swift */, - 6FDEF805218725D200D8FBF6 /* SettingsTableViewController.swift */, + 5F4541AC21C4720B00994C13 /* ViewController */, + 5F4541A721C44F5B00994C13 /* View */, 6F7774E0217181B1006A79B3 /* AppDelegate.swift */, - 6F7774DF217181B1006A79B3 /* MainViewController.swift */, 6F919EC2218A2AE90023B400 /* ErrorPresenter.swift */, 5F45417C21C1B23600994C13 /* UITableViewCell+Reuse.swift */, ); diff --git a/WireGuard/WireGuard/UI/TunnelViewModel.swift b/WireGuard/WireGuard/UI/TunnelViewModel.swift index c9b1cf1..d542f98 100644 --- a/WireGuard/WireGuard/UI/TunnelViewModel.swift +++ b/WireGuard/WireGuard/UI/TunnelViewModel.swift @@ -3,6 +3,7 @@ import UIKit +//swiftlint:disable:next type_body_length class TunnelViewModel { enum InterfaceField: String { diff --git a/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditReadOnlyKeyValueCell.swift b/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditReadOnlyKeyValueCell.swift deleted file mode 100644 index 15d58d6..0000000 --- a/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditReadOnlyKeyValueCell.swift +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit - -class TunnelEditReadOnlyKeyValueCell: CopyableLabelTableViewCell { - var key: String { - get { return keyLabel.text ?? "" } - set(value) { keyLabel.text = value } - } - var value: String { - get { return valueLabel.text } - set(value) { valueLabel.text = value } - } - - override var textToCopy: String? { - return valueLabel.text - } - - let keyLabel: UILabel = { - let keyLabel = UILabel() - keyLabel.font = UIFont.preferredFont(forTextStyle: .body) - keyLabel.adjustsFontForContentSizeCategory = true - keyLabel.textColor = .gray - return keyLabel - }() - - let valueLabel: ScrollableLabel = { - let valueLabel = ScrollableLabel() - valueLabel.label.font = UIFont.preferredFont(forTextStyle: .body) - valueLabel.label.adjustsFontForContentSizeCategory = true - valueLabel.textColor = .gray - return valueLabel - }() - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - contentView.addSubview(keyLabel) - keyLabel.translatesAutoresizingMaskIntoConstraints = false - keyLabel.textAlignment = .right - let widthRatioConstraint = NSLayoutConstraint(item: keyLabel, attribute: .width, - relatedBy: .equal, - toItem: self, attribute: .width, - multiplier: 0.4, constant: 0) - // In case the key doesn't fit into 0.4 * width, - // so set a CR priority > the 0.4-constraint's priority. - widthRatioConstraint.priority = .defaultHigh + 1 - keyLabel.setContentCompressionResistancePriority(.defaultHigh + 2, for: .horizontal) - NSLayoutConstraint.activate([ - keyLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), - keyLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), - widthRatioConstraint - ]) - - contentView.addSubview(valueLabel) - valueLabel.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - valueLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), - valueLabel.leftAnchor.constraint(equalToSystemSpacingAfter: keyLabel.rightAnchor, multiplier: 1), - valueLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor) - ]) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - key = "" - value = "" - } -} diff --git a/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditTableViewController.swift b/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditTableViewController.swift deleted file mode 100644 index 393294e..0000000 --- a/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditTableViewController.swift +++ /dev/null @@ -1,488 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit - -protocol TunnelEditTableViewControllerDelegate: class { - func tunnelSaved(tunnel: TunnelContainer) - func tunnelEditingCancelled() -} - -// MARK: TunnelEditTableViewController - -class TunnelEditTableViewController: UITableViewController { - - private enum Section { - case interface - case peer(_ peer: TunnelViewModel.PeerData) - case addPeer - case onDemand - } - - weak var delegate: TunnelEditTableViewControllerDelegate? - - let interfaceFieldsBySection: [[TunnelViewModel.InterfaceField]] = [ - [.name], - [.privateKey, .publicKey, .generateKeyPair], - [.addresses, .listenPort, .mtu, .dns] - ] - - let peerFields: [TunnelViewModel.PeerField] = [ - .publicKey, .preSharedKey, .endpoint, - .allowedIPs, .excludePrivateIPs, .persistentKeepAlive, - .deletePeer - ] - - let activateOnDemandOptions: [ActivateOnDemandOption] = [ - .useOnDemandOverWiFiOrCellular, - .useOnDemandOverWiFiOnly, - .useOnDemandOverCellularOnly - ] - - let tunnelsManager: TunnelsManager - let tunnel: TunnelContainer? - let tunnelViewModel: TunnelViewModel - var activateOnDemandSetting: ActivateOnDemandSetting - private var sections = [Section]() - - init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) { - // Use this initializer to edit an existing tunnel. - self.tunnelsManager = tunnelsManager - self.tunnel = tunnel - tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration()) - activateOnDemandSetting = tunnel.activateOnDemandSetting() - super.init(style: .grouped) - loadSections() - } - - init(tunnelsManager: TunnelsManager, tunnelConfiguration: TunnelConfiguration?) { - // Use this initializer to create a new tunnel. - // If tunnelConfiguration is passed, data will be prepopulated from that configuration. - self.tunnelsManager = tunnelsManager - tunnel = nil - tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnelConfiguration) - activateOnDemandSetting = ActivateOnDemandSetting.defaultSetting - super.init(style: .grouped) - loadSections() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - title = tunnel == nil ? "New configuration" : "Edit configuration" - 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(EditableKeyValueCell.self) - tableView.register(TunnelEditReadOnlyKeyValueCell.self) - tableView.register(ButtonCell.self) - tableView.register(SwitchCell.self) - tableView.register(CheckmarkCell.self) - } - - private func loadSections() { - sections.removeAll() - interfaceFieldsBySection.forEach { _ in sections.append(.interface) } - tunnelViewModel.peersData.forEach { sections.append(.peer($0)) } - sections.append(.addPeer) - sections.append(.onDemand) - } - - @objc func saveTapped() { - tableView.endEditing(false) - let tunnelSaveResult = tunnelViewModel.save() - switch tunnelSaveResult { - case .error(let errorMessage): - let erroringConfiguration = (tunnelViewModel.interfaceData.validatedConfiguration == nil) ? "Interface" : "Peer" - ErrorPresenter.showErrorAlert(title: "Invalid \(erroringConfiguration)", message: errorMessage, from: self) - tableView.reloadData() // Highlight erroring fields - case .saved(let tunnelConfiguration): - if let tunnel = tunnel { - // We're modifying an existing tunnel - tunnelsManager.modify(tunnel: tunnel, - tunnelConfiguration: tunnelConfiguration, - activateOnDemandSetting: activateOnDemandSetting) { [weak self] error in - if let error = error { - ErrorPresenter.showErrorAlert(error: error, from: self) - } else { - self?.dismiss(animated: true, completion: nil) - self?.delegate?.tunnelSaved(tunnel: tunnel) - } - } - } else { - // We're adding a new tunnel - tunnelsManager.add(tunnelConfiguration: tunnelConfiguration, - activateOnDemandSetting: activateOnDemandSetting) { [weak self] result in - if let error = result.error { - ErrorPresenter.showErrorAlert(error: error, from: self) - } else { - let tunnel: TunnelContainer = result.value! - self?.dismiss(animated: true, completion: nil) - self?.delegate?.tunnelSaved(tunnel: tunnel) - } - } - } - } - } - - @objc func cancelTapped() { - dismiss(animated: true, completion: nil) - delegate?.tunnelEditingCancelled() - } -} - -// MARK: UITableViewDataSource - -extension TunnelEditTableViewController { - override func numberOfSections(in tableView: UITableView) -> Int { - return sections.count - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch sections[section] { - case .interface: - return interfaceFieldsBySection[section].count - case .peer(let peerData): - let peerFieldsToShow = peerData.shouldAllowExcludePrivateIPsControl ? peerFields : peerFields.filter { $0 != .excludePrivateIPs } - return peerFieldsToShow.count - case .addPeer: - return 1 - case .onDemand: - if activateOnDemandSetting.isActivateOnDemandEnabled { - return 4 - } else { - return 1 - } - } - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - switch sections[section] { - case .interface: - return section == 0 ? "Interface" : nil - case .peer: - return "Peer" - case .addPeer: - return nil - case .onDemand: - return "On-Demand Activation" - } - } - - 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.rawValue - cell.onTapped = { [weak self] in - guard let self = self else { return } - - self.tunnelViewModel.interfaceData[.privateKey] = Curve25519.generatePrivateKey().base64EncodedString() - 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: TunnelEditReadOnlyKeyValueCell = tableView.dequeueReusableCell(for: indexPath) - cell.key = field.rawValue - cell.value = tunnelViewModel.interfaceData[field] - return cell - } - - private func interfaceFieldKeyValueCell(for tableView: UITableView, at indexPath: IndexPath, with field: TunnelViewModel.InterfaceField) -> UITableViewCell { - let cell: EditableKeyValueCell = tableView.dequeueReusableCell(for: indexPath) - cell.key = field.rawValue - - switch field { - case .name, .privateKey: - cell.placeholderText = "Required" - cell.keyboardType = .default - case .addresses, .dns: - cell.placeholderText = "Optional" - cell.keyboardType = .numbersAndPunctuation - case .listenPort, .mtu: - cell.placeholderText = "Automatic" - cell.keyboardType = .numberPad - case .publicKey, .generateKeyPair: - cell.keyboardType = .default - } - - cell.isValueValid = (!tunnelViewModel.interfaceData.fieldsWithError.contains(field)) - // Bind values to view model - cell.value = tunnelViewModel.interfaceData[field] - if field == .dns { // While editing DNS, you might directly set exclude private IPs - cell.onValueChanged = nil - cell.onValueBeingEdited = { [weak self] value in - self?.tunnelViewModel.interfaceData[field] = value - } - } else { - cell.onValueChanged = { [weak self] value in - self?.tunnelViewModel.interfaceData[field] = value - } - cell.onValueBeingEdited = nil - } - // 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) - } - } - } else { - cell.onValueBeingEdited = nil - } - 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.rawValue - cell.hasDestructiveAction = true - cell.onTapped = { [weak self, weak peerData] in - guard let peerData = peerData else { return } - guard let self = self else { return } - self.showConfirmationAlert(message: "Delete this peer?", buttonTitle: "Delete", from: cell) { [weak self] in - guard let self = self else { return } - let removedSectionIndices = self.deletePeer(peer: peerData) - let shouldShowExcludePrivateIPs = (self.tunnelViewModel.peersData.count == 1 && self.tunnelViewModel.peersData[0].shouldAllowExcludePrivateIPsControl) - 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.rawValue - 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: EditableKeyValueCell = tableView.dequeueReusableCell(for: indexPath) - cell.key = field.rawValue - - switch field { - case .publicKey: - cell.placeholderText = "Required" - cell.keyboardType = .default - case .preSharedKey, .endpoint: - cell.placeholderText = "Optional" - cell.keyboardType = .default - case .allowedIPs: - cell.placeholderText = "Optional" - cell.keyboardType = .numbersAndPunctuation - case .persistentKeepAlive: - cell.placeholderText = "Off" - cell.keyboardType = .numberPad - case .excludePrivateIPs, .deletePeer: - cell.keyboardType = .default - } - - cell.isValueValid = !peerData.fieldsWithError.contains(field) - cell.value = peerData[field] - - if field == .allowedIPs { - 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 { - if 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) - } - } - } - } - } 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 = "Add peer" - cell.onTapped = { [weak self] in - guard let self = self else { return } - let shouldHideExcludePrivateIPs = (self.tunnelViewModel.peersData.count == 1 && self.tunnelViewModel.peersData[0].shouldAllowExcludePrivateIPsControl) - let addedSectionIndices = self.appendEmptyPeer() - tableView.performBatchUpdates({ - tableView.insertSections(addedSectionIndices, with: .fade) - if shouldHideExcludePrivateIPs { - if let row = self.peerFields.firstIndex(of: .excludePrivateIPs) { - let rowIndexPath = IndexPath(row: row, section: self.interfaceFieldsBySection.count /* First peer section */) - self.tableView.deleteRows(at: [rowIndexPath], with: .fade) - } - } - }, completion: nil) - } - return cell - } - - private func onDemandCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - if indexPath.row == 0 { - let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath) - cell.message = "Activate on demand" - cell.isOn = activateOnDemandSetting.isActivateOnDemandEnabled - cell.onSwitchToggled = { [weak self] isOn in - guard let self = self else { return } - let indexPaths = (1 ..< 4).map { IndexPath(row: $0, section: indexPath.section) } - if isOn { - self.activateOnDemandSetting.isActivateOnDemandEnabled = true - if self.activateOnDemandSetting.activateOnDemandOption == .none { - self.activateOnDemandSetting.activateOnDemandOption = TunnelViewModel.defaultActivateOnDemandOption() - } - self.loadSections() - self.tableView.insertRows(at: indexPaths, with: .fade) - } else { - self.activateOnDemandSetting.isActivateOnDemandEnabled = false - self.loadSections() - self.tableView.deleteRows(at: indexPaths, with: .fade) - } - } - return cell - } else { - let cell: CheckmarkCell = tableView.dequeueReusableCell(for: indexPath) - let rowOption = activateOnDemandOptions[indexPath.row - 1] - let selectedOption = activateOnDemandSetting.activateOnDemandOption - assert(selectedOption != .none) - cell.message = TunnelViewModel.activateOnDemandOptionText(for: rowOption) - cell.isChecked = (selectedOption == rowOption) - return cell - } - } - - func appendEmptyPeer() -> IndexSet { - tunnelViewModel.appendEmptyPeer() - loadSections() - let addedPeerIndex = tunnelViewModel.peersData.count - 1 - return IndexSet(integer: interfaceFieldsBySection.count + addedPeerIndex) - } - - func deletePeer(peer: TunnelViewModel.PeerData) -> IndexSet { - tunnelViewModel.deletePeer(peer: peer) - loadSections() - return IndexSet(integer: interfaceFieldsBySection.count + peer.index) - } - - func showConfirmationAlert(message: String, buttonTitle: String, from sourceView: UIView, onConfirmed: @escaping (() -> Void)) { - let destroyAction = UIAlertAction(title: buttonTitle, style: .destructive) { _ in - onConfirmed() - } - let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) - let alert = UIAlertController(title: "", message: message, preferredStyle: .actionSheet) - alert.addAction(destroyAction) - alert.addAction(cancelAction) - - // popoverPresentationController will be nil on iPhone and non-nil on iPad - alert.popoverPresentationController?.sourceView = sourceView - alert.popoverPresentationController?.sourceRect = sourceView.bounds - - present(alert, animated: true, completion: nil) - } -} - -// MARK: UITableViewDelegate - -extension TunnelEditTableViewController { - override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { - if case .onDemand = sections[indexPath.section], indexPath.row > 0 { - return indexPath - } else { - return nil - } - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - switch sections[indexPath.section] { - case .onDemand: - let option = activateOnDemandOptions[indexPath.row - 1] - assert(option != .none) - activateOnDemandSetting.activateOnDemandOption = option - - let indexPaths = (1 ..< 4).map { IndexPath(row: $0, section: indexPath.section) } - UIView.performWithoutAnimation { - tableView.reloadRows(at: indexPaths, with: .none) - } - default: - assertionFailure() - } - } -} diff --git a/WireGuard/WireGuard/UI/iOS/MainViewController.swift b/WireGuard/WireGuard/UI/iOS/MainViewController.swift deleted file mode 100644 index 82e6f81..0000000 --- a/WireGuard/WireGuard/UI/iOS/MainViewController.swift +++ /dev/null @@ -1,129 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit - -class MainViewController: UISplitViewController { - - var tunnelsManager: TunnelsManager? - var onTunnelsManagerReady: ((TunnelsManager) -> Void)? - - var tunnelsListVC: TunnelsListTableViewController? - - init() { - let detailVC = UIViewController() - 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 ] - - // State restoration - 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 } - - if let error = result.error { - ErrorPresenter.showErrorAlert(error: error, from: self) - return - } - let tunnelsManager: TunnelsManager = result.value! - - self.tunnelsManager = tunnelsManager - self.tunnelsListVC?.setTunnelsManager(tunnelsManager: tunnelsManager) - - tunnelsManager.activationDelegate = self - - self.onTunnelsManagerReady?(tunnelsManager) - self.onTunnelsManagerReady = nil - } - } -} - -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) { - let showTunnelDetailBlock: (TunnelsManager) -> Void = { [weak self] tunnelsManager in - if let tunnel = tunnelsManager.tunnel(named: tunnelName) { - let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager, tunnel: tunnel) - let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC) - tunnelDetailNC.restorationIdentifier = "DetailNC" - if let self = self { - if animated { - self.showDetailViewController(tunnelDetailNC, sender: self) - } else { - UIView.performWithoutAnimation { - self.showDetailViewController(tunnelDetailNC, sender: self) - } - } - } - } - } - if let tunnelsManager = tunnelsManager { - showTunnelDetailBlock(tunnelsManager) - } else { - onTunnelsManagerReady = showTunnelDetailBlock - } - } -} - -extension MainViewController: UISplitViewControllerDelegate { - func splitViewController(_ splitViewController: UISplitViewController, - collapseSecondary secondaryViewController: UIViewController, - onto primaryViewController: UIViewController) -> Bool { - // On iPhone, if the secondaryVC (detailVC) is just a UIViewController, it indicates that it's empty, - // so just show the primaryVC (masterVC). - let detailVC = (secondaryViewController as? UINavigationController)?.viewControllers.first - let isDetailVCEmpty: Bool - if let detailVC = detailVC { - isDetailVCEmpty = (type(of: detailVC) == UIViewController.self) - } else { - isDetailVCEmpty = true - } - return isDetailVCEmpty - } -} diff --git a/WireGuard/WireGuard/UI/iOS/QRScanViewController.swift b/WireGuard/WireGuard/UI/iOS/QRScanViewController.swift deleted file mode 100644 index 1e231ec..0000000 --- a/WireGuard/WireGuard/UI/iOS/QRScanViewController.swift +++ /dev/null @@ -1,159 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import AVFoundation -import UIKit - -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 = "Scan QR code" - navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped)) - - let tipLabel = UILabel() - tipLabel.text = "Tip: Generate with `qrencode -t ansiutf8 < tunnel.conf`" - tipLabel.adjustsFontSizeToFitWidth = true - tipLabel.textColor = .lightGray - tipLabel.textAlignment = .center - - view.addSubview(tipLabel) - tipLabel.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - tipLabel.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor), - tipLabel.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor), - tipLabel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -32) - ]) - - guard let videoCaptureDevice = AVCaptureDevice.default(for: .video), - let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice), - let captureSession = captureSession, - captureSession.canAddInput(videoInput), - captureSession.canAddOutput(metadataOutput) else { - scanDidEncounterError(title: "Camera Unsupported", message: "This device is not able to scan QR codes") - 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 = UIDevice.current - - let orientation: UIDeviceOrientation = currentDevice.orientation - - let previewLayerConnection: AVCaptureConnection = 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? WgQuickConfigFileParser.parse(code, name: "Scanned") - guard let tunnelConfiguration = scannedTunnelConfiguration else { - scanDidEncounterError(title: "Invalid QR Code", message: "The scanned QR code is not a valid WireGuard configuration") - return - } - - let alert = UIAlertController(title: NSLocalizedString("Please name the scanned tunnel", comment: ""), message: nil, preferredStyle: .alert) - alert.addTextField(configurationHandler: nil) - alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel) { [weak self] _ in - self?.dismiss(animated: true, completion: nil) - }) - alert.addAction(UIAlertAction(title: NSLocalizedString("Save", comment: ""), style: .default) { [weak self] _ in - guard let title = alert.textFields?[0].text?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else { return } - tunnelConfiguration.interface.name = title - if let self = self { - self.delegate?.addScannedQRCode(tunnelConfiguration: tunnelConfiguration, qrScanViewController: self) { - self.dismiss(animated: true, completion: nil) - } - } - }) - present(alert, animated: true) - } - - func scanDidEncounterError(title: String, message: String) { - let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) - alertController.addAction(UIAlertAction(title: "OK", style: .default) { [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: "Invalid Code", message: "The scanned code could not be read") - return - } - - AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) - scanDidComplete(withCode: stringValue) - } -} diff --git a/WireGuard/WireGuard/UI/iOS/SettingsTableViewController.swift b/WireGuard/WireGuard/UI/iOS/SettingsTableViewController.swift deleted file mode 100644 index b583c5b..0000000 --- a/WireGuard/WireGuard/UI/iOS/SettingsTableViewController.swift +++ /dev/null @@ -1,200 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit -import os.log - -class SettingsTableViewController: UITableViewController { - - enum SettingsFields: String { - case iosAppVersion = "WireGuard for iOS" - case goBackendVersion = "WireGuard Go Backend" - case exportZipArchive = "Export zip archive" - case exportLogFile = "Export log file" - } - - let settingsFieldsBySection: [[SettingsFields]] = [ - [.iosAppVersion, .goBackendVersion], - [.exportZipArchive], - [.exportLogFile] - ] - - let tunnelsManager: TunnelsManager? - var wireguardCaptionedImage: (view: UIView, size: CGSize)? - - init(tunnelsManager: TunnelsManager?) { - self.tunnelsManager = tunnelsManager - super.init(style: .grouped) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - title = "Settings" - 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) { - guard let tunnelsManager = tunnelsManager else { return } - guard let destinationDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } - - let destinationURL = destinationDir.appendingPathComponent("wireguard-export.zip") - _ = FileManager.deleteFile(at: destinationURL) - - let count = tunnelsManager.numberOfTunnels() - let tunnelConfigurations = (0 ..< count).compactMap { tunnelsManager.tunnel(at: $0).tunnelConfiguration() } - ZipExporter.exportConfigFiles(tunnelConfigurations: tunnelConfigurations, to: destinationURL) { [weak self] error in - if let error = error { - ErrorPresenter.showErrorAlert(error: error, from: self) - return - } - - let fileExportVC = UIDocumentPickerViewController(url: destinationURL, in: .exportToService) - self?.present(fileExportVC, animated: true, completion: nil) - } - } - - func exportLogForLastActivatedTunnel(sourceView: UIView) { - guard let destinationDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } - - let dateFormatter = ISO8601DateFormatter() - dateFormatter.formatOptions = [.withFullDate, .withTime, .withTimeZone] // Avoid ':' in the filename - let timeStampString = dateFormatter.string(from: Date()) - let destinationURL = destinationDir.appendingPathComponent("wireguard-log-\(timeStampString).txt") - - DispatchQueue.global(qos: .userInitiated).async { - - if FileManager.default.fileExists(atPath: destinationURL.path) { - let isDeleted = FileManager.deleteFile(at: destinationURL) - if !isDeleted { - ErrorPresenter.showErrorAlert(title: "Log export failed", message: "The pre-existing log could not be cleared", from: self) - return - } - } - - guard let networkExtensionLogFilePath = FileManager.networkExtensionLogFileURL?.path else { - ErrorPresenter.showErrorAlert(title: "Log export failed", message: "Unable to determine extension log path", from: self) - return - } - - let isWritten = Logger.global?.writeLog(called: "APP", mergedWith: networkExtensionLogFilePath, called: "NET", to: destinationURL.path) ?? false - guard isWritten else { - ErrorPresenter.showErrorAlert(title: "Log export failed", message: "Unable to write logs to file", from: self) - return - } - - DispatchQueue.main.async { - let activityVC = UIActivityViewController(activityItems: [destinationURL], applicationActivities: nil) - // popoverPresentationController shall be non-nil on the iPad - activityVC.popoverPresentationController?.sourceView = sourceView - activityVC.popoverPresentationController?.sourceRect = sourceView.bounds - activityVC.completionWithItemsHandler = { _, _, _, _ in - // Remove the exported log file after the activity has completed - _ = FileManager.deleteFile(at: destinationURL) - } - self.present(activityVC, animated: true) - } - } - } -} - -// MARK: UITableViewDataSource - -extension SettingsTableViewController { - override func numberOfSections(in tableView: UITableView) -> Int { - return settingsFieldsBySection.count - } - - override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return settingsFieldsBySection[section].count - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - switch section { - case 0: - return "About" - case 1: - return "Export configurations" - case 2: - return "Tunnel log" - default: - return nil - } - } - - override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let field = settingsFieldsBySection[indexPath.section][indexPath.row] - if field == .iosAppVersion || field == .goBackendVersion { - let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) - cell.copyableGesture = false - cell.key = field.rawValue - if field == .iosAppVersion { - var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown version" - if let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String { - appVersion += " (\(appBuild))" - } - cell.value = appVersion - } else if field == .goBackendVersion { - cell.value = WIREGUARD_GO_VERSION - } - return cell - } else if field == .exportZipArchive { - let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) - cell.buttonText = field.rawValue - cell.onTapped = { [weak self] in - self?.exportConfigurationsAsZipFile(sourceView: cell.button) - } - return cell - } else { - assert(field == .exportLogFile) - let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) - cell.buttonText = field.rawValue - cell.onTapped = { [weak self] in - self?.exportLogForLastActivatedTunnel(sourceView: cell.button) - } - return cell - } - } -} diff --git a/WireGuard/WireGuard/UI/iOS/SharedViews/BorderedTextButton.swift b/WireGuard/WireGuard/UI/iOS/SharedViews/BorderedTextButton.swift deleted file mode 100644 index 94b76d6..0000000 --- a/WireGuard/WireGuard/UI/iOS/SharedViews/BorderedTextButton.swift +++ /dev/null @@ -1,50 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 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) - } - - @objc func buttonTapped() { - onTapped?() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/WireGuard/WireGuard/UI/iOS/SharedViews/ButtonCell.swift b/WireGuard/WireGuard/UI/iOS/SharedViews/ButtonCell.swift deleted file mode 100644 index 4702993..0000000 --- a/WireGuard/WireGuard/UI/iOS/SharedViews/ButtonCell.swift +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit - -class ButtonCell: UITableViewCell { - var buttonText: String { - get { return button.title(for: .normal) ?? "" } - set(value) { button.setTitle(value, for: .normal) } - } - var hasDestructiveAction: Bool { - get { return button.tintColor == .red } - set(value) { button.tintColor = value ? .red : buttonStandardTintColor } - } - var onTapped: (() -> Void)? - - let button: UIButton = { - let button = UIButton(type: .system) - button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) - button.titleLabel?.adjustsFontForContentSizeCategory = true - return button - }() - - var buttonStandardTintColor: UIColor - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - buttonStandardTintColor = button.tintColor - super.init(style: style, reuseIdentifier: reuseIdentifier) - - contentView.addSubview(button) - button.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - button.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), - contentView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: button.bottomAnchor), - button.centerXAnchor.constraint(equalTo: contentView.centerXAnchor) - ]) - - button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) - } - - @objc func buttonTapped() { - onTapped?() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - buttonText = "" - onTapped = nil - hasDestructiveAction = false - } -} diff --git a/WireGuard/WireGuard/UI/iOS/SharedViews/CheckmarkCell.swift b/WireGuard/WireGuard/UI/iOS/SharedViews/CheckmarkCell.swift deleted file mode 100644 index db4b6c9..0000000 --- a/WireGuard/WireGuard/UI/iOS/SharedViews/CheckmarkCell.swift +++ /dev/null @@ -1,31 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit - -class CheckmarkCell: UITableViewCell { - var message: String { - get { return textLabel?.text ?? "" } - set(value) { textLabel!.text = value } - } - var isChecked: Bool { - didSet { - accessoryType = isChecked ? .checkmark : .none - } - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - isChecked = false - super.init(style: .default, reuseIdentifier: reuseIdentifier) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - message = "" - isChecked = false - } -} diff --git a/WireGuard/WireGuard/UI/iOS/SharedViews/CopyableLabelTableViewCell.swift b/WireGuard/WireGuard/UI/iOS/SharedViews/CopyableLabelTableViewCell.swift deleted file mode 100644 index 93a9ef7..0000000 --- a/WireGuard/WireGuard/UI/iOS/SharedViews/CopyableLabelTableViewCell.swift +++ /dev/null @@ -1,55 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit - -class CopyableLabelTableViewCell: UITableViewCell { - var copyableGesture = true - - var textToCopy: String? { - fatalError("textToCopy must be implemented by subclass") - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:))) - addGestureRecognizer(gestureRecognizer) - isUserInteractionEnabled = true - } - - // MARK: - UIGestureRecognizer - @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 = textToCopy - } - - override func prepareForReuse() { - super.prepareForReuse() - copyableGesture = true - } -} diff --git a/WireGuard/WireGuard/UI/iOS/SharedViews/EditableKeyValueCell.swift b/WireGuard/WireGuard/UI/iOS/SharedViews/EditableKeyValueCell.swift deleted file mode 100644 index 48956eb..0000000 --- a/WireGuard/WireGuard/UI/iOS/SharedViews/EditableKeyValueCell.swift +++ /dev/null @@ -1,158 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit - -class EditableKeyValueCell: UITableViewCell { - var key: String { - get { return keyLabel.text ?? "" } - set(value) { keyLabel.text = value } - } - var value: String { - get { return valueTextField.text ?? "" } - set(value) { valueTextField.text = value } - } - var placeholderText: String { - get { return valueTextField.placeholder ?? "" } - set(value) { valueTextField.placeholder = value } - } - var isValueValid = true { - didSet { - if isValueValid { - keyLabel.textColor = .black - } else { - keyLabel.textColor = .red - } - } - } - var keyboardType: UIKeyboardType { - get { return valueTextField.keyboardType } - set(value) { valueTextField.keyboardType = value } - } - - var onValueChanged: ((String) -> Void)? - var onValueBeingEdited: ((String) -> Void)? - - let keyLabel: UILabel = { - let keyLabel = UILabel() - keyLabel.font = UIFont.preferredFont(forTextStyle: .body) - keyLabel.adjustsFontForContentSizeCategory = true - return keyLabel - }() - - let valueTextField: UITextField = { - let valueTextField = UITextField() - valueTextField.font = UIFont.preferredFont(forTextStyle: .body) - valueTextField.adjustsFontForContentSizeCategory = true - valueTextField.autocapitalizationType = .none - valueTextField.autocorrectionType = .no - valueTextField.spellCheckingType = .no - return valueTextField - }() - - var isStackedHorizontally = false - var isStackedVertically = false - var contentSizeBasedConstraints = [NSLayoutConstraint]() - - private var textFieldValueOnBeginEditing: String = "" - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - contentView.addSubview(keyLabel) - keyLabel.translatesAutoresizingMaskIntoConstraints = false - keyLabel.textAlignment = .right - let widthRatioConstraint = NSLayoutConstraint(item: keyLabel, attribute: .width, - relatedBy: .equal, - toItem: self, attribute: .width, - multiplier: 0.4, constant: 0) - // The "Persistent Keepalive" key doesn't fit into 0.4 * width on the iPhone SE, - // so set a CR priority > the 0.4-constraint's priority. - widthRatioConstraint.priority = .defaultHigh + 1 - keyLabel.setContentCompressionResistancePriority(.defaultHigh + 2, for: .horizontal) - NSLayoutConstraint.activate([ - keyLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), - keyLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5), - widthRatioConstraint - ]) - - contentView.addSubview(valueTextField) - valueTextField.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - valueTextField.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor), - contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: valueTextField.bottomAnchor, multiplier: 0.5) - ]) - valueTextField.delegate = self - - configureForContentSize() - } - - func configureForContentSize() { - var constraints = [NSLayoutConstraint]() - if traitCollection.preferredContentSizeCategory.isAccessibilityCategory { - // Stack vertically - if !isStackedVertically { - constraints = [ - valueTextField.topAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), - valueTextField.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), - keyLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor) - ] - isStackedVertically = true - isStackedHorizontally = false - } - } else { - // Stack horizontally - if !isStackedHorizontally { - constraints = [ - contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), - valueTextField.leftAnchor.constraint(equalToSystemSpacingAfter: keyLabel.rightAnchor, multiplier: 1), - valueTextField.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) - ] - isStackedHorizontally = true - isStackedVertically = false - } - } - if !constraints.isEmpty { - NSLayoutConstraint.deactivate(contentSizeBasedConstraints) - NSLayoutConstraint.activate(constraints) - contentSizeBasedConstraints = constraints - } - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - key = "" - value = "" - placeholderText = "" - isValueValid = true - keyboardType = .default - onValueChanged = nil - onValueBeingEdited = nil - configureForContentSize() - } -} - -extension EditableKeyValueCell: UITextFieldDelegate { - func textFieldDidBeginEditing(_ textField: UITextField) { - textFieldValueOnBeginEditing = textField.text ?? "" - isValueValid = true - } - func textFieldDidEndEditing(_ textField: UITextField) { - let isModified = (textField.text ?? "" != textFieldValueOnBeginEditing) - guard isModified else { return } - if let onValueChanged = onValueChanged { - onValueChanged(textField.text ?? "") - } - } - func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - if let onValueBeingEdited = onValueBeingEdited { - let modifiedText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string) - onValueBeingEdited(modifiedText) - } - return true - } -} diff --git a/WireGuard/WireGuard/UI/iOS/SharedViews/KeyValueCell.swift b/WireGuard/WireGuard/UI/iOS/SharedViews/KeyValueCell.swift deleted file mode 100644 index 78026ea..0000000 --- a/WireGuard/WireGuard/UI/iOS/SharedViews/KeyValueCell.swift +++ /dev/null @@ -1,107 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit - -class KeyValueCell: CopyableLabelTableViewCell { - var key: String { - get { return keyLabel.text ?? "" } - set(value) { keyLabel.text = value } - } - var value: String { - get { return valueLabel.text } - set(value) { valueLabel.text = value } - } - - override var textToCopy: String? { - return valueLabel.text - } - - let keyLabel: UILabel = { - let keyLabel = UILabel() - keyLabel.font = UIFont.preferredFont(forTextStyle: .body) - keyLabel.adjustsFontForContentSizeCategory = true - keyLabel.textColor = .black - return keyLabel - }() - - let valueLabel: ScrollableLabel = { - let valueLabel = ScrollableLabel() - valueLabel.label.font = UIFont.preferredFont(forTextStyle: .body) - valueLabel.label.adjustsFontForContentSizeCategory = true - valueLabel.textColor = .gray - return valueLabel - }() - - var isStackedHorizontally = false - var isStackedVertically = false - var contentSizeBasedConstraints = [NSLayoutConstraint]() - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - - contentView.addSubview(keyLabel) - keyLabel.translatesAutoresizingMaskIntoConstraints = false - keyLabel.textAlignment = .left - NSLayoutConstraint.activate([ - keyLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), - keyLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) - ]) - - contentView.addSubview(valueLabel) - valueLabel.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - valueLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor), - contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: valueLabel.bottomAnchor, multiplier: 0.5) - ]) - - keyLabel.setContentCompressionResistancePriority(.defaultHigh + 1, for: .horizontal) - keyLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) - valueLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) - - configureForContentSize() - } - - func configureForContentSize() { - var constraints = [NSLayoutConstraint]() - if traitCollection.preferredContentSizeCategory.isAccessibilityCategory { - // Stack vertically - if !isStackedVertically { - constraints = [ - valueLabel.topAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), - valueLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), - keyLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor) - ] - isStackedVertically = true - isStackedHorizontally = false - } - } else { - // Stack horizontally - if !isStackedHorizontally { - constraints = [ - contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), - valueLabel.leftAnchor.constraint(equalToSystemSpacingAfter: keyLabel.rightAnchor, multiplier: 1), - valueLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) - ] - isStackedHorizontally = true - isStackedVertically = false - } - } - if !constraints.isEmpty { - NSLayoutConstraint.deactivate(contentSizeBasedConstraints) - NSLayoutConstraint.activate(constraints) - contentSizeBasedConstraints = constraints - } - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - key = "" - value = "" - configureForContentSize() - } -} diff --git a/WireGuard/WireGuard/UI/iOS/SharedViews/ScrollableLabel.swift b/WireGuard/WireGuard/UI/iOS/SharedViews/ScrollableLabel.swift deleted file mode 100644 index bd6f547..0000000 --- a/WireGuard/WireGuard/UI/iOS/SharedViews/ScrollableLabel.swift +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit - -class ScrollableLabel: UIScrollView { - var text: String { - get { return label.text ?? "" } - set(value) { label.text = value } - } - var textColor: UIColor { - get { return label.textColor } - set(value) { label.textColor = value } - } - - let label: UILabel = { - let label = UILabel() - label.translatesAutoresizingMaskIntoConstraints = false - label.textAlignment = .right - return label - }() - - init() { - super.init(frame: CGRect.zero) - - isDirectionalLockEnabled = true - showsHorizontalScrollIndicator = false - showsVerticalScrollIndicator = false - - addSubview(label) - label.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - label.leftAnchor.constraint(equalTo: contentLayoutGuide.leftAnchor), - label.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor), - label.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor), - label.rightAnchor.constraint(equalTo: contentLayoutGuide.rightAnchor), - label.heightAnchor.constraint(equalTo: heightAnchor) - ]) - - let expandToFitValueLabelConstraint = NSLayoutConstraint(item: label, attribute: .width, relatedBy: .equal, toItem: self, attribute: .width, multiplier: 1, constant: 0) - expandToFitValueLabelConstraint.priority = .defaultLow + 1 - expandToFitValueLabelConstraint.isActive = true - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/WireGuard/WireGuard/UI/iOS/SharedViews/SwitchCell.swift b/WireGuard/WireGuard/UI/iOS/SharedViews/SwitchCell.swift deleted file mode 100644 index d0c29aa..0000000 --- a/WireGuard/WireGuard/UI/iOS/SharedViews/SwitchCell.swift +++ /dev/null @@ -1,48 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit - -class SwitchCell: UITableViewCell { - var message: String { - get { return textLabel?.text ?? "" } - set(value) { textLabel?.text = value } - } - var isOn: Bool { - get { return switchView.isOn } - set(value) { switchView.isOn = value } - } - var isEnabled: Bool { - get { return switchView.isEnabled } - set(value) { - switchView.isEnabled = value - textLabel?.textColor = value ? .black : .gray - } - } - - var onSwitchToggled: ((Bool) -> Void)? - - 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() - isEnabled = true - message = "" - isOn = false - } -} diff --git a/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailTableViewController.swift b/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailTableViewController.swift deleted file mode 100644 index ed48d0f..0000000 --- a/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailTableViewController.swift +++ /dev/null @@ -1,260 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit - -// MARK: TunnelDetailTableViewController - -class TunnelDetailTableViewController: UITableViewController { - - private enum Section { - case status - case interface - case peer(_ peer: TunnelViewModel.PeerData) - case onDemand - case delete - } - - let interfaceFields: [TunnelViewModel.InterfaceField] = [ - .name, .publicKey, .addresses, - .listenPort, .mtu, .dns - ] - - let peerFields: [TunnelViewModel.PeerField] = [ - .publicKey, .preSharedKey, .endpoint, - .allowedIPs, .persistentKeepAlive - ] - - let tunnelsManager: TunnelsManager - let tunnel: TunnelContainer - var tunnelViewModel: TunnelViewModel - private var sections = [Section]() - private var onDemandStatusObservervationToken: AnyObject? - private var statusObservervationToken: AnyObject? - - init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) { - self.tunnelsManager = tunnelsManager - self.tunnel = tunnel - tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration()) - super.init(style: .grouped) - loadSections() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - onDemandStatusObservervationToken = nil - statusObservervationToken = nil - } - - override func viewDidLoad() { - super.viewDidLoad() - title = tunnelViewModel.interfaceData[.name] - navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(editTapped)) - - tableView.estimatedRowHeight = 44 - tableView.rowHeight = UITableView.automaticDimension - tableView.allowsSelection = false - tableView.register(SwitchCell.self) - tableView.register(KeyValueCell.self) - tableView.register(ButtonCell.self) - - // State restoration - restorationIdentifier = "TunnelDetailVC:\(tunnel.name)" - } - - private func loadSections() { - sections.removeAll() - sections.append(.status) - sections.append(.interface) - tunnelViewModel.peersData.forEach { sections.append(.peer($0)) } - sections.append(.onDemand) - sections.append(.delete) - } - - @objc func editTapped() { - let editVC = TunnelEditTableViewController(tunnelsManager: tunnelsManager, tunnel: tunnel) - editVC.delegate = self - let editNC = UINavigationController(rootViewController: editVC) - editNC.modalPresentationStyle = .formSheet - present(editNC, animated: true) - } - - func showConfirmationAlert(message: String, buttonTitle: String, from sourceView: UIView, onConfirmed: @escaping (() -> Void)) { - let destroyAction = UIAlertAction(title: buttonTitle, style: .destructive) { _ in - onConfirmed() - } - let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) - let alert = UIAlertController(title: "", message: message, preferredStyle: .actionSheet) - alert.addAction(destroyAction) - alert.addAction(cancelAction) - - // popoverPresentationController will be nil on iPhone and non-nil on iPad - alert.popoverPresentationController?.sourceView = sourceView - alert.popoverPresentationController?.sourceRect = sourceView.bounds - - present(alert, animated: true, completion: nil) - } -} - -// MARK: TunnelEditTableViewControllerDelegate - -extension TunnelDetailTableViewController: TunnelEditTableViewControllerDelegate { - func tunnelSaved(tunnel: TunnelContainer) { - tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration()) - loadSections() - title = tunnel.name - tableView.reloadData() - } - func tunnelEditingCancelled() { - // Nothing to do - } -} - -// MARK: UITableViewDataSource - -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 tunnelViewModel.interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFields).count - case .peer(let peerData): - return peerData.filterFieldsWithValueOrControl(peerFields: peerFields).count - case .onDemand: - return 1 - case .delete: - return 1 - } - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - switch sections[section] { - case .status: - return "Status" - case .interface: - return "Interface" - case .peer: - return "Peer" - case .onDemand: - return "On-Demand Activation" - 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 peer): - return peerCell(for: tableView, at: indexPath, with: peer) - 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 = "Inactive" - case .activating: - text = "Activating" - case .active: - text = "Active" - case .deactivating: - text = "Deactivating" - case .reasserting: - text = "Reactivating" - case .restarting: - text = "Restarting" - case .waiting: - text = "Waiting" - } - 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) - statusObservervationToken = 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 field = tunnelViewModel.interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFields)[indexPath.row] - let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) - cell.key = field.rawValue - cell.value = tunnelViewModel.interfaceData[field] - return cell - } - - private func peerCell(for tableView: UITableView, at indexPath: IndexPath, with peerData: TunnelViewModel.PeerData) -> UITableViewCell { - let field = peerData.filterFieldsWithValueOrControl(peerFields: peerFields)[indexPath.row] - let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) - cell.key = field.rawValue - cell.value = peerData[field] - return cell - } - - private func onDemandCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) - cell.key = "Activate on demand" - cell.value = TunnelViewModel.activateOnDemandDetailText(for: tunnel.activateOnDemandSetting()) - onDemandStatusObservervationToken = tunnel.observe(\.isActivateOnDemandEnabled) { [weak cell] tunnel, _ in - cell?.value = TunnelViewModel.activateOnDemandDetailText(for: tunnel.activateOnDemandSetting()) - } - return cell - } - - private func deleteConfigurationCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { - let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) - cell.buttonText = "Delete tunnel" - cell.hasDestructiveAction = true - cell.onTapped = { [weak self] in - guard let self = self else { return } - self.showConfirmationAlert(message: "Delete this tunnel?", buttonTitle: "Delete", from: cell) { [weak self] in - guard let tunnelsManager = self?.tunnelsManager, let tunnel = self?.tunnel else { return } - tunnelsManager.remove(tunnel: tunnel) { error in - if error != nil { - print("Error removing tunnel: \(String(describing: error))") - return - } - } - self?.navigationController?.navigationController?.popToRootViewController(animated: true) - } - } - return cell - } - -} diff --git a/WireGuard/WireGuard/UI/iOS/TunnelList/TunnelListCell.swift b/WireGuard/WireGuard/UI/iOS/TunnelList/TunnelListCell.swift deleted file mode 100644 index 14a7194..0000000 --- a/WireGuard/WireGuard/UI/iOS/TunnelList/TunnelListCell.swift +++ /dev/null @@ -1,111 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 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 ?? "" - nameObservervationToken = tunnel?.observe(\.name) { [weak self] tunnel, _ in - self?.nameLabel.text = tunnel.name - } - // Bind to the tunnel's status - update(from: tunnel?.status) - statusObservervationToken = tunnel?.observe(\.status) { [weak self] tunnel, _ in - self?.update(from: tunnel.status) - } - } - } - var onSwitchToggled: ((Bool) -> Void)? - - let nameLabel: UILabel = { - let nameLabel = UILabel() - nameLabel.font = UIFont.preferredFont(forTextStyle: .body) - nameLabel.adjustsFontForContentSizeCategory = true - nameLabel.numberOfLines = 0 - return nameLabel - }() - - let busyIndicator: UIActivityIndicatorView = { - let busyIndicator = UIActivityIndicatorView(style: .gray) - busyIndicator.hidesWhenStopped = true - return busyIndicator - }() - - let statusSwitch = UISwitch() - - private var statusObservervationToken: AnyObject? - private var nameObservervationToken: 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.rightAnchor.constraint(equalTo: statusSwitch.rightAnchor) - ]) - - contentView.addSubview(busyIndicator) - busyIndicator.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - busyIndicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), - statusSwitch.leftAnchor.constraint(equalToSystemSpacingAfter: busyIndicator.rightAnchor, 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.leftAnchor.constraint(equalToSystemSpacingAfter: contentView.layoutMarginsGuide.leftAnchor, multiplier: 1), - busyIndicator.leftAnchor.constraint(equalToSystemSpacingAfter: nameLabel.rightAnchor, multiplier: 1), - bottomAnchorConstraint - ]) - - accessoryType = .disclosureIndicator - - statusSwitch.addTarget(self, action: #selector(switchToggled), for: .valueChanged) - } - - @objc func switchToggled() { - onSwitchToggled?(statusSwitch.isOn) - } - - private func update(from status: TunnelStatus?) { - guard let status = status else { - reset() - return - } - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak statusSwitch, weak busyIndicator] in - guard let statusSwitch = statusSwitch, let busyIndicator = busyIndicator else { return } - statusSwitch.isOn = !(status == .deactivating || status == .inactive) - statusSwitch.isUserInteractionEnabled = (status == .inactive || status == .active) - if status == .inactive || status == .active { - busyIndicator.stopAnimating() - } else { - busyIndicator.startAnimating() - } - } - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func reset() { - statusSwitch.isOn = false - statusSwitch.isUserInteractionEnabled = false - busyIndicator.stopAnimating() - } - - override func prepareForReuse() { - super.prepareForReuse() - reset() - } -} diff --git a/WireGuard/WireGuard/UI/iOS/TunnelList/TunnelsListTableViewController.swift b/WireGuard/WireGuard/UI/iOS/TunnelList/TunnelsListTableViewController.swift deleted file mode 100644 index 0188c62..0000000 --- a/WireGuard/WireGuard/UI/iOS/TunnelList/TunnelsListTableViewController.swift +++ /dev/null @@ -1,307 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit -import MobileCoreServices -import UserNotifications - -class TunnelsListTableViewController: UIViewController { - - var tunnelsManager: TunnelsManager? - - 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 = "Add a tunnel" - button.isHidden = true - return button - }() - - let busyIndicator: UIActivityIndicatorView = { - let busyIndicator = UIActivityIndicatorView(style: .gray) - busyIndicator.hidesWhenStopped = true - return busyIndicator - }() - - override func loadView() { - view = UIView() - view.backgroundColor = .white - - tableView.dataSource = self - tableView.delegate = self - - view.addSubview(tableView) - tableView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - tableView.leftAnchor.constraint(equalTo: view.leftAnchor), - tableView.rightAnchor.constraint(equalTo: view.rightAnchor), - tableView.topAnchor.constraint(equalTo: view.topAnchor), - tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) - - view.addSubview(busyIndicator) - busyIndicator.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - busyIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), - busyIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor) - ]) - - view.addSubview(centeredAddButton) - centeredAddButton.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - centeredAddButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), - centeredAddButton.centerYAnchor.constraint(equalTo: view.centerYAnchor) - ]) - - centeredAddButton.onTapped = { [weak self] in - guard let self = self else { return } - self.addButtonTapped(sender: self.centeredAddButton) - } - - busyIndicator.startAnimating() - } - - override func viewDidLoad() { - super.viewDidLoad() - - title = "WireGuard" - navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped(sender:))) - navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Settings", style: .plain, target: self, action: #selector(settingsButtonTapped(sender:))) - - restorationIdentifier = "TunnelsListVC" - } - - func setTunnelsManager(tunnelsManager: TunnelsManager) { - self.tunnelsManager = tunnelsManager - tunnelsManager.tunnelsListDelegate = self - - busyIndicator.stopAnimating() - tableView.reloadData() - centeredAddButton.isHidden = tunnelsManager.numberOfTunnels() > 0 - } - - override func viewWillAppear(_: Bool) { - // Remove selection when getting back to the list view on iPhone - 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: "Add a new WireGuard tunnel", preferredStyle: .actionSheet) - let importFileAction = UIAlertAction(title: "Create from file or archive", style: .default) { [weak self] _ in - self?.presentViewControllerForFileImport() - } - alert.addAction(importFileAction) - - let scanQRCodeAction = UIAlertAction(title: "Create from QR code", style: .default) { [weak self] _ in - self?.presentViewControllerForScanningQRCode() - } - alert.addAction(scanQRCodeAction) - - let createFromScratchAction = UIAlertAction(title: "Create from scratch", style: .default) { [weak self] _ in - if let self = self, let tunnelsManager = self.tunnelsManager { - self.presentViewControllerForTunnelCreation(tunnelsManager: tunnelsManager, tunnelConfiguration: nil) - } - } - alert.addAction(createFromScratchAction) - - let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) - alert.addAction(cancelAction) - - // popoverPresentationController will be nil on iPhone and non-nil on iPad - if let sender = sender as? UIBarButtonItem { - alert.popoverPresentationController?.barButtonItem = sender - } else if let sender = sender as? UIView { - alert.popoverPresentationController?.sourceView = sender - alert.popoverPresentationController?.sourceRect = sender.bounds - } - 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, tunnelConfiguration: TunnelConfiguration?) { - let editVC = TunnelEditTableViewController(tunnelsManager: tunnelsManager, tunnelConfiguration: tunnelConfiguration) - let editNC = UINavigationController(rootViewController: editVC) - editNC.modalPresentationStyle = .formSheet - present(editNC, animated: true) - } - - func presentViewControllerForFileImport() { - let documentTypes = ["com.wireguard.config.quick", String(kUTTypeText), String(kUTTypeZipArchive)] - let filePicker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import) - filePicker.delegate = self - present(filePicker, animated: true) - } - - func presentViewControllerForScanningQRCode() { - let scanQRCodeVC = QRScanViewController() - scanQRCodeVC.delegate = self - let scanQRCodeNC = UINavigationController(rootViewController: scanQRCodeVC) - scanQRCodeNC.modalPresentationStyle = .fullScreen - present(scanQRCodeNC, animated: true) - } - - func importFromFile(url: URL, completionHandler: (() -> Void)?) { - guard let tunnelsManager = tunnelsManager else { return } - if url.pathExtension == "zip" { - ZipImporter.importConfigFiles(from: url) { [weak self] result in - if let error = result.error { - ErrorPresenter.showErrorAlert(error: error, from: self) - return - } - let configs = result.value! - tunnelsManager.addMultiple(tunnelConfigurations: configs.compactMap { $0 }) { [weak self] numberSuccessful in - if numberSuccessful == configs.count { - completionHandler?() - return - } - ErrorPresenter.showErrorAlert(title: "Created \(numberSuccessful) tunnels", - message: "Created \(numberSuccessful) of \(configs.count) tunnels from zip archive", - from: self, onPresented: completionHandler) - } - } - } else /* if (url.pathExtension == "conf") -- we assume everything else is a conf */ { - let fileBaseName = url.deletingPathExtension().lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) - if let fileContents = try? String(contentsOf: url), - let tunnelConfiguration = try? WgQuickConfigFileParser.parse(fileContents, name: fileBaseName) { - tunnelsManager.add(tunnelConfiguration: tunnelConfiguration) { [weak self] result in - if let error = result.error { - ErrorPresenter.showErrorAlert(error: error, from: self, onPresented: completionHandler) - } else { - completionHandler?() - } - } - } else { - ErrorPresenter.showErrorAlert(title: "Unable to import tunnel", - message: "An error occured when importing the tunnel configuration.", - from: self, onPresented: completionHandler) - } - } - } -} - -// MARK: UIDocumentPickerDelegate - -extension TunnelsListTableViewController: UIDocumentPickerDelegate { - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - urls.forEach { - importFromFile(url: $0, completionHandler: nil) - } - } -} - -// MARK: QRScanViewControllerDelegate - -extension TunnelsListTableViewController: QRScanViewControllerDelegate { - func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController, - completionHandler: (() -> Void)?) { - tunnelsManager?.add(tunnelConfiguration: tunnelConfiguration) { result in - if let error = result.error { - ErrorPresenter.showErrorAlert(error: error, from: qrScanViewController, onDismissal: completionHandler) - } else { - completionHandler?() - } - } - } -} - -// MARK: UITableViewDataSource - -extension TunnelsListTableViewController: UITableViewDataSource { - func numberOfSections(in tableView: UITableView) -> Int { - return 1 - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return (tunnelsManager?.numberOfTunnels() ?? 0) - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell: 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 - } -} - -// MARK: UITableViewDelegate - -extension TunnelsListTableViewController: UITableViewDelegate { - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - guard let tunnelsManager = tunnelsManager else { return } - let tunnel = tunnelsManager.tunnel(at: indexPath.row) - let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager, - tunnel: tunnel) - let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC) - tunnelDetailNC.restorationIdentifier = "DetailNC" - showDetailViewController(tunnelDetailNC, sender: self) // Shall get propagated up to the split-vc - } - - func tableView(_ tableView: UITableView, - trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { - let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { [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]) - } -} - -// MARK: TunnelsManagerDelegate - -extension TunnelsListTableViewController: TunnelsManagerListDelegate { - func tunnelAdded(at index: Int) { - tableView.insertRows(at: [IndexPath(row: index, section: 0)], with: .automatic) - centeredAddButton.isHidden = (tunnelsManager?.numberOfTunnels() ?? 0 > 0) - } - - func tunnelModified(at index: Int) { - tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .automatic) - } - - func tunnelMoved(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) { - tableView.deleteRows(at: [IndexPath(row: index, section: 0)], with: .automatic) - centeredAddButton.isHidden = tunnelsManager?.numberOfTunnels() ?? 0 > 0 - } -} diff --git a/WireGuard/WireGuard/UI/iOS/View/BorderedTextButton.swift b/WireGuard/WireGuard/UI/iOS/View/BorderedTextButton.swift new file mode 100644 index 0000000..94b76d6 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/View/BorderedTextButton.swift @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 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) + } + + @objc func buttonTapped() { + onTapped?() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/WireGuard/WireGuard/UI/iOS/View/ButtonCell.swift b/WireGuard/WireGuard/UI/iOS/View/ButtonCell.swift new file mode 100644 index 0000000..4702993 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/View/ButtonCell.swift @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class ButtonCell: UITableViewCell { + var buttonText: String { + get { return button.title(for: .normal) ?? "" } + set(value) { button.setTitle(value, for: .normal) } + } + var hasDestructiveAction: Bool { + get { return button.tintColor == .red } + set(value) { button.tintColor = value ? .red : buttonStandardTintColor } + } + var onTapped: (() -> Void)? + + let button: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) + button.titleLabel?.adjustsFontForContentSizeCategory = true + return button + }() + + var buttonStandardTintColor: UIColor + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + buttonStandardTintColor = button.tintColor + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: button.bottomAnchor), + button.centerXAnchor.constraint(equalTo: contentView.centerXAnchor) + ]) + + button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + } + + @objc func buttonTapped() { + onTapped?() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + buttonText = "" + onTapped = nil + hasDestructiveAction = false + } +} diff --git a/WireGuard/WireGuard/UI/iOS/View/CheckmarkCell.swift b/WireGuard/WireGuard/UI/iOS/View/CheckmarkCell.swift new file mode 100644 index 0000000..db4b6c9 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/View/CheckmarkCell.swift @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class CheckmarkCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel!.text = value } + } + var isChecked: Bool { + didSet { + accessoryType = isChecked ? .checkmark : .none + } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + isChecked = false + super.init(style: .default, reuseIdentifier: reuseIdentifier) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + message = "" + isChecked = false + } +} diff --git a/WireGuard/WireGuard/UI/iOS/View/CopyableLabelTableViewCell.swift b/WireGuard/WireGuard/UI/iOS/View/CopyableLabelTableViewCell.swift new file mode 100644 index 0000000..93a9ef7 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/View/CopyableLabelTableViewCell.swift @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class CopyableLabelTableViewCell: UITableViewCell { + var copyableGesture = true + + var textToCopy: String? { + fatalError("textToCopy must be implemented by subclass") + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:))) + addGestureRecognizer(gestureRecognizer) + isUserInteractionEnabled = true + } + + // MARK: - UIGestureRecognizer + @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 = textToCopy + } + + override func prepareForReuse() { + super.prepareForReuse() + copyableGesture = true + } +} diff --git a/WireGuard/WireGuard/UI/iOS/View/EditableKeyValueCell.swift b/WireGuard/WireGuard/UI/iOS/View/EditableKeyValueCell.swift new file mode 100644 index 0000000..48956eb --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/View/EditableKeyValueCell.swift @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class EditableKeyValueCell: UITableViewCell { + var key: String { + get { return keyLabel.text ?? "" } + set(value) { keyLabel.text = value } + } + var value: String { + get { return valueTextField.text ?? "" } + set(value) { valueTextField.text = value } + } + var placeholderText: String { + get { return valueTextField.placeholder ?? "" } + set(value) { valueTextField.placeholder = value } + } + var isValueValid = true { + didSet { + if isValueValid { + keyLabel.textColor = .black + } else { + keyLabel.textColor = .red + } + } + } + var keyboardType: UIKeyboardType { + get { return valueTextField.keyboardType } + set(value) { valueTextField.keyboardType = value } + } + + var onValueChanged: ((String) -> Void)? + var onValueBeingEdited: ((String) -> Void)? + + let keyLabel: UILabel = { + let keyLabel = UILabel() + keyLabel.font = UIFont.preferredFont(forTextStyle: .body) + keyLabel.adjustsFontForContentSizeCategory = true + return keyLabel + }() + + let valueTextField: UITextField = { + let valueTextField = UITextField() + valueTextField.font = UIFont.preferredFont(forTextStyle: .body) + valueTextField.adjustsFontForContentSizeCategory = true + valueTextField.autocapitalizationType = .none + valueTextField.autocorrectionType = .no + valueTextField.spellCheckingType = .no + return valueTextField + }() + + var isStackedHorizontally = false + var isStackedVertically = false + var contentSizeBasedConstraints = [NSLayoutConstraint]() + + private var textFieldValueOnBeginEditing: String = "" + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(keyLabel) + keyLabel.translatesAutoresizingMaskIntoConstraints = false + keyLabel.textAlignment = .right + let widthRatioConstraint = NSLayoutConstraint(item: keyLabel, attribute: .width, + relatedBy: .equal, + toItem: self, attribute: .width, + multiplier: 0.4, constant: 0) + // The "Persistent Keepalive" key doesn't fit into 0.4 * width on the iPhone SE, + // so set a CR priority > the 0.4-constraint's priority. + widthRatioConstraint.priority = .defaultHigh + 1 + keyLabel.setContentCompressionResistancePriority(.defaultHigh + 2, for: .horizontal) + NSLayoutConstraint.activate([ + keyLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), + keyLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5), + widthRatioConstraint + ]) + + contentView.addSubview(valueTextField) + valueTextField.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + valueTextField.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor), + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: valueTextField.bottomAnchor, multiplier: 0.5) + ]) + valueTextField.delegate = self + + configureForContentSize() + } + + func configureForContentSize() { + var constraints = [NSLayoutConstraint]() + if traitCollection.preferredContentSizeCategory.isAccessibilityCategory { + // Stack vertically + if !isStackedVertically { + constraints = [ + valueTextField.topAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), + valueTextField.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), + keyLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor) + ] + isStackedVertically = true + isStackedHorizontally = false + } + } else { + // Stack horizontally + if !isStackedHorizontally { + constraints = [ + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), + valueTextField.leftAnchor.constraint(equalToSystemSpacingAfter: keyLabel.rightAnchor, multiplier: 1), + valueTextField.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) + ] + isStackedHorizontally = true + isStackedVertically = false + } + } + if !constraints.isEmpty { + NSLayoutConstraint.deactivate(contentSizeBasedConstraints) + NSLayoutConstraint.activate(constraints) + contentSizeBasedConstraints = constraints + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + key = "" + value = "" + placeholderText = "" + isValueValid = true + keyboardType = .default + onValueChanged = nil + onValueBeingEdited = nil + configureForContentSize() + } +} + +extension EditableKeyValueCell: UITextFieldDelegate { + func textFieldDidBeginEditing(_ textField: UITextField) { + textFieldValueOnBeginEditing = textField.text ?? "" + isValueValid = true + } + func textFieldDidEndEditing(_ textField: UITextField) { + let isModified = (textField.text ?? "" != textFieldValueOnBeginEditing) + guard isModified else { return } + if let onValueChanged = onValueChanged { + onValueChanged(textField.text ?? "") + } + } + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if let onValueBeingEdited = onValueBeingEdited { + let modifiedText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string) + onValueBeingEdited(modifiedText) + } + return true + } +} diff --git a/WireGuard/WireGuard/UI/iOS/View/KeyValueCell.swift b/WireGuard/WireGuard/UI/iOS/View/KeyValueCell.swift new file mode 100644 index 0000000..78026ea --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/View/KeyValueCell.swift @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class KeyValueCell: CopyableLabelTableViewCell { + var key: String { + get { return keyLabel.text ?? "" } + set(value) { keyLabel.text = value } + } + var value: String { + get { return valueLabel.text } + set(value) { valueLabel.text = value } + } + + override var textToCopy: String? { + return valueLabel.text + } + + let keyLabel: UILabel = { + let keyLabel = UILabel() + keyLabel.font = UIFont.preferredFont(forTextStyle: .body) + keyLabel.adjustsFontForContentSizeCategory = true + keyLabel.textColor = .black + return keyLabel + }() + + let valueLabel: ScrollableLabel = { + let valueLabel = ScrollableLabel() + valueLabel.label.font = UIFont.preferredFont(forTextStyle: .body) + valueLabel.label.adjustsFontForContentSizeCategory = true + valueLabel.textColor = .gray + return valueLabel + }() + + var isStackedHorizontally = false + var isStackedVertically = false + var contentSizeBasedConstraints = [NSLayoutConstraint]() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(keyLabel) + keyLabel.translatesAutoresizingMaskIntoConstraints = false + keyLabel.textAlignment = .left + NSLayoutConstraint.activate([ + keyLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), + keyLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) + ]) + + contentView.addSubview(valueLabel) + valueLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + valueLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor), + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: valueLabel.bottomAnchor, multiplier: 0.5) + ]) + + keyLabel.setContentCompressionResistancePriority(.defaultHigh + 1, for: .horizontal) + keyLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + valueLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + + configureForContentSize() + } + + func configureForContentSize() { + var constraints = [NSLayoutConstraint]() + if traitCollection.preferredContentSizeCategory.isAccessibilityCategory { + // Stack vertically + if !isStackedVertically { + constraints = [ + valueLabel.topAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), + valueLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), + keyLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor) + ] + isStackedVertically = true + isStackedHorizontally = false + } + } else { + // Stack horizontally + if !isStackedHorizontally { + constraints = [ + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), + valueLabel.leftAnchor.constraint(equalToSystemSpacingAfter: keyLabel.rightAnchor, multiplier: 1), + valueLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) + ] + isStackedHorizontally = true + isStackedVertically = false + } + } + if !constraints.isEmpty { + NSLayoutConstraint.deactivate(contentSizeBasedConstraints) + NSLayoutConstraint.activate(constraints) + contentSizeBasedConstraints = constraints + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + key = "" + value = "" + configureForContentSize() + } +} diff --git a/WireGuard/WireGuard/UI/iOS/View/ScrollableLabel.swift b/WireGuard/WireGuard/UI/iOS/View/ScrollableLabel.swift new file mode 100644 index 0000000..bd6f547 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/View/ScrollableLabel.swift @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class ScrollableLabel: UIScrollView { + var text: String { + get { return label.text ?? "" } + set(value) { label.text = value } + } + var textColor: UIColor { + get { return label.textColor } + set(value) { label.textColor = value } + } + + let label: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textAlignment = .right + return label + }() + + init() { + super.init(frame: CGRect.zero) + + isDirectionalLockEnabled = true + showsHorizontalScrollIndicator = false + showsVerticalScrollIndicator = false + + addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.leftAnchor.constraint(equalTo: contentLayoutGuide.leftAnchor), + label.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor), + label.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor), + label.rightAnchor.constraint(equalTo: contentLayoutGuide.rightAnchor), + label.heightAnchor.constraint(equalTo: heightAnchor) + ]) + + let expandToFitValueLabelConstraint = NSLayoutConstraint(item: label, attribute: .width, relatedBy: .equal, toItem: self, attribute: .width, multiplier: 1, constant: 0) + expandToFitValueLabelConstraint.priority = .defaultLow + 1 + expandToFitValueLabelConstraint.isActive = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/WireGuard/WireGuard/UI/iOS/View/SwitchCell.swift b/WireGuard/WireGuard/UI/iOS/View/SwitchCell.swift new file mode 100644 index 0000000..d0c29aa --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/View/SwitchCell.swift @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class SwitchCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel?.text = value } + } + var isOn: Bool { + get { return switchView.isOn } + set(value) { switchView.isOn = value } + } + var isEnabled: Bool { + get { return switchView.isEnabled } + set(value) { + switchView.isEnabled = value + textLabel?.textColor = value ? .black : .gray + } + } + + var onSwitchToggled: ((Bool) -> Void)? + + 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() + isEnabled = true + message = "" + isOn = false + } +} diff --git a/WireGuard/WireGuard/UI/iOS/View/TunnelEditReadOnlyKeyValueCell.swift b/WireGuard/WireGuard/UI/iOS/View/TunnelEditReadOnlyKeyValueCell.swift new file mode 100644 index 0000000..15d58d6 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/View/TunnelEditReadOnlyKeyValueCell.swift @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelEditReadOnlyKeyValueCell: CopyableLabelTableViewCell { + var key: String { + get { return keyLabel.text ?? "" } + set(value) { keyLabel.text = value } + } + var value: String { + get { return valueLabel.text } + set(value) { valueLabel.text = value } + } + + override var textToCopy: String? { + return valueLabel.text + } + + let keyLabel: UILabel = { + let keyLabel = UILabel() + keyLabel.font = UIFont.preferredFont(forTextStyle: .body) + keyLabel.adjustsFontForContentSizeCategory = true + keyLabel.textColor = .gray + return keyLabel + }() + + let valueLabel: ScrollableLabel = { + let valueLabel = ScrollableLabel() + valueLabel.label.font = UIFont.preferredFont(forTextStyle: .body) + valueLabel.label.adjustsFontForContentSizeCategory = true + valueLabel.textColor = .gray + return valueLabel + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(keyLabel) + keyLabel.translatesAutoresizingMaskIntoConstraints = false + keyLabel.textAlignment = .right + let widthRatioConstraint = NSLayoutConstraint(item: keyLabel, attribute: .width, + relatedBy: .equal, + toItem: self, attribute: .width, + multiplier: 0.4, constant: 0) + // In case the key doesn't fit into 0.4 * width, + // so set a CR priority > the 0.4-constraint's priority. + widthRatioConstraint.priority = .defaultHigh + 1 + keyLabel.setContentCompressionResistancePriority(.defaultHigh + 2, for: .horizontal) + NSLayoutConstraint.activate([ + keyLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + keyLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), + widthRatioConstraint + ]) + + contentView.addSubview(valueLabel) + valueLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + valueLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + valueLabel.leftAnchor.constraint(equalToSystemSpacingAfter: keyLabel.rightAnchor, multiplier: 1), + valueLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor) + ]) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + key = "" + value = "" + } +} diff --git a/WireGuard/WireGuard/UI/iOS/View/TunnelListCell.swift b/WireGuard/WireGuard/UI/iOS/View/TunnelListCell.swift new file mode 100644 index 0000000..14a7194 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/View/TunnelListCell.swift @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 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 ?? "" + nameObservervationToken = tunnel?.observe(\.name) { [weak self] tunnel, _ in + self?.nameLabel.text = tunnel.name + } + // Bind to the tunnel's status + update(from: tunnel?.status) + statusObservervationToken = tunnel?.observe(\.status) { [weak self] tunnel, _ in + self?.update(from: tunnel.status) + } + } + } + var onSwitchToggled: ((Bool) -> Void)? + + let nameLabel: UILabel = { + let nameLabel = UILabel() + nameLabel.font = UIFont.preferredFont(forTextStyle: .body) + nameLabel.adjustsFontForContentSizeCategory = true + nameLabel.numberOfLines = 0 + return nameLabel + }() + + let busyIndicator: UIActivityIndicatorView = { + let busyIndicator = UIActivityIndicatorView(style: .gray) + busyIndicator.hidesWhenStopped = true + return busyIndicator + }() + + let statusSwitch = UISwitch() + + private var statusObservervationToken: AnyObject? + private var nameObservervationToken: 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.rightAnchor.constraint(equalTo: statusSwitch.rightAnchor) + ]) + + contentView.addSubview(busyIndicator) + busyIndicator.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + busyIndicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + statusSwitch.leftAnchor.constraint(equalToSystemSpacingAfter: busyIndicator.rightAnchor, 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.leftAnchor.constraint(equalToSystemSpacingAfter: contentView.layoutMarginsGuide.leftAnchor, multiplier: 1), + busyIndicator.leftAnchor.constraint(equalToSystemSpacingAfter: nameLabel.rightAnchor, multiplier: 1), + bottomAnchorConstraint + ]) + + accessoryType = .disclosureIndicator + + statusSwitch.addTarget(self, action: #selector(switchToggled), for: .valueChanged) + } + + @objc func switchToggled() { + onSwitchToggled?(statusSwitch.isOn) + } + + private func update(from status: TunnelStatus?) { + guard let status = status else { + reset() + return + } + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(200)) { [weak statusSwitch, weak busyIndicator] in + guard let statusSwitch = statusSwitch, let busyIndicator = busyIndicator else { return } + statusSwitch.isOn = !(status == .deactivating || status == .inactive) + statusSwitch.isUserInteractionEnabled = (status == .inactive || status == .active) + if status == .inactive || status == .active { + busyIndicator.stopAnimating() + } else { + busyIndicator.startAnimating() + } + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func reset() { + statusSwitch.isOn = false + statusSwitch.isUserInteractionEnabled = false + busyIndicator.stopAnimating() + } + + override func prepareForReuse() { + super.prepareForReuse() + reset() + } +} diff --git a/WireGuard/WireGuard/UI/iOS/ViewController/MainViewController.swift b/WireGuard/WireGuard/UI/iOS/ViewController/MainViewController.swift new file mode 100644 index 0000000..82e6f81 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/ViewController/MainViewController.swift @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class MainViewController: UISplitViewController { + + var tunnelsManager: TunnelsManager? + var onTunnelsManagerReady: ((TunnelsManager) -> Void)? + + var tunnelsListVC: TunnelsListTableViewController? + + init() { + let detailVC = UIViewController() + 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 ] + + // State restoration + 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 } + + if let error = result.error { + ErrorPresenter.showErrorAlert(error: error, from: self) + return + } + let tunnelsManager: TunnelsManager = result.value! + + self.tunnelsManager = tunnelsManager + self.tunnelsListVC?.setTunnelsManager(tunnelsManager: tunnelsManager) + + tunnelsManager.activationDelegate = self + + self.onTunnelsManagerReady?(tunnelsManager) + self.onTunnelsManagerReady = nil + } + } +} + +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) { + let showTunnelDetailBlock: (TunnelsManager) -> Void = { [weak self] tunnelsManager in + if let tunnel = tunnelsManager.tunnel(named: tunnelName) { + let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager, tunnel: tunnel) + let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC) + tunnelDetailNC.restorationIdentifier = "DetailNC" + if let self = self { + if animated { + self.showDetailViewController(tunnelDetailNC, sender: self) + } else { + UIView.performWithoutAnimation { + self.showDetailViewController(tunnelDetailNC, sender: self) + } + } + } + } + } + if let tunnelsManager = tunnelsManager { + showTunnelDetailBlock(tunnelsManager) + } else { + onTunnelsManagerReady = showTunnelDetailBlock + } + } +} + +extension MainViewController: UISplitViewControllerDelegate { + func splitViewController(_ splitViewController: UISplitViewController, + collapseSecondary secondaryViewController: UIViewController, + onto primaryViewController: UIViewController) -> Bool { + // On iPhone, if the secondaryVC (detailVC) is just a UIViewController, it indicates that it's empty, + // so just show the primaryVC (masterVC). + let detailVC = (secondaryViewController as? UINavigationController)?.viewControllers.first + let isDetailVCEmpty: Bool + if let detailVC = detailVC { + isDetailVCEmpty = (type(of: detailVC) == UIViewController.self) + } else { + isDetailVCEmpty = true + } + return isDetailVCEmpty + } +} diff --git a/WireGuard/WireGuard/UI/iOS/ViewController/QRScanViewController.swift b/WireGuard/WireGuard/UI/iOS/ViewController/QRScanViewController.swift new file mode 100644 index 0000000..1e231ec --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/ViewController/QRScanViewController.swift @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import AVFoundation +import UIKit + +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 = "Scan QR code" + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped)) + + let tipLabel = UILabel() + tipLabel.text = "Tip: Generate with `qrencode -t ansiutf8 < tunnel.conf`" + tipLabel.adjustsFontSizeToFitWidth = true + tipLabel.textColor = .lightGray + tipLabel.textAlignment = .center + + view.addSubview(tipLabel) + tipLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tipLabel.leftAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leftAnchor), + tipLabel.rightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.rightAnchor), + tipLabel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -32) + ]) + + guard let videoCaptureDevice = AVCaptureDevice.default(for: .video), + let videoInput = try? AVCaptureDeviceInput(device: videoCaptureDevice), + let captureSession = captureSession, + captureSession.canAddInput(videoInput), + captureSession.canAddOutput(metadataOutput) else { + scanDidEncounterError(title: "Camera Unsupported", message: "This device is not able to scan QR codes") + 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 = UIDevice.current + + let orientation: UIDeviceOrientation = currentDevice.orientation + + let previewLayerConnection: AVCaptureConnection = 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? WgQuickConfigFileParser.parse(code, name: "Scanned") + guard let tunnelConfiguration = scannedTunnelConfiguration else { + scanDidEncounterError(title: "Invalid QR Code", message: "The scanned QR code is not a valid WireGuard configuration") + return + } + + let alert = UIAlertController(title: NSLocalizedString("Please name the scanned tunnel", comment: ""), message: nil, preferredStyle: .alert) + alert.addTextField(configurationHandler: nil) + alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel) { [weak self] _ in + self?.dismiss(animated: true, completion: nil) + }) + alert.addAction(UIAlertAction(title: NSLocalizedString("Save", comment: ""), style: .default) { [weak self] _ in + guard let title = alert.textFields?[0].text?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else { return } + tunnelConfiguration.interface.name = title + if let self = self { + self.delegate?.addScannedQRCode(tunnelConfiguration: tunnelConfiguration, qrScanViewController: self) { + self.dismiss(animated: true, completion: nil) + } + } + }) + present(alert, animated: true) + } + + func scanDidEncounterError(title: String, message: String) { + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: "OK", style: .default) { [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: "Invalid Code", message: "The scanned code could not be read") + return + } + + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + scanDidComplete(withCode: stringValue) + } +} diff --git a/WireGuard/WireGuard/UI/iOS/ViewController/SettingsTableViewController.swift b/WireGuard/WireGuard/UI/iOS/ViewController/SettingsTableViewController.swift new file mode 100644 index 0000000..b583c5b --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/ViewController/SettingsTableViewController.swift @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit +import os.log + +class SettingsTableViewController: UITableViewController { + + enum SettingsFields: String { + case iosAppVersion = "WireGuard for iOS" + case goBackendVersion = "WireGuard Go Backend" + case exportZipArchive = "Export zip archive" + case exportLogFile = "Export log file" + } + + let settingsFieldsBySection: [[SettingsFields]] = [ + [.iosAppVersion, .goBackendVersion], + [.exportZipArchive], + [.exportLogFile] + ] + + let tunnelsManager: TunnelsManager? + var wireguardCaptionedImage: (view: UIView, size: CGSize)? + + init(tunnelsManager: TunnelsManager?) { + self.tunnelsManager = tunnelsManager + super.init(style: .grouped) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = "Settings" + 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) { + guard let tunnelsManager = tunnelsManager else { return } + guard let destinationDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + + let destinationURL = destinationDir.appendingPathComponent("wireguard-export.zip") + _ = FileManager.deleteFile(at: destinationURL) + + let count = tunnelsManager.numberOfTunnels() + let tunnelConfigurations = (0 ..< count).compactMap { tunnelsManager.tunnel(at: $0).tunnelConfiguration() } + ZipExporter.exportConfigFiles(tunnelConfigurations: tunnelConfigurations, to: destinationURL) { [weak self] error in + if let error = error { + ErrorPresenter.showErrorAlert(error: error, from: self) + return + } + + let fileExportVC = UIDocumentPickerViewController(url: destinationURL, in: .exportToService) + self?.present(fileExportVC, animated: true, completion: nil) + } + } + + func exportLogForLastActivatedTunnel(sourceView: UIView) { + guard let destinationDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withFullDate, .withTime, .withTimeZone] // Avoid ':' in the filename + let timeStampString = dateFormatter.string(from: Date()) + let destinationURL = destinationDir.appendingPathComponent("wireguard-log-\(timeStampString).txt") + + DispatchQueue.global(qos: .userInitiated).async { + + if FileManager.default.fileExists(atPath: destinationURL.path) { + let isDeleted = FileManager.deleteFile(at: destinationURL) + if !isDeleted { + ErrorPresenter.showErrorAlert(title: "Log export failed", message: "The pre-existing log could not be cleared", from: self) + return + } + } + + guard let networkExtensionLogFilePath = FileManager.networkExtensionLogFileURL?.path else { + ErrorPresenter.showErrorAlert(title: "Log export failed", message: "Unable to determine extension log path", from: self) + return + } + + let isWritten = Logger.global?.writeLog(called: "APP", mergedWith: networkExtensionLogFilePath, called: "NET", to: destinationURL.path) ?? false + guard isWritten else { + ErrorPresenter.showErrorAlert(title: "Log export failed", message: "Unable to write logs to file", from: self) + return + } + + DispatchQueue.main.async { + let activityVC = UIActivityViewController(activityItems: [destinationURL], applicationActivities: nil) + // popoverPresentationController shall be non-nil on the iPad + activityVC.popoverPresentationController?.sourceView = sourceView + activityVC.popoverPresentationController?.sourceRect = sourceView.bounds + activityVC.completionWithItemsHandler = { _, _, _, _ in + // Remove the exported log file after the activity has completed + _ = FileManager.deleteFile(at: destinationURL) + } + self.present(activityVC, animated: true) + } + } + } +} + +// MARK: UITableViewDataSource + +extension SettingsTableViewController { + override func numberOfSections(in tableView: UITableView) -> Int { + return settingsFieldsBySection.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return settingsFieldsBySection[section].count + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch section { + case 0: + return "About" + case 1: + return "Export configurations" + case 2: + return "Tunnel log" + default: + return nil + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let field = settingsFieldsBySection[indexPath.section][indexPath.row] + if field == .iosAppVersion || field == .goBackendVersion { + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.copyableGesture = false + cell.key = field.rawValue + if field == .iosAppVersion { + var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown version" + if let appBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String { + appVersion += " (\(appBuild))" + } + cell.value = appVersion + } else if field == .goBackendVersion { + cell.value = WIREGUARD_GO_VERSION + } + return cell + } else if field == .exportZipArchive { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.rawValue + cell.onTapped = { [weak self] in + self?.exportConfigurationsAsZipFile(sourceView: cell.button) + } + return cell + } else { + assert(field == .exportLogFile) + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.rawValue + cell.onTapped = { [weak self] in + self?.exportLogForLastActivatedTunnel(sourceView: cell.button) + } + return cell + } + } +} diff --git a/WireGuard/WireGuard/UI/iOS/ViewController/TunnelDetailTableViewController.swift b/WireGuard/WireGuard/UI/iOS/ViewController/TunnelDetailTableViewController.swift new file mode 100644 index 0000000..ed48d0f --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/ViewController/TunnelDetailTableViewController.swift @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +// MARK: TunnelDetailTableViewController + +class TunnelDetailTableViewController: UITableViewController { + + private enum Section { + case status + case interface + case peer(_ peer: TunnelViewModel.PeerData) + case onDemand + case delete + } + + let interfaceFields: [TunnelViewModel.InterfaceField] = [ + .name, .publicKey, .addresses, + .listenPort, .mtu, .dns + ] + + let peerFields: [TunnelViewModel.PeerField] = [ + .publicKey, .preSharedKey, .endpoint, + .allowedIPs, .persistentKeepAlive + ] + + let tunnelsManager: TunnelsManager + let tunnel: TunnelContainer + var tunnelViewModel: TunnelViewModel + private var sections = [Section]() + private var onDemandStatusObservervationToken: AnyObject? + private var statusObservervationToken: AnyObject? + + init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) { + self.tunnelsManager = tunnelsManager + self.tunnel = tunnel + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration()) + super.init(style: .grouped) + loadSections() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + onDemandStatusObservervationToken = nil + statusObservervationToken = nil + } + + override func viewDidLoad() { + super.viewDidLoad() + title = tunnelViewModel.interfaceData[.name] + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(editTapped)) + + tableView.estimatedRowHeight = 44 + tableView.rowHeight = UITableView.automaticDimension + tableView.allowsSelection = false + tableView.register(SwitchCell.self) + tableView.register(KeyValueCell.self) + tableView.register(ButtonCell.self) + + // State restoration + restorationIdentifier = "TunnelDetailVC:\(tunnel.name)" + } + + private func loadSections() { + sections.removeAll() + sections.append(.status) + sections.append(.interface) + tunnelViewModel.peersData.forEach { sections.append(.peer($0)) } + sections.append(.onDemand) + sections.append(.delete) + } + + @objc func editTapped() { + let editVC = TunnelEditTableViewController(tunnelsManager: tunnelsManager, tunnel: tunnel) + editVC.delegate = self + let editNC = UINavigationController(rootViewController: editVC) + editNC.modalPresentationStyle = .formSheet + present(editNC, animated: true) + } + + func showConfirmationAlert(message: String, buttonTitle: String, from sourceView: UIView, onConfirmed: @escaping (() -> Void)) { + let destroyAction = UIAlertAction(title: buttonTitle, style: .destructive) { _ in + onConfirmed() + } + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) + let alert = UIAlertController(title: "", message: message, preferredStyle: .actionSheet) + alert.addAction(destroyAction) + alert.addAction(cancelAction) + + // popoverPresentationController will be nil on iPhone and non-nil on iPad + alert.popoverPresentationController?.sourceView = sourceView + alert.popoverPresentationController?.sourceRect = sourceView.bounds + + present(alert, animated: true, completion: nil) + } +} + +// MARK: TunnelEditTableViewControllerDelegate + +extension TunnelDetailTableViewController: TunnelEditTableViewControllerDelegate { + func tunnelSaved(tunnel: TunnelContainer) { + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration()) + loadSections() + title = tunnel.name + tableView.reloadData() + } + func tunnelEditingCancelled() { + // Nothing to do + } +} + +// MARK: UITableViewDataSource + +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 tunnelViewModel.interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFields).count + case .peer(let peerData): + return peerData.filterFieldsWithValueOrControl(peerFields: peerFields).count + case .onDemand: + return 1 + case .delete: + return 1 + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch sections[section] { + case .status: + return "Status" + case .interface: + return "Interface" + case .peer: + return "Peer" + case .onDemand: + return "On-Demand Activation" + 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 peer): + return peerCell(for: tableView, at: indexPath, with: peer) + 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 = "Inactive" + case .activating: + text = "Activating" + case .active: + text = "Active" + case .deactivating: + text = "Deactivating" + case .reasserting: + text = "Reactivating" + case .restarting: + text = "Restarting" + case .waiting: + text = "Waiting" + } + 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) + statusObservervationToken = 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 field = tunnelViewModel.interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFields)[indexPath.row] + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.rawValue + cell.value = tunnelViewModel.interfaceData[field] + return cell + } + + private func peerCell(for tableView: UITableView, at indexPath: IndexPath, with peerData: TunnelViewModel.PeerData) -> UITableViewCell { + let field = peerData.filterFieldsWithValueOrControl(peerFields: peerFields)[indexPath.row] + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.rawValue + cell.value = peerData[field] + return cell + } + + private func onDemandCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = "Activate on demand" + cell.value = TunnelViewModel.activateOnDemandDetailText(for: tunnel.activateOnDemandSetting()) + onDemandStatusObservervationToken = tunnel.observe(\.isActivateOnDemandEnabled) { [weak cell] tunnel, _ in + cell?.value = TunnelViewModel.activateOnDemandDetailText(for: tunnel.activateOnDemandSetting()) + } + return cell + } + + private func deleteConfigurationCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = "Delete tunnel" + cell.hasDestructiveAction = true + cell.onTapped = { [weak self] in + guard let self = self else { return } + self.showConfirmationAlert(message: "Delete this tunnel?", buttonTitle: "Delete", from: cell) { [weak self] in + guard let tunnelsManager = self?.tunnelsManager, let tunnel = self?.tunnel else { return } + tunnelsManager.remove(tunnel: tunnel) { error in + if error != nil { + print("Error removing tunnel: \(String(describing: error))") + return + } + } + self?.navigationController?.navigationController?.popToRootViewController(animated: true) + } + } + return cell + } + +} diff --git a/WireGuard/WireGuard/UI/iOS/ViewController/TunnelEditTableViewController.swift b/WireGuard/WireGuard/UI/iOS/ViewController/TunnelEditTableViewController.swift new file mode 100644 index 0000000..393294e --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/ViewController/TunnelEditTableViewController.swift @@ -0,0 +1,488 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +protocol TunnelEditTableViewControllerDelegate: class { + func tunnelSaved(tunnel: TunnelContainer) + func tunnelEditingCancelled() +} + +// MARK: TunnelEditTableViewController + +class TunnelEditTableViewController: UITableViewController { + + private enum Section { + case interface + case peer(_ peer: TunnelViewModel.PeerData) + case addPeer + case onDemand + } + + weak var delegate: TunnelEditTableViewControllerDelegate? + + let interfaceFieldsBySection: [[TunnelViewModel.InterfaceField]] = [ + [.name], + [.privateKey, .publicKey, .generateKeyPair], + [.addresses, .listenPort, .mtu, .dns] + ] + + let peerFields: [TunnelViewModel.PeerField] = [ + .publicKey, .preSharedKey, .endpoint, + .allowedIPs, .excludePrivateIPs, .persistentKeepAlive, + .deletePeer + ] + + let activateOnDemandOptions: [ActivateOnDemandOption] = [ + .useOnDemandOverWiFiOrCellular, + .useOnDemandOverWiFiOnly, + .useOnDemandOverCellularOnly + ] + + let tunnelsManager: TunnelsManager + let tunnel: TunnelContainer? + let tunnelViewModel: TunnelViewModel + var activateOnDemandSetting: ActivateOnDemandSetting + private var sections = [Section]() + + init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) { + // Use this initializer to edit an existing tunnel. + self.tunnelsManager = tunnelsManager + self.tunnel = tunnel + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration()) + activateOnDemandSetting = tunnel.activateOnDemandSetting() + super.init(style: .grouped) + loadSections() + } + + init(tunnelsManager: TunnelsManager, tunnelConfiguration: TunnelConfiguration?) { + // Use this initializer to create a new tunnel. + // If tunnelConfiguration is passed, data will be prepopulated from that configuration. + self.tunnelsManager = tunnelsManager + tunnel = nil + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnelConfiguration) + activateOnDemandSetting = ActivateOnDemandSetting.defaultSetting + super.init(style: .grouped) + loadSections() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = tunnel == nil ? "New configuration" : "Edit configuration" + 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(EditableKeyValueCell.self) + tableView.register(TunnelEditReadOnlyKeyValueCell.self) + tableView.register(ButtonCell.self) + tableView.register(SwitchCell.self) + tableView.register(CheckmarkCell.self) + } + + private func loadSections() { + sections.removeAll() + interfaceFieldsBySection.forEach { _ in sections.append(.interface) } + tunnelViewModel.peersData.forEach { sections.append(.peer($0)) } + sections.append(.addPeer) + sections.append(.onDemand) + } + + @objc func saveTapped() { + tableView.endEditing(false) + let tunnelSaveResult = tunnelViewModel.save() + switch tunnelSaveResult { + case .error(let errorMessage): + let erroringConfiguration = (tunnelViewModel.interfaceData.validatedConfiguration == nil) ? "Interface" : "Peer" + ErrorPresenter.showErrorAlert(title: "Invalid \(erroringConfiguration)", message: errorMessage, from: self) + tableView.reloadData() // Highlight erroring fields + case .saved(let tunnelConfiguration): + if let tunnel = tunnel { + // We're modifying an existing tunnel + tunnelsManager.modify(tunnel: tunnel, + tunnelConfiguration: tunnelConfiguration, + activateOnDemandSetting: activateOnDemandSetting) { [weak self] error in + if let error = error { + ErrorPresenter.showErrorAlert(error: error, from: self) + } else { + self?.dismiss(animated: true, completion: nil) + self?.delegate?.tunnelSaved(tunnel: tunnel) + } + } + } else { + // We're adding a new tunnel + tunnelsManager.add(tunnelConfiguration: tunnelConfiguration, + activateOnDemandSetting: activateOnDemandSetting) { [weak self] result in + if let error = result.error { + ErrorPresenter.showErrorAlert(error: error, from: self) + } else { + let tunnel: TunnelContainer = result.value! + self?.dismiss(animated: true, completion: nil) + self?.delegate?.tunnelSaved(tunnel: tunnel) + } + } + } + } + } + + @objc func cancelTapped() { + dismiss(animated: true, completion: nil) + delegate?.tunnelEditingCancelled() + } +} + +// MARK: UITableViewDataSource + +extension TunnelEditTableViewController { + override func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch sections[section] { + case .interface: + return interfaceFieldsBySection[section].count + case .peer(let peerData): + let peerFieldsToShow = peerData.shouldAllowExcludePrivateIPsControl ? peerFields : peerFields.filter { $0 != .excludePrivateIPs } + return peerFieldsToShow.count + case .addPeer: + return 1 + case .onDemand: + if activateOnDemandSetting.isActivateOnDemandEnabled { + return 4 + } else { + return 1 + } + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch sections[section] { + case .interface: + return section == 0 ? "Interface" : nil + case .peer: + return "Peer" + case .addPeer: + return nil + case .onDemand: + return "On-Demand Activation" + } + } + + 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.rawValue + cell.onTapped = { [weak self] in + guard let self = self else { return } + + self.tunnelViewModel.interfaceData[.privateKey] = Curve25519.generatePrivateKey().base64EncodedString() + 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: TunnelEditReadOnlyKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.rawValue + cell.value = tunnelViewModel.interfaceData[field] + return cell + } + + private func interfaceFieldKeyValueCell(for tableView: UITableView, at indexPath: IndexPath, with field: TunnelViewModel.InterfaceField) -> UITableViewCell { + let cell: EditableKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.rawValue + + switch field { + case .name, .privateKey: + cell.placeholderText = "Required" + cell.keyboardType = .default + case .addresses, .dns: + cell.placeholderText = "Optional" + cell.keyboardType = .numbersAndPunctuation + case .listenPort, .mtu: + cell.placeholderText = "Automatic" + cell.keyboardType = .numberPad + case .publicKey, .generateKeyPair: + cell.keyboardType = .default + } + + cell.isValueValid = (!tunnelViewModel.interfaceData.fieldsWithError.contains(field)) + // Bind values to view model + cell.value = tunnelViewModel.interfaceData[field] + if field == .dns { // While editing DNS, you might directly set exclude private IPs + cell.onValueChanged = nil + cell.onValueBeingEdited = { [weak self] value in + self?.tunnelViewModel.interfaceData[field] = value + } + } else { + cell.onValueChanged = { [weak self] value in + self?.tunnelViewModel.interfaceData[field] = value + } + cell.onValueBeingEdited = nil + } + // 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) + } + } + } else { + cell.onValueBeingEdited = nil + } + 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.rawValue + cell.hasDestructiveAction = true + cell.onTapped = { [weak self, weak peerData] in + guard let peerData = peerData else { return } + guard let self = self else { return } + self.showConfirmationAlert(message: "Delete this peer?", buttonTitle: "Delete", from: cell) { [weak self] in + guard let self = self else { return } + let removedSectionIndices = self.deletePeer(peer: peerData) + let shouldShowExcludePrivateIPs = (self.tunnelViewModel.peersData.count == 1 && self.tunnelViewModel.peersData[0].shouldAllowExcludePrivateIPsControl) + 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.rawValue + 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: EditableKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.rawValue + + switch field { + case .publicKey: + cell.placeholderText = "Required" + cell.keyboardType = .default + case .preSharedKey, .endpoint: + cell.placeholderText = "Optional" + cell.keyboardType = .default + case .allowedIPs: + cell.placeholderText = "Optional" + cell.keyboardType = .numbersAndPunctuation + case .persistentKeepAlive: + cell.placeholderText = "Off" + cell.keyboardType = .numberPad + case .excludePrivateIPs, .deletePeer: + cell.keyboardType = .default + } + + cell.isValueValid = !peerData.fieldsWithError.contains(field) + cell.value = peerData[field] + + if field == .allowedIPs { + 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 { + if 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) + } + } + } + } + } 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 = "Add peer" + cell.onTapped = { [weak self] in + guard let self = self else { return } + let shouldHideExcludePrivateIPs = (self.tunnelViewModel.peersData.count == 1 && self.tunnelViewModel.peersData[0].shouldAllowExcludePrivateIPsControl) + let addedSectionIndices = self.appendEmptyPeer() + tableView.performBatchUpdates({ + tableView.insertSections(addedSectionIndices, with: .fade) + if shouldHideExcludePrivateIPs { + if let row = self.peerFields.firstIndex(of: .excludePrivateIPs) { + let rowIndexPath = IndexPath(row: row, section: self.interfaceFieldsBySection.count /* First peer section */) + self.tableView.deleteRows(at: [rowIndexPath], with: .fade) + } + } + }, completion: nil) + } + return cell + } + + private func onDemandCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + if indexPath.row == 0 { + let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = "Activate on demand" + cell.isOn = activateOnDemandSetting.isActivateOnDemandEnabled + cell.onSwitchToggled = { [weak self] isOn in + guard let self = self else { return } + let indexPaths = (1 ..< 4).map { IndexPath(row: $0, section: indexPath.section) } + if isOn { + self.activateOnDemandSetting.isActivateOnDemandEnabled = true + if self.activateOnDemandSetting.activateOnDemandOption == .none { + self.activateOnDemandSetting.activateOnDemandOption = TunnelViewModel.defaultActivateOnDemandOption() + } + self.loadSections() + self.tableView.insertRows(at: indexPaths, with: .fade) + } else { + self.activateOnDemandSetting.isActivateOnDemandEnabled = false + self.loadSections() + self.tableView.deleteRows(at: indexPaths, with: .fade) + } + } + return cell + } else { + let cell: CheckmarkCell = tableView.dequeueReusableCell(for: indexPath) + let rowOption = activateOnDemandOptions[indexPath.row - 1] + let selectedOption = activateOnDemandSetting.activateOnDemandOption + assert(selectedOption != .none) + cell.message = TunnelViewModel.activateOnDemandOptionText(for: rowOption) + cell.isChecked = (selectedOption == rowOption) + return cell + } + } + + func appendEmptyPeer() -> IndexSet { + tunnelViewModel.appendEmptyPeer() + loadSections() + let addedPeerIndex = tunnelViewModel.peersData.count - 1 + return IndexSet(integer: interfaceFieldsBySection.count + addedPeerIndex) + } + + func deletePeer(peer: TunnelViewModel.PeerData) -> IndexSet { + tunnelViewModel.deletePeer(peer: peer) + loadSections() + return IndexSet(integer: interfaceFieldsBySection.count + peer.index) + } + + func showConfirmationAlert(message: String, buttonTitle: String, from sourceView: UIView, onConfirmed: @escaping (() -> Void)) { + let destroyAction = UIAlertAction(title: buttonTitle, style: .destructive) { _ in + onConfirmed() + } + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) + let alert = UIAlertController(title: "", message: message, preferredStyle: .actionSheet) + alert.addAction(destroyAction) + alert.addAction(cancelAction) + + // popoverPresentationController will be nil on iPhone and non-nil on iPad + alert.popoverPresentationController?.sourceView = sourceView + alert.popoverPresentationController?.sourceRect = sourceView.bounds + + present(alert, animated: true, completion: nil) + } +} + +// MARK: UITableViewDelegate + +extension TunnelEditTableViewController { + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + if case .onDemand = sections[indexPath.section], indexPath.row > 0 { + return indexPath + } else { + return nil + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + switch sections[indexPath.section] { + case .onDemand: + let option = activateOnDemandOptions[indexPath.row - 1] + assert(option != .none) + activateOnDemandSetting.activateOnDemandOption = option + + let indexPaths = (1 ..< 4).map { IndexPath(row: $0, section: indexPath.section) } + UIView.performWithoutAnimation { + tableView.reloadRows(at: indexPaths, with: .none) + } + default: + assertionFailure() + } + } +} diff --git a/WireGuard/WireGuard/UI/iOS/ViewController/TunnelsListTableViewController.swift b/WireGuard/WireGuard/UI/iOS/ViewController/TunnelsListTableViewController.swift new file mode 100644 index 0000000..0188c62 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/ViewController/TunnelsListTableViewController.swift @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit +import MobileCoreServices +import UserNotifications + +class TunnelsListTableViewController: UIViewController { + + var tunnelsManager: TunnelsManager? + + let tableView: UITableView = { + let tableView = UITableView(frame: CGRect.zero, style: .plain) + tableView.estimatedRowHeight = 60 + tableView.rowHeight = UITableView.automaticDimension + tableView.separatorStyle = .none + tableView.register(TunnelListCell.self) + return tableView + }() + + let centeredAddButton: BorderedTextButton = { + let button = BorderedTextButton() + button.title = "Add a tunnel" + button.isHidden = true + return button + }() + + let busyIndicator: UIActivityIndicatorView = { + let busyIndicator = UIActivityIndicatorView(style: .gray) + busyIndicator.hidesWhenStopped = true + return busyIndicator + }() + + override func loadView() { + view = UIView() + view.backgroundColor = .white + + tableView.dataSource = self + tableView.delegate = self + + view.addSubview(tableView) + tableView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tableView.leftAnchor.constraint(equalTo: view.leftAnchor), + tableView.rightAnchor.constraint(equalTo: view.rightAnchor), + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + view.addSubview(busyIndicator) + busyIndicator.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + busyIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + busyIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + + view.addSubview(centeredAddButton) + centeredAddButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + centeredAddButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), + centeredAddButton.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + + centeredAddButton.onTapped = { [weak self] in + guard let self = self else { return } + self.addButtonTapped(sender: self.centeredAddButton) + } + + busyIndicator.startAnimating() + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = "WireGuard" + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped(sender:))) + navigationItem.leftBarButtonItem = UIBarButtonItem(title: "Settings", style: .plain, target: self, action: #selector(settingsButtonTapped(sender:))) + + restorationIdentifier = "TunnelsListVC" + } + + func setTunnelsManager(tunnelsManager: TunnelsManager) { + self.tunnelsManager = tunnelsManager + tunnelsManager.tunnelsListDelegate = self + + busyIndicator.stopAnimating() + tableView.reloadData() + centeredAddButton.isHidden = tunnelsManager.numberOfTunnels() > 0 + } + + override func viewWillAppear(_: Bool) { + // Remove selection when getting back to the list view on iPhone + 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: "Add a new WireGuard tunnel", preferredStyle: .actionSheet) + let importFileAction = UIAlertAction(title: "Create from file or archive", style: .default) { [weak self] _ in + self?.presentViewControllerForFileImport() + } + alert.addAction(importFileAction) + + let scanQRCodeAction = UIAlertAction(title: "Create from QR code", style: .default) { [weak self] _ in + self?.presentViewControllerForScanningQRCode() + } + alert.addAction(scanQRCodeAction) + + let createFromScratchAction = UIAlertAction(title: "Create from scratch", style: .default) { [weak self] _ in + if let self = self, let tunnelsManager = self.tunnelsManager { + self.presentViewControllerForTunnelCreation(tunnelsManager: tunnelsManager, tunnelConfiguration: nil) + } + } + alert.addAction(createFromScratchAction) + + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) + alert.addAction(cancelAction) + + // popoverPresentationController will be nil on iPhone and non-nil on iPad + if let sender = sender as? UIBarButtonItem { + alert.popoverPresentationController?.barButtonItem = sender + } else if let sender = sender as? UIView { + alert.popoverPresentationController?.sourceView = sender + alert.popoverPresentationController?.sourceRect = sender.bounds + } + 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, tunnelConfiguration: TunnelConfiguration?) { + let editVC = TunnelEditTableViewController(tunnelsManager: tunnelsManager, tunnelConfiguration: tunnelConfiguration) + let editNC = UINavigationController(rootViewController: editVC) + editNC.modalPresentationStyle = .formSheet + present(editNC, animated: true) + } + + func presentViewControllerForFileImport() { + let documentTypes = ["com.wireguard.config.quick", String(kUTTypeText), String(kUTTypeZipArchive)] + let filePicker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import) + filePicker.delegate = self + present(filePicker, animated: true) + } + + func presentViewControllerForScanningQRCode() { + let scanQRCodeVC = QRScanViewController() + scanQRCodeVC.delegate = self + let scanQRCodeNC = UINavigationController(rootViewController: scanQRCodeVC) + scanQRCodeNC.modalPresentationStyle = .fullScreen + present(scanQRCodeNC, animated: true) + } + + func importFromFile(url: URL, completionHandler: (() -> Void)?) { + guard let tunnelsManager = tunnelsManager else { return } + if url.pathExtension == "zip" { + ZipImporter.importConfigFiles(from: url) { [weak self] result in + if let error = result.error { + ErrorPresenter.showErrorAlert(error: error, from: self) + return + } + let configs = result.value! + tunnelsManager.addMultiple(tunnelConfigurations: configs.compactMap { $0 }) { [weak self] numberSuccessful in + if numberSuccessful == configs.count { + completionHandler?() + return + } + ErrorPresenter.showErrorAlert(title: "Created \(numberSuccessful) tunnels", + message: "Created \(numberSuccessful) of \(configs.count) tunnels from zip archive", + from: self, onPresented: completionHandler) + } + } + } else /* if (url.pathExtension == "conf") -- we assume everything else is a conf */ { + let fileBaseName = url.deletingPathExtension().lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) + if let fileContents = try? String(contentsOf: url), + let tunnelConfiguration = try? WgQuickConfigFileParser.parse(fileContents, name: fileBaseName) { + tunnelsManager.add(tunnelConfiguration: tunnelConfiguration) { [weak self] result in + if let error = result.error { + ErrorPresenter.showErrorAlert(error: error, from: self, onPresented: completionHandler) + } else { + completionHandler?() + } + } + } else { + ErrorPresenter.showErrorAlert(title: "Unable to import tunnel", + message: "An error occured when importing the tunnel configuration.", + from: self, onPresented: completionHandler) + } + } + } +} + +// MARK: UIDocumentPickerDelegate + +extension TunnelsListTableViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + urls.forEach { + importFromFile(url: $0, completionHandler: nil) + } + } +} + +// MARK: QRScanViewControllerDelegate + +extension TunnelsListTableViewController: QRScanViewControllerDelegate { + func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController, + completionHandler: (() -> Void)?) { + tunnelsManager?.add(tunnelConfiguration: tunnelConfiguration) { result in + if let error = result.error { + ErrorPresenter.showErrorAlert(error: error, from: qrScanViewController, onDismissal: completionHandler) + } else { + completionHandler?() + } + } + } +} + +// MARK: UITableViewDataSource + +extension TunnelsListTableViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return (tunnelsManager?.numberOfTunnels() ?? 0) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: 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 + } +} + +// MARK: UITableViewDelegate + +extension TunnelsListTableViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let tunnelsManager = tunnelsManager else { return } + let tunnel = tunnelsManager.tunnel(at: indexPath.row) + let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager, + tunnel: tunnel) + let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC) + tunnelDetailNC.restorationIdentifier = "DetailNC" + showDetailViewController(tunnelDetailNC, sender: self) // Shall get propagated up to the split-vc + } + + func tableView(_ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { [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]) + } +} + +// MARK: TunnelsManagerDelegate + +extension TunnelsListTableViewController: TunnelsManagerListDelegate { + func tunnelAdded(at index: Int) { + tableView.insertRows(at: [IndexPath(row: index, section: 0)], with: .automatic) + centeredAddButton.isHidden = (tunnelsManager?.numberOfTunnels() ?? 0 > 0) + } + + func tunnelModified(at index: Int) { + tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .automatic) + } + + func tunnelMoved(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) { + tableView.deleteRows(at: [IndexPath(row: index, section: 0)], with: .automatic) + centeredAddButton.isHidden = tunnelsManager?.numberOfTunnels() ?? 0 > 0 + } +} -- cgit v1.2.3-59-g8ed1b