From 05d750539b91eff582ff6a789fcdcab73bb5f7bb Mon Sep 17 00:00:00 2001 From: Eric Kuck Date: Thu, 13 Dec 2018 12:58:50 -0600 Subject: Reorganized ViewControllers (split out UIViews and UITableViewCells into their own classes) All swiftlint warnings except one fixed up Signed-off-by: Eric Kuck --- .../UI/iOS/TunnelList/BorderedTextButton.swift | 50 ++++ .../UI/iOS/TunnelList/TunnelListCell.swift | 111 ++++++++ .../TunnelsListTableViewController.swift | 305 +++++++++++++++++++++ 3 files changed, 466 insertions(+) create mode 100644 WireGuard/WireGuard/UI/iOS/TunnelList/BorderedTextButton.swift create mode 100644 WireGuard/WireGuard/UI/iOS/TunnelList/TunnelListCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/TunnelList/TunnelsListTableViewController.swift (limited to 'WireGuard/WireGuard/UI/iOS/TunnelList') diff --git a/WireGuard/WireGuard/UI/iOS/TunnelList/BorderedTextButton.swift b/WireGuard/WireGuard/UI/iOS/TunnelList/BorderedTextButton.swift new file mode 100644 index 0000000..5114c09 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/TunnelList/BorderedTextButton.swift @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class BorderedTextButton: UIView { + let button: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) + button.titleLabel?.adjustsFontForContentSizeCategory = true + return button + }() + + override var intrinsicContentSize: CGSize { + let buttonSize = button.intrinsicContentSize + return CGSize(width: buttonSize.width + 32, height: buttonSize.height + 16) + } + + var title: String { + get { return button.title(for: .normal) ?? "" } + set(value) { button.setTitle(value, for: .normal) } + } + + var onTapped: (() -> Void)? + + init() { + super.init(frame: CGRect.zero) + + layer.borderWidth = 1 + layer.cornerRadius = 5 + layer.borderColor = button.tintColor.cgColor + + addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.centerXAnchor.constraint(equalTo: self.centerXAnchor), + button.centerYAnchor.constraint(equalTo: self.centerYAnchor) + ]) + + button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + } + + @objc func buttonTapped() { + onTapped?() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/WireGuard/WireGuard/UI/iOS/TunnelList/TunnelListCell.swift b/WireGuard/WireGuard/UI/iOS/TunnelList/TunnelListCell.swift new file mode 100644 index 0000000..95055c5 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/TunnelList/TunnelListCell.swift @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelListCell: UITableViewCell { + var tunnel: TunnelContainer? { + didSet(value) { + // Bind to the tunnel's name + nameLabel.text = tunnel?.name ?? "" + nameObservervationToken = tunnel?.observe(\.name) { [weak self] tunnel, _ in + self?.nameLabel.text = tunnel.name + } + // Bind to the tunnel's status + update(from: tunnel?.status) + statusObservervationToken = tunnel?.observe(\.status) { [weak self] tunnel, _ in + self?.update(from: tunnel.status) + } + } + } + var onSwitchToggled: ((Bool) -> Void)? + + let nameLabel: UILabel = { + let nameLabel = UILabel() + nameLabel.font = UIFont.preferredFont(forTextStyle: .body) + nameLabel.adjustsFontForContentSizeCategory = true + nameLabel.numberOfLines = 0 + return nameLabel + }() + + let busyIndicator: UIActivityIndicatorView = { + let busyIndicator = UIActivityIndicatorView(style: .gray) + busyIndicator.hidesWhenStopped = true + return busyIndicator + }() + + let statusSwitch = UISwitch() + + private var statusObservervationToken: AnyObject? + private var nameObservervationToken: AnyObject? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(statusSwitch) + statusSwitch.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + statusSwitch.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + contentView.rightAnchor.constraint(equalTo: statusSwitch.rightAnchor) + ]) + + contentView.addSubview(busyIndicator) + busyIndicator.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + busyIndicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + statusSwitch.leftAnchor.constraint(equalToSystemSpacingAfter: busyIndicator.rightAnchor, multiplier: 1) + ]) + + contentView.addSubview(nameLabel) + nameLabel.translatesAutoresizingMaskIntoConstraints = false + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let bottomAnchorConstraint = contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: nameLabel.bottomAnchor, multiplier: 1) + bottomAnchorConstraint.priority = .defaultLow + NSLayoutConstraint.activate([ + nameLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 1), + nameLabel.leftAnchor.constraint(equalToSystemSpacingAfter: contentView.layoutMarginsGuide.leftAnchor, multiplier: 1), + busyIndicator.leftAnchor.constraint(equalToSystemSpacingAfter: nameLabel.rightAnchor, multiplier: 1), + bottomAnchorConstraint + ]) + + accessoryType = .disclosureIndicator + + statusSwitch.addTarget(self, action: #selector(switchToggled), for: .valueChanged) + } + + @objc func switchToggled() { + onSwitchToggled?(statusSwitch.isOn) + } + + private func update(from status: TunnelStatus?) { + guard let status = status else { + reset() + return + } + DispatchQueue.main.async { [weak statusSwitch, weak busyIndicator] in + guard let statusSwitch = statusSwitch, let busyIndicator = busyIndicator else { return } + statusSwitch.isOn = !(status == .deactivating || status == .inactive) + statusSwitch.isUserInteractionEnabled = (status == .inactive || status == .active) + if status == .inactive || status == .active { + busyIndicator.stopAnimating() + } else { + busyIndicator.startAnimating() + } + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func reset() { + statusSwitch.isOn = false + statusSwitch.isUserInteractionEnabled = false + busyIndicator.stopAnimating() + } + + override func prepareForReuse() { + super.prepareForReuse() + reset() + } +} diff --git a/WireGuard/WireGuard/UI/iOS/TunnelList/TunnelsListTableViewController.swift b/WireGuard/WireGuard/UI/iOS/TunnelList/TunnelsListTableViewController.swift new file mode 100644 index 0000000..eda09af --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/TunnelList/TunnelsListTableViewController.swift @@ -0,0 +1,305 @@ +// 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) { + if self.tunnelsManager == nil { return } // Do nothing until we've loaded the tunnels + 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 + } + self.present(alert, animated: true, completion: nil) + } + + @objc func settingsButtonTapped(sender: UIBarButtonItem!) { + if self.tunnelsManager == nil { return } // Do nothing until we've loaded the tunnels + let settingsVC = SettingsTableViewController(tunnelsManager: tunnelsManager) + let settingsNC = UINavigationController(rootViewController: settingsVC) + settingsNC.modalPresentationStyle = .formSheet + self.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 + self.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 + self.present(filePicker, animated: true) + } + + func presentViewControllerForScanningQRCode() { + let scanQRCodeVC = QRScanViewController() + scanQRCodeVC.delegate = self + let scanQRCodeNC = UINavigationController(rootViewController: scanQRCodeVC) + scanQRCodeNC.modalPresentationStyle = .fullScreen + self.present(scanQRCodeNC, animated: true) + } + + func importFromFile(url: URL, completionHandler: (() -> Void)?) { + guard let tunnelsManager = tunnelsManager else { return } + if url.pathExtension == "zip" { + ZipImporter.importConfigFiles(from: url) { [weak self] result in + if let error = result.error { + ErrorPresenter.showErrorAlert(error: error, from: self) + return + } + let configs = result.value! + tunnelsManager.addMultiple(tunnelConfigurations: configs.compactMap { $0 }) { [weak self] numberSuccessful in + if numberSuccessful == configs.count { + completionHandler?() + return + } + ErrorPresenter.showErrorAlert(title: "Created \(numberSuccessful) tunnels", + message: "Created \(numberSuccessful) of \(configs.count) tunnels from zip archive", + from: self, onPresented: completionHandler) + } + } + } else /* if (url.pathExtension == "conf") -- we assume everything else is a conf */ { + let fileBaseName = url.deletingPathExtension().lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines) + if let fileContents = try? String(contentsOf: url), + let tunnelConfiguration = try? WgQuickConfigFileParser.parse(fileContents, name: fileBaseName) { + tunnelsManager.add(tunnelConfiguration: tunnelConfiguration) { [weak self] result in + if let error = result.error { + ErrorPresenter.showErrorAlert(error: error, from: self, onPresented: completionHandler) + } else { + completionHandler?() + } + } + } else { + ErrorPresenter.showErrorAlert(title: "Unable to import tunnel", + message: "An error occured when importing the tunnel configuration.", + from: self, onPresented: completionHandler) + } + } + } +} + +// MARK: UIDocumentPickerDelegate + +extension TunnelsListTableViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + urls.forEach { + importFromFile(url: $0, completionHandler: nil) + } + } +} + +// MARK: QRScanViewControllerDelegate + +extension TunnelsListTableViewController: QRScanViewControllerDelegate { + func addScannedQRCode(tunnelConfiguration: TunnelConfiguration, qrScanViewController: QRScanViewController, + completionHandler: (() -> Void)?) { + tunnelsManager?.add(tunnelConfiguration: tunnelConfiguration) { result in + if let error = result.error { + ErrorPresenter.showErrorAlert(error: error, from: qrScanViewController, onDismissal: completionHandler) + } else { + completionHandler?() + } + } + } +} + +// MARK: UITableViewDataSource + +extension TunnelsListTableViewController: UITableViewDataSource { + func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return (tunnelsManager?.numberOfTunnels() ?? 0) + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: TunnelListCell = tableView.dequeueReusableCell(for: indexPath) + if let tunnelsManager = tunnelsManager { + let tunnel = tunnelsManager.tunnel(at: indexPath.row) + cell.tunnel = tunnel + cell.onSwitchToggled = { [weak self] isOn in + guard let self = self, let tunnelsManager = self.tunnelsManager else { return } + if isOn { + tunnelsManager.startActivation(of: tunnel) + } else { + tunnelsManager.startDeactivation(of: tunnel) + } + } + } + return cell + } +} + +// MARK: UITableViewDelegate + +extension TunnelsListTableViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + guard let tunnelsManager = tunnelsManager else { return } + let tunnel = tunnelsManager.tunnel(at: indexPath.row) + let tunnelDetailVC = TunnelDetailTableViewController(tunnelsManager: tunnelsManager, + tunnel: tunnel) + let tunnelDetailNC = UINavigationController(rootViewController: tunnelDetailVC) + tunnelDetailNC.restorationIdentifier = "DetailNC" + showDetailViewController(tunnelDetailNC, sender: self) // Shall get propagated up to the split-vc + } + + func tableView(_ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { [weak self] _, _, completionHandler in + guard let tunnelsManager = self?.tunnelsManager else { return } + let tunnel = tunnelsManager.tunnel(at: indexPath.row) + tunnelsManager.remove(tunnel: tunnel) { error in + if error != nil { + ErrorPresenter.showErrorAlert(error: error!, from: self) + completionHandler(false) + } else { + completionHandler(true) + } + } + } + return UISwipeActionsConfiguration(actions: [deleteAction]) + } +} + +// MARK: TunnelsManagerDelegate + +extension TunnelsListTableViewController: TunnelsManagerListDelegate { + func tunnelAdded(at index: Int) { + tableView.insertRows(at: [IndexPath(row: index, section: 0)], with: .automatic) + centeredAddButton.isHidden = (tunnelsManager?.numberOfTunnels() ?? 0 > 0) + } + + func tunnelModified(at index: Int) { + tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .automatic) + } + + func tunnelMoved(from oldIndex: Int, to newIndex: Int) { + tableView.moveRow(at: IndexPath(row: oldIndex, section: 0), to: IndexPath(row: newIndex, section: 0)) + } + + func tunnelRemoved(at index: Int) { + tableView.deleteRows(at: [IndexPath(row: index, section: 0)], with: .automatic) + centeredAddButton.isHidden = tunnelsManager?.numberOfTunnels() ?? 0 > 0 + } +} -- cgit v1.2.3-59-g8ed1b