]> git.ipfire.org Git - thirdparty/wireguard-apple.git/blob - Sources/WireGuardApp/UI/iOS/ViewController/TunnelsListTableViewController.swift
7f405cf8f92d0b8065541b2c92775ec7a4ed901d
[thirdparty/wireguard-apple.git] / Sources / WireGuardApp / UI / iOS / ViewController / TunnelsListTableViewController.swift
1 // SPDX-License-Identifier: MIT
2 // Copyright © 2018-2020 WireGuard LLC. All Rights Reserved.
3
4 import UIKit
5 import MobileCoreServices
6 import UserNotifications
7
8 class TunnelsListTableViewController: UIViewController {
9
10 var tunnelsManager: TunnelsManager?
11
12 enum TableState: Equatable {
13 case normal
14 case rowSwiped
15 case multiSelect(selectionCount: Int)
16 }
17
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)
24 return tableView
25 }()
26
27 let centeredAddButton: BorderedTextButton = {
28 let button = BorderedTextButton()
29 button.title = tr("tunnelsListCenteredAddTunnelButtonTitle")
30 button.isHidden = true
31 return button
32 }()
33
34 let busyIndicator: UIActivityIndicatorView = {
35 if #available(iOS 13.0, *) {
36 let busyIndicator = UIActivityIndicatorView(style: .medium)
37 busyIndicator.hidesWhenStopped = true
38 return busyIndicator
39 } else {
40 let busyIndicator = UIActivityIndicatorView(style: .gray)
41 busyIndicator.hidesWhenStopped = true
42 return busyIndicator
43 }
44 }()
45
46 var detailDisplayedTunnel: TunnelContainer?
47 var tableState: TableState = .normal {
48 didSet {
49 handleTableStateChange()
50 }
51 }
52
53 override func loadView() {
54 view = UIView()
55 if #available(iOS 13.0, *) {
56 view.backgroundColor = .systemBackground
57 } else {
58 view.backgroundColor = .white
59 }
60
61 tableView.dataSource = self
62 tableView.delegate = self
63
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)
71 ])
72
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)
78 ])
79
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)
85 ])
86
87 centeredAddButton.onTapped = { [weak self] in
88 guard let self = self else { return }
89 self.addButtonTapped(sender: self.centeredAddButton)
90 }
91
92 busyIndicator.startAnimating()
93 }
94
95 override func viewDidLoad() {
96 super.viewDidLoad()
97
98 tableState = .normal
99 restorationIdentifier = "TunnelsListVC"
100 }
101
102 func handleTableStateChange() {
103 switch tableState {
104 case .normal:
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:)))
107 case .rowSwiped:
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:)))
114 } else {
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))
117 }
118 }
119 if case .multiSelect(let selectionCount) = tableState, selectionCount > 0 {
120 navigationItem.title = tr(format: "tunnelsListSelectedTitle (%d)", selectionCount)
121 } else {
122 navigationItem.title = tr("tunnelsListTitle")
123 }
124 if case .multiSelect = tableState {
125 tableView.allowsMultipleSelectionDuringEditing = true
126 } else {
127 tableView.allowsMultipleSelectionDuringEditing = false
128 }
129 }
130
131 func setTunnelsManager(tunnelsManager: TunnelsManager) {
132 self.tunnelsManager = tunnelsManager
133 tunnelsManager.tunnelsListDelegate = self
134
135 busyIndicator.stopAnimating()
136 tableView.reloadData()
137 centeredAddButton.isHidden = tunnelsManager.numberOfTunnels() > 0
138 }
139
140 override func viewWillAppear(_: Bool) {
141 if let selectedRowIndexPath = tableView.indexPathForSelectedRow {
142 tableView.deselectRow(at: selectedRowIndexPath, animated: false)
143 }
144 }
145
146 @objc func addButtonTapped(sender: AnyObject) {
147 guard tunnelsManager != nil else { return }
148
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()
152 }
153 alert.addAction(importFileAction)
154
155 let scanQRCodeAction = UIAlertAction(title: tr("addTunnelMenuQRCode"), style: .default) { [weak self] _ in
156 self?.presentViewControllerForScanningQRCode()
157 }
158 alert.addAction(scanQRCodeAction)
159
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)
163 }
164 }
165 alert.addAction(createFromScratchAction)
166
167 let cancelAction = UIAlertAction(title: tr("actionCancel"), style: .cancel)
168 alert.addAction(cancelAction)
169
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
175 }
176 present(alert, animated: true, completion: nil)
177 }
178
179 @objc func settingsButtonTapped(sender: UIBarButtonItem) {
180 guard tunnelsManager != nil else { return }
181
182 let settingsVC = SettingsTableViewController(tunnelsManager: tunnelsManager)
183 let settingsNC = UINavigationController(rootViewController: settingsVC)
184 settingsNC.modalPresentationStyle = .formSheet
185 present(settingsNC, animated: true)
186 }
187
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)
193 }
194
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)
200 }
201
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)
208 }
209
210 @objc func selectButtonTapped() {
211 let shouldCancelSwipe = tableState == .rowSwiped
212 tableState = .multiSelect(selectionCount: 0)
213 if shouldCancelSwipe {
214 tableView.setEditing(false, animated: false)
215 }
216 tableView.setEditing(true, animated: true)
217 }
218
219 @objc func doneButtonTapped() {
220 tableState = .normal
221 tableView.setEditing(false, animated: true)
222 }
223
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)
229 }
230 tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0)
231 }
232
233 @objc func cancelButtonTapped() {
234 tableState = .normal
235 tableView.setEditing(false, animated: true)
236 }
237
238 @objc func deleteButtonTapped(sender: AnyObject?) {
239 guard let sender = sender as? UIBarButtonItem else { return }
240 guard let tunnelsManager = tunnelsManager else { return }
241
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
245 }
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)
257 return
258 }
259 self.tableState = .normal
260 self.tableView.setEditing(false, animated: true)
261 }
262 }
263 }
264
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 }
269
270 let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager,
271 tunnel: tunnel)
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)
276 } else {
277 splitViewController.showDetailViewController(tunnelDetailNC, sender: self, animated: animated)
278 }
279 detailDisplayedTunnel = tunnel
280 self.presentedViewController?.dismiss(animated: false, completion: nil)
281 }
282 }
283
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)
288 }
289 }
290
291 extension TunnelsListTableViewController: QRScanViewControllerDelegate {
292 func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController,
293 completionHandler: (() -> Void)?) {
294 tunnelsManager?.add(tunnelConfiguration: tunnelConfiguration) { result in
295 switch result {
296 case .failure(let error):
297 ErrorPresenter.showErrorAlert(error: error, from: qrScanViewController, onDismissal: completionHandler)
298 case .success:
299 completionHandler?()
300 }
301 }
302 }
303 }
304
305 extension TunnelsListTableViewController: UITableViewDataSource {
306 func numberOfSections(in tableView: UITableView) -> Int {
307 return 1
308 }
309
310 func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
311 return (tunnelsManager?.numberOfTunnels() ?? 0)
312 }
313
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)
318 cell.tunnel = tunnel
319 cell.onSwitchToggled = { [weak self] isOn in
320 guard let self = self, let tunnelsManager = self.tunnelsManager else { return }
321 if isOn {
322 tunnelsManager.startActivation(of: tunnel)
323 } else {
324 tunnelsManager.startDeactivation(of: tunnel)
325 }
326 }
327 }
328 return cell
329 }
330 }
331
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)
336 return
337 }
338 guard let tunnelsManager = tunnelsManager else { return }
339 let tunnel = tunnelsManager.tunnel(at: indexPath.row)
340 showTunnelDetail(for: tunnel, animated: true)
341 }
342
343 func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
344 guard !tableView.isEditing else {
345 tableState = .multiSelect(selectionCount: tableView.indexPathsForSelectedRows?.count ?? 0)
346 return
347 }
348 }
349
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
356 if error != nil {
357 ErrorPresenter.showErrorAlert(error: error!, from: self)
358 completionHandler(false)
359 } else {
360 completionHandler(true)
361 }
362 }
363 }
364 return UISwipeActionsConfiguration(actions: [deleteAction])
365 }
366
367 func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
368 if tableState == .normal {
369 tableState = .rowSwiped
370 }
371 }
372
373 func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
374 if tableState == .rowSwiped {
375 tableState = .normal
376 }
377 }
378 }
379
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)
384 }
385
386 func tunnelModified(at index: Int) {
387 tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .automatic)
388 }
389
390 func tunnelMoved(from oldIndex: Int, to newIndex: Int) {
391 tableView.moveRow(at: IndexPath(row: oldIndex, section: 0), to: IndexPath(row: newIndex, section: 0))
392 }
393
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)
400 } else {
401 let detailVC = UIViewController()
402 if #available(iOS 13.0, *) {
403 detailVC.view.backgroundColor = .systemBackground
404 } else {
405 detailVC.view.backgroundColor = .white
406 }
407 let detailNC = UINavigationController(rootViewController: detailVC)
408 splitViewController.showDetailViewController(detailNC, sender: self)
409 }
410 detailDisplayedTunnel = nil
411 if let presentedNavController = self.presentedViewController as? UINavigationController, presentedNavController.viewControllers.first is TunnelEditTableViewController {
412 self.presentedViewController?.dismiss(animated: false, completion: nil)
413 }
414 }
415 }
416 }
417
418 extension UISplitViewController {
419 func showDetailViewController(_ viewController: UIViewController, sender: Any?, animated: Bool) {
420 if animated {
421 showDetailViewController(viewController, sender: sender)
422 } else {
423 UIView.performWithoutAnimation {
424 showDetailViewController(viewController, sender: sender)
425 }
426 }
427 }
428 }