aboutsummaryrefslogtreecommitdiffstats
path: root/WireGuard/WireGuard/UI/iOS/ViewController
diff options
context:
space:
mode:
Diffstat (limited to 'WireGuard/WireGuard/UI/iOS/ViewController')
-rw-r--r--WireGuard/WireGuard/UI/iOS/ViewController/MainViewController.swift129
-rw-r--r--WireGuard/WireGuard/UI/iOS/ViewController/QRScanViewController.swift159
-rw-r--r--WireGuard/WireGuard/UI/iOS/ViewController/SettingsTableViewController.swift200
-rw-r--r--WireGuard/WireGuard/UI/iOS/ViewController/TunnelDetailTableViewController.swift260
-rw-r--r--WireGuard/WireGuard/UI/iOS/ViewController/TunnelEditTableViewController.swift488
-rw-r--r--WireGuard/WireGuard/UI/iOS/ViewController/TunnelsListTableViewController.swift307
6 files changed, 1543 insertions, 0 deletions
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
+ }
+}