1 // SPDX-License-Identifier: MIT
2 // Copyright © 2018-2020 WireGuard LLC. All Rights Reserved.
5 import MobileCoreServices
6 import UserNotifications
8 class TunnelsListTableViewController: UIViewController {
10 var tunnelsManager: TunnelsManager?
12 enum TableState: Equatable {
15 case multiSelect(selectionCount: Int)
18 let tableView: UITableView = {
19 let tableView = UITableView(frame: CGRect.zero, style: .plain)
20 tableView.estimatedRowHeight = 60
21 tableView.rowHeight = UITableView.automaticDimension
22 tableView.separatorStyle = .none
23 tableView.register(TunnelListCell.self)
27 let centeredAddButton: BorderedTextButton = {
28 let button = BorderedTextButton()
29 button.title = tr("tunnelsListCenteredAddTunnelButtonTitle")
30 button.isHidden = true
34 let busyIndicator: UIActivityIndicatorView = {
35 if #available(iOS 13.0, *) {
36 let busyIndicator = UIActivityIndicatorView(style: .medium)
37 busyIndicator.hidesWhenStopped = true
40 let busyIndicator = UIActivityIndicatorView(style: .gray)
41 busyIndicator.hidesWhenStopped = true
46 var detailDisplayedTunnel: TunnelContainer?
47 var tableState: TableState = .normal {
49 handleTableStateChange()
53 override func loadView() {
55 if #available(iOS 13.0, *) {
56 view.backgroundColor = .systemBackground
58 view.backgroundColor = .white
61 tableView.dataSource = self
62 tableView.delegate = self
64 view.addSubview(tableView)
65 tableView.translatesAutoresizingMaskIntoConstraints = false
66 NSLayoutConstraint.activate([
67 tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
68 tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
69 tableView.topAnchor.constraint(equalTo: view.topAnchor),
70 tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
73 view.addSubview(busyIndicator)
74 busyIndicator.translatesAutoresizingMaskIntoConstraints = false
75 NSLayoutConstraint.activate([
76 busyIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
77 busyIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor)
80 view.addSubview(centeredAddButton)
81 centeredAddButton.translatesAutoresizingMaskIntoConstraints = false
82 NSLayoutConstraint.activate([
83 centeredAddButton.centerXAnchor.constraint(equalTo: view.centerXAnchor),
84 centeredAddButton.centerYAnchor.constraint(equalTo: view.centerYAnchor)
87 centeredAddButton.onTapped = { [weak self] in
88 guard let self = self else { return }
89 self.addButtonTapped(sender: self.centeredAddButton)
92 busyIndicator.startAnimating()
95 override func viewDidLoad() {
99 restorationIdentifier = "TunnelsListVC"
102 func handleTableStateChange() {
105 navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped(sender:)))
106 navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSettingsButtonTitle"), style: .plain, target: self, action: #selector(settingsButtonTapped(sender:)))
108 navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneButtonTapped))
109 navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSelectButtonTitle"), style: .plain, target: self, action: #selector(selectButtonTapped))
110 case .multiSelect(let selectionCount):
111 if selectionCount > 0 {
112 navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonTapped))
113 navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListDeleteButtonTitle"), style: .plain, target: self, action: #selector(deleteButtonTapped(sender:)))
115 navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonTapped))
116 navigationItem.leftBarButtonItem = UIBarButtonItem(title: tr("tunnelsListSelectAllButtonTitle"), style: .plain, target: self, action: #selector(selectAllButtonTapped))
119 if case .multiSelect(let selectionCount) = tableState, selectionCount > 0 {
120 navigationItem.title = tr(format: "tunnelsListSelectedTitle (%d)", selectionCount)
122 navigationItem.title = tr("tunnelsListTitle")
124 if case .multiSelect = tableState {
125 tableView.allowsMultipleSelectionDuringEditing = true
127 tableView.allowsMultipleSelectionDuringEditing = false
131 func setTunnelsManager(tunnelsManager: TunnelsManager) {
132 self.tunnelsManager = tunnelsManager
133 tunnelsManager.tunnelsListDelegate = self
135 busyIndicator.stopAnimating()
136 tableView.reloadData()
137 centeredAddButton.isHidden = tunnelsManager.numberOfTunnels() > 0
140 override func viewWillAppear(_: Bool) {
141 if let selectedRowIndexPath = tableView.indexPathForSelectedRow {
142 tableView.deselectRow(at: selectedRowIndexPath, animated: false)
146 @objc func addButtonTapped(sender: AnyObject) {
147 guard tunnelsManager != nil else { return }
149 let alert = UIAlertController(title: "", message: tr("addTunnelMenuHeader"), preferredStyle: .actionSheet)
150 let importFileAction = UIAlertAction(title: tr("addTunnelMenuImportFile"), style: .default) { [weak self] _ in
151 self?.presentViewControllerForFileImport()
153 alert.addAction(importFileAction)
155 let scanQRCodeAction = UIAlertAction(title: tr("addTunnelMenuQRCode"), style: .default) { [weak self] _ in
156 self?.presentViewControllerForScanningQRCode()
158 alert.addAction(scanQRCodeAction)
160 let createFromScratchAction = UIAlertAction(title: tr("addTunnelMenuFromScratch"), style: .default) { [weak self] _ in
161 if let self = self, let tunnelsManager = self.tunnelsManager {
162 self.presentViewControllerForTunnelCreation(tunnelsManager: tunnelsManager)
165 alert.addAction(createFromScratchAction)
167 let cancelAction = UIAlertAction(title: tr("actionCancel"), style: .cancel)
168 alert.addAction(cancelAction)
170 if let sender = sender as? UIBarButtonItem {
171 alert.popoverPresentationController?.barButtonItem = sender
172 } else if let sender = sender as? UIView {
173 alert.popoverPresentationController?.sourceView = sender
174 alert.popoverPresentationController?.sourceRect = sender.bounds
176 present(alert, animated: true, completion: nil)
179 @objc func settingsButtonTapped(sender: UIBarButtonItem) {
180 guard tunnelsManager != nil else { return }
182 let settingsVC = SettingsTableViewController(tunnelsManager: tunnelsManager)
183 let settingsNC = UINavigationController(rootViewController: settingsVC)
184 settingsNC.modalPresentationStyle = .formSheet
185 present(settingsNC, animated: true)
188 func presentViewControllerForTunnelCreation(tunnelsManager: TunnelsManager) {
189 let editVC = TunnelEditTableViewController(tunnelsManager: tunnelsManager)
190 let editNC = UINavigationController(rootViewController: editVC)
191 editNC.modalPresentationStyle = .fullScreen
192 present(editNC, animated: true)
195 func presentViewControllerForFileImport() {
196 let documentTypes = ["com.wireguard.config.quick", String(kUTTypeText), String(kUTTypeZipArchive)]
197 let filePicker = UIDocumentPickerViewController(documentTypes: documentTypes, in: .import)
198 filePicker.delegate = self
199 present(filePicker, animated: true)
202 func presentViewControllerForScanningQRCode() {
203 let scanQRCodeVC = QRScanViewController()
204 scanQRCodeVC.delegate = self
205 let scanQRCodeNC = UINavigationController(rootViewController: scanQRCodeVC)
206 scanQRCodeNC.modalPresentationStyle = .fullScreen
207 present(scanQRCodeNC, animated: true)
210 @objc func selectButtonTapped() {
211 let shouldCancelSwipe = tableState == .rowSwiped
212 tableState = .multiSelect(selectionCount: 0)
213 if shouldCancelSwipe {
214 tableView.setEditing(false, animated: false)
216 tableView.setEditing(true, animated: true)
219 @objc func doneButtonTapped() {
221 tableView.setEditing(false, animated: true)
224 @objc func selectAllButtonTapped() {
225 guard tableView.isEditing else { return }
226 guard let tunnelsManager = tunnelsManager else { return }
227 for index in 0 ..< tunnelsManager.numberOfTunnels() {
228 tableView.selectRow(at: IndexPath(row: index, section: 0), animated: false, scrollPosition: .none)
230 tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0)
233 @objc func cancelButtonTapped() {
235 tableView.setEditing(false, animated: true)
238 @objc func deleteButtonTapped(sender: AnyObject?) {
239 guard let sender = sender as? UIBarButtonItem else { return }
240 guard let tunnelsManager = tunnelsManager else { return }
242 let selectedTunnelIndices = tableView.indexPathsForSelectedRows?.map { $0.row } ?? []
243 let selectedTunnels = selectedTunnelIndices.compactMap { tunnelIndex in
244 tunnelIndex >= 0 && tunnelIndex < tunnelsManager.numberOfTunnels() ? tunnelsManager.tunnel(at: tunnelIndex) : nil
246 guard !selectedTunnels.isEmpty else { return }
247 let message = selectedTunnels.count == 1 ?
248 tr(format: "deleteTunnelConfirmationAlertButtonMessage (%d)", selectedTunnels.count) :
249 tr(format: "deleteTunnelsConfirmationAlertButtonMessage (%d)", selectedTunnels.count)
250 let title = tr("deleteTunnelsConfirmationAlertButtonTitle")
251 ConfirmationAlertPresenter.showConfirmationAlert(message: message, buttonTitle: title,
252 from: sender, presentingVC: self) { [weak self] in
253 self?.tunnelsManager?.removeMultiple(tunnels: selectedTunnels) { [weak self] error in
254 guard let self = self else { return }
255 if let error = error {
256 ErrorPresenter.showErrorAlert(error: error, from: self)
259 self.tableState = .normal
260 self.tableView.setEditing(false, animated: true)
265 func showTunnelDetail(for tunnel: TunnelContainer, animated: Bool) {
266 guard let tunnelsManager = tunnelsManager else { return }
267 guard let splitViewController = splitViewController else { return }
268 guard let navController = navigationController else { return }
270 let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager,
272 let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC)
273 tunnelDetailNC.restorationIdentifier = "DetailNC"
274 if splitViewController.isCollapsed && navController.viewControllers.count > 1 {
275 navController.setViewControllers([self, tunnelDetailNC], animated: animated)
277 splitViewController.showDetailViewController(tunnelDetailNC, sender: self, animated: animated)
279 detailDisplayedTunnel = tunnel
280 self.presentedViewController?.dismiss(animated: false, completion: nil)
284 extension TunnelsListTableViewController: UIDocumentPickerDelegate {
285 func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
286 guard let tunnelsManager = tunnelsManager else { return }
287 TunnelImporter.importFromFile(urls: urls, into: tunnelsManager, sourceVC: self, errorPresenterType: ErrorPresenter.self)
291 extension TunnelsListTableViewController: QRScanViewControllerDelegate {
292 func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController,
293 completionHandler: (() -> Void)?) {
294 tunnelsManager?.add(tunnelConfiguration: tunnelConfiguration) { result in
296 case .failure(let error):
297 ErrorPresenter.showErrorAlert(error: error, from: qrScanViewController, onDismissal: completionHandler)
305 extension TunnelsListTableViewController: UITableViewDataSource {
306 func numberOfSections(in tableView: UITableView) -> Int {
310 func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
311 return (tunnelsManager?.numberOfTunnels() ?? 0)
314 func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
315 let cell: TunnelListCell = tableView.dequeueReusableCell(for: indexPath)
316 if let tunnelsManager = tunnelsManager {
317 let tunnel = tunnelsManager.tunnel(at: indexPath.row)
319 cell.onSwitchToggled = { [weak self] isOn in
320 guard let self = self, let tunnelsManager = self.tunnelsManager else { return }
322 tunnelsManager.startActivation(of: tunnel)
324 tunnelsManager.startDeactivation(of: tunnel)
332 extension TunnelsListTableViewController: UITableViewDelegate {
333 func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
334 guard !tableView.isEditing else {
335 tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0)
338 guard let tunnelsManager = tunnelsManager else { return }
339 let tunnel = tunnelsManager.tunnel(at: indexPath.row)
340 showTunnelDetail(for: tunnel, animated: true)
343 func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
344 guard !tableView.isEditing else {
345 tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0)
350 func tableView(_ tableView: UITableView,
351 trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
352 let deleteAction = UIContextualAction(style: .destructive, title: tr("tunnelsListSwipeDeleteButtonTitle")) { [weak self] _, _, completionHandler in
353 guard let tunnelsManager = self?.tunnelsManager else { return }
354 let tunnel = tunnelsManager.tunnel(at: indexPath.row)
355 tunnelsManager.remove(tunnel: tunnel) { error in
357 ErrorPresenter.showErrorAlert(error: error!, from: self)
358 completionHandler(false)
360 completionHandler(true)
364 return UISwipeActionsConfiguration(actions: [deleteAction])
367 func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
368 if tableState == .normal {
369 tableState = .rowSwiped
373 func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
374 if tableState == .rowSwiped {
380 extension TunnelsListTableViewController: TunnelsManagerListDelegate {
381 func tunnelAdded(at index: Int) {
382 tableView.insertRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
383 centeredAddButton.isHidden = (tunnelsManager?.numberOfTunnels() ?? 0 > 0)
386 func tunnelModified(at index: Int) {
387 tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
390 func tunnelMoved(from oldIndex: Int, to newIndex: Int) {
391 tableView.moveRow(at: IndexPath(row: oldIndex, section: 0), to: IndexPath(row: newIndex, section: 0))
394 func tunnelRemoved(at index: Int, tunnel: TunnelContainer) {
395 tableView.deleteRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
396 centeredAddButton.isHidden = tunnelsManager?.numberOfTunnels() ?? 0 > 0
397 if detailDisplayedTunnel == tunnel, let splitViewController = splitViewController {
398 if splitViewController.isCollapsed != false {
399 (splitViewController.viewControllers[0] as? UINavigationController)?.popToRootViewController(animated: false)
401 let detailVC = UIViewController()
402 if #available(iOS 13.0, *) {
403 detailVC.view.backgroundColor = .systemBackground
405 detailVC.view.backgroundColor = .white
407 let detailNC = UINavigationController(rootViewController: detailVC)
408 splitViewController.showDetailViewController(detailNC, sender: self)
410 detailDisplayedTunnel = nil
411 if let presentedNavController = self.presentedViewController as? UINavigationController, presentedNavController.viewControllers.first is TunnelEditTableViewController {
412 self.presentedViewController?.dismiss(animated: false, completion: nil)
418 extension UISplitViewController {
419 func showDetailViewController(_ viewController: UIViewController, sender: Any?, animated: Bool) {
421 showDetailViewController(viewController, sender: sender)
423 UIView.performWithoutAnimation {
424 showDetailViewController(viewController, sender: sender)