aboutsummaryrefslogtreecommitdiffstats
path: root/Sources/WireGuardApp/UI/iOS/ViewController/TunnelsListTableViewController.swift
diff options
context:
space:
mode:
Diffstat (limited to 'Sources/WireGuardApp/UI/iOS/ViewController/TunnelsListTableViewController.swift')
-rw-r--r--Sources/WireGuardApp/UI/iOS/ViewController/TunnelsListTableViewController.swift423
1 files changed, 423 insertions, 0 deletions
diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/TunnelsListTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelsListTableViewController.swift
new file mode 100644
index 0000000..4486151
--- /dev/null
+++ b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelsListTableViewController.swift
@@ -0,0 +1,423 @@
+// SPDX-License-Identifier: MIT
+// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved.
+
+import UIKit
+import MobileCoreServices
+import UserNotifications
+
+class TunnelsListTableViewController: UIViewController {
+
+ var tunnelsManager: TunnelsManager?
+
+ enum TableState: Equatable {
+ case normal
+ case rowSwiped
+ case multiSelect(selectionCount: Int)
+ }
+
+ let tableView: UITableView = {
+ let tableView = UITableView(frame: CGRect.zero, style: .plain)
+ tableView.estimatedRowHeight = 60
+ tableView.rowHeight = UITableView.automaticDimension
+ tableView.separatorStyle = .none
+ tableView.register(TunnelListCell.self)
+ return tableView
+ }()
+
+ let centeredAddButton: BorderedTextButton = {
+ let button = BorderedTextButton()
+ button.title = tr("tunnelsListCenteredAddTunnelButtonTitle")
+ button.isHidden = true
+ return button
+ }()
+
+ let busyIndicator: UIActivityIndicatorView = {
+ let busyIndicator: UIActivityIndicatorView
+ busyIndicator = UIActivityIndicatorView(style: .medium)
+ busyIndicator.hidesWhenStopped = true
+ return busyIndicator
+ }()
+
+ var detailDisplayedTunnel: TunnelContainer?
+ var tableState: TableState = .normal {
+ didSet {
+ handleTableStateChange()
+ }
+ }
+
+ override func loadView() {
+ view = UIView()
+ view.backgroundColor = .systemBackground
+
+ tableView.dataSource = self
+ tableView.delegate = self
+
+ view.addSubview(tableView)
+ tableView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ tableView.topAnchor.constraint(equalTo: view.topAnchor),
+ tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+ ])
+
+ view.addSubview(busyIndicator)
+ busyIndicator.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ busyIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ busyIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
+ ])
+
+ view.addSubview(centeredAddButton)
+ centeredAddButton.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ centeredAddButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ centeredAddButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
+ ])
+
+ centeredAddButton.onTapped = { [weak self] in
+ guard let self = self else { return }
+ self.addButtonTapped(sender: self.centeredAddButton)
+ }
+
+ busyIndicator.startAnimating()
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ tableState = .normal
+ restorationIdentifier = "TunnelsListVC"
+ }
+
+ func handleTableStateChange() {
+ switch tableState {
+ case .normal:
+ navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped(sender:)))
+ navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSettingsButtonTitle"), style: .plain, target: self, action: #selector(settingsButtonTapped(sender:)))
+ case .rowSwiped:
+ navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButtonTapped))
+ navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSelectButtonTitle"), style: .plain, target: self, action: #selector(selectButtonTapped))
+ case .multiSelect(let selectionCount):
+ if selectionCount > 0 {
+ navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonTapped))
+ navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListDeleteButtonTitle"), style: .plain, target: self, action: #selector(deleteButtonTapped(sender:)))
+ } else {
+ navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonTapped))
+ navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSelectAllButtonTitle"), style: .plain, target: self, action: #selector(selectAllButtonTapped))
+ }
+ }
+ if case .multiSelect(let selectionCount) = tableState, selectionCount > 0 {
+ navigationItem.title = tr(format: "tunnelsListSelectedTitle (%d)", selectionCount)
+ } else {
+ navigationItem.title = tr("tunnelsListTitle")
+ }
+ if case .multiSelect = tableState {
+ tableView.allowsMultipleSelectionDuringEditing = true
+ } else {
+ tableView.allowsMultipleSelectionDuringEditing = false
+ }
+ }
+
+ func setTunnelsManager(tunnelsManager: TunnelsManager) {
+ self.tunnelsManager = tunnelsManager
+ tunnelsManager.tunnelsListDelegate = self
+
+ busyIndicator.stopAnimating()
+ tableView.reloadData()
+ centeredAddButton.isHidden = tunnelsManager.numberOfTunnels() > 0
+ }
+
+ override func viewWillAppear(_: Bool) {
+ if let selectedRowIndexPath = tableView.indexPathForSelectedRow {
+ tableView.deselectRow(at: selectedRowIndexPath, animated: false)
+ }
+ }
+
+ @objc func addButtonTapped(sender: AnyObject) {
+ guard tunnelsManager != nil else { return }
+
+ let alert = UIAlertController(title: "", message: tr("addTunnelMenuHeader"), preferredStyle: .actionSheet)
+ let importFileAction = UIAlertAction(title: tr("addTunnelMenuImportFile"), style: .default) { [weak self] _ in
+ self?.presentViewControllerForFileImport()
+ }
+ alert.addAction(importFileAction)
+
+ let scanQRCodeAction = UIAlertAction(title: tr("addTunnelMenuQRCode"), style: .default) { [weak self] _ in
+ self?.presentViewControllerForScanningQRCode()
+ }
+ alert.addAction(scanQRCodeAction)
+
+ let createFromScratchAction = UIAlertAction(title: tr("addTunnelMenuFromScratch"), style: .default) { [weak self] _ in
+ if let self = self, let tunnelsManager = self.tunnelsManager {
+ self.presentViewControllerForTunnelCreation(tunnelsManager: tunnelsManager)
+ }
+ }
+ alert.addAction(createFromScratchAction)
+
+ let cancelAction = UIAlertAction(title: tr("actionCancel"), style: .cancel)
+ alert.addAction(cancelAction)
+
+ if let sender = sender as? UIBarButtonItem {
+ alert.popoverPresentationController?.barButtonItem = sender
+ } else if let sender = sender as? UIView {
+ alert.popoverPresentationController?.sourceView = sender
+ alert.popoverPresentationController?.sourceRect = sender.bounds
+ }
+ present(alert, animated: true, completion: nil)
+ }
+
+ @objc func settingsButtonTapped(sender: UIBarButtonItem) {
+ guard tunnelsManager != nil else { return }
+
+ let settingsVC = SettingsTableViewController(tunnelsManager: tunnelsManager)
+ let settingsNC = UINavigationController(rootViewController: settingsVC)
+ settingsNC.modalPresentationStyle = .formSheet
+ present(settingsNC, animated: true)
+ }
+
+ func presentViewControllerForTunnelCreation(tunnelsManager: TunnelsManager) {
+ let editVC = TunnelEditTableViewController(tunnelsManager: tunnelsManager)
+ let editNC = UINavigationController(rootViewController: editVC)
+ editNC.modalPresentationStyle = .fullScreen
+ present(editNC, animated: true)
+ }
+
+ func presentViewControllerForFileImport() {
+ let documentTypes = ["com.wireguard.config.quick", String(kUTTypeText), String(kUTTypeZipArchive)]
+ let filePicker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import)
+ filePicker.delegate = self
+ present(filePicker, animated: true)
+ }
+
+ func presentViewControllerForScanningQRCode() {
+ let scanQRCodeVC = QRScanViewController()
+ scanQRCodeVC.delegate = self
+ let scanQRCodeNC = UINavigationController(rootViewController: scanQRCodeVC)
+ scanQRCodeNC.modalPresentationStyle = .fullScreen
+ present(scanQRCodeNC, animated: true)
+ }
+
+ @objc func selectButtonTapped() {
+ let shouldCancelSwipe = tableState == .rowSwiped
+ tableState = .multiSelect(selectionCount: 0)
+ if shouldCancelSwipe {
+ tableView.setEditing(false, animated: false)
+ }
+ tableView.setEditing(true, animated: true)
+ }
+
+ @objc func doneButtonTapped() {
+ tableState = .normal
+ tableView.setEditing(false, animated: true)
+ }
+
+ @objc func selectAllButtonTapped() {
+ guard tableView.isEditing else { return }
+ guard let tunnelsManager = tunnelsManager else { return }
+ for index in 0 ..< tunnelsManager.numberOfTunnels() {
+ tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none)
+ }
+ tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0)
+ }
+
+ @objc func cancelButtonTapped() {
+ tableState = .normal
+ tableView.setEditing(false, animated: true)
+ }
+
+ @objc func deleteButtonTapped(sender: AnyObject?) {
+ guard let sender = sender as? UIBarButtonItem else { return }
+ guard let tunnelsManager = tunnelsManager else { return }
+
+ let selectedTunnelIndices = tableView.indexPathsForSelectedRows?.map { $0.row } ?? []
+ let selectedTunnels = selectedTunnelIndices.compactMap { tunnelIndex in
+ tunnelIndex >= 0 && tunnelIndex < tunnelsManager.numberOfTunnels() ? tunnelsManager.tunnel(at: tunnelIndex) : nil
+ }
+ guard !selectedTunnels.isEmpty else { return }
+ let message = selectedTunnels.count == 1 ?
+ tr(format: "deleteTunnelConfirmationAlertButtonMessage (%d)", selectedTunnels.count) :
+ tr(format: "deleteTunnelsConfirmationAlertButtonMessage (%d)", selectedTunnels.count)
+ let title = tr("deleteTunnelsConfirmationAlertButtonTitle")
+ ConfirmationAlertPresenter.showConfirmationAlert(message: message, buttonTitle: title,
+ from: sender, presentingVC: self) { [weak self] in
+ self?.tunnelsManager?.removeMultiple(tunnels: selectedTunnels) { [weak self] error in
+ guard let self = self else { return }
+ if let error = error {
+ ErrorPresenter.showErrorAlert(error: error, from: self)
+ return
+ }
+ self.tableState = .normal
+ self.tableView.setEditing(false, animated: true)
+ }
+ }
+ }
+
+ func showTunnelDetail(for tunnel: TunnelContainer, animated: Bool) {
+ guard let tunnelsManager = tunnelsManager else { return }
+ guard let splitViewController = splitViewController else { return }
+ guard let navController = navigationController else { return }
+
+ let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager,
+ tunnel: tunnel)
+ let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC)
+ tunnelDetailNC.restorationIdentifier = "DetailNC"
+ if splitViewController.isCollapsed && navController.viewControllers.count > 1 {
+ navController.setViewControllers([self, tunnelDetailNC], animated: animated)
+ } else {
+ splitViewController.showDetailViewController(tunnelDetailNC, sender: self, animated: animated)
+ }
+ detailDisplayedTunnel = tunnel
+ self.presentedViewController?.dismiss(animated: false, completion: nil)
+ }
+}
+
+extension TunnelsListTableViewController: UIDocumentPickerDelegate {
+ func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
+ guard let tunnelsManager = tunnelsManager else { return }
+ TunnelImporter.importFromFile(urls: urls, into: tunnelsManager, sourceVC: self, errorPresenterType: ErrorPresenter.self)
+ }
+}
+
+extension TunnelsListTableViewController: QRScanViewControllerDelegate {
+ func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController,
+ completionHandler: (() -> Void)?) {
+ tunnelsManager?.add(tunnelConfiguration: tunnelConfiguration) { result in
+ switch result {
+ case .failure(let error):
+ ErrorPresenter.showErrorAlert(error: error, from: qrScanViewController, onDismissal: completionHandler)
+ case .success:
+ completionHandler?()
+ }
+ }
+ }
+}
+
+extension TunnelsListTableViewController: UITableViewDataSource {
+ func numberOfSections(in tableView: UITableView) -> Int {
+ return 1
+ }
+
+ func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
+ return (tunnelsManager?.numberOfTunnels() ?? 0)
+ }
+
+ func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
+ let cell: TunnelListCell = tableView.dequeueReusableCell(for: indexPath)
+ if let tunnelsManager = tunnelsManager {
+ let tunnel = tunnelsManager.tunnel(at: indexPath.row)
+ cell.tunnel = tunnel
+ cell.onSwitchToggled = { [weak self] isOn in
+ guard let self = self, let tunnelsManager = self.tunnelsManager else { return }
+ if tunnel.hasOnDemandRules {
+ tunnelsManager.setOnDemandEnabled(isOn, on: tunnel) { error in
+ if error == nil && !isOn {
+ tunnelsManager.startDeactivation(of: tunnel)
+ }
+ }
+ } else {
+ if isOn {
+ tunnelsManager.startActivation(of: tunnel)
+ } else {
+ tunnelsManager.startDeactivation(of: tunnel)
+ }
+ }
+ }
+ }
+ return cell
+ }
+}
+
+extension TunnelsListTableViewController: UITableViewDelegate {
+ func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
+ guard !tableView.isEditing else {
+ tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0)
+ return
+ }
+ guard let tunnelsManager = tunnelsManager else { return }
+ let tunnel = tunnelsManager.tunnel(at: indexPath.row)
+ showTunnelDetail(for: tunnel, animated: true)
+ }
+
+ func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
+ guard !tableView.isEditing else {
+ tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0)
+ return
+ }
+ }
+
+ func tableView(_ tableView: UITableView,
+ trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
+ let deleteAction = UIContextualAction(style: .destructive, title: tr("tunnelsListSwipeDeleteButtonTitle")) { [weak self] _, _, completionHandler in
+ guard let tunnelsManager = self?.tunnelsManager else { return }
+ let tunnel = tunnelsManager.tunnel(at: indexPath.row)
+ tunnelsManager.remove(tunnel: tunnel) { error in
+ if error != nil {
+ ErrorPresenter.showErrorAlert(error: error!, from: self)
+ completionHandler(false)
+ } else {
+ completionHandler(true)
+ }
+ }
+ }
+ return UISwipeActionsConfiguration(actions: [deleteAction])
+ }
+
+ func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
+ if tableState == .normal {
+ tableState = .rowSwiped
+ }
+ }
+
+ func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
+ if tableState == .rowSwiped {
+ tableState = .normal
+ }
+ }
+}
+
+extension TunnelsListTableViewController: TunnelsManagerListDelegate {
+ func tunnelAdded(at index: Int) {
+ tableView.insertRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
+ centeredAddButton.isHidden = (tunnelsManager?.numberOfTunnels() ?? 0 > 0)
+ }
+
+ func tunnelModified(at index: Int) {
+ tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
+ }
+
+ func tunnelMoved(from oldIndex: Int, to newIndex: Int) {
+ tableView.moveRow(at: IndexPath(row: oldIndex, section: 0), to: IndexPath(row: newIndex, section: 0))
+ }
+
+ func tunnelRemoved(at index: Int, tunnel: TunnelContainer) {
+ tableView.deleteRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
+ centeredAddButton.isHidden = tunnelsManager?.numberOfTunnels() ?? 0 > 0
+ if detailDisplayedTunnel == tunnel, let splitViewController = splitViewController {
+ if splitViewController.isCollapsed != false {
+ (splitViewController.viewControllers[0] as? UINavigationController)?.popToRootViewController(animated: false)
+ } else {
+ let detailVC = UIViewController()
+ detailVC.view.backgroundColor = .systemBackground
+ let detailNC = UINavigationController(rootViewController: detailVC)
+ splitViewController.showDetailViewController(detailNC, sender: self)
+ }
+ detailDisplayedTunnel = nil
+ if let presentedNavController = self.presentedViewController as? UINavigationController, presentedNavController.viewControllers.first is TunnelEditTableViewController {
+ self.presentedViewController?.dismiss(animated: false, completion: nil)
+ }
+ }
+ }
+}
+
+extension UISplitViewController {
+ func showDetailViewController(_ viewController: UIViewController, sender: Any?, animated: Bool) {
+ if animated {
+ showDetailViewController(viewController, sender: sender)
+ } else {
+ UIView.performWithoutAnimation {
+ showDetailViewController(viewController, sender: sender)
+ }
+ }
+ }
+}