diff options
Diffstat (limited to 'Sources/WireGuardApp/UI/iOS/ViewController')
9 files changed, 2395 insertions, 0 deletions
diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/LogViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/LogViewController.swift new file mode 100644 index 0000000..2398919 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/LogViewController.swift @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class LogViewController: UIViewController { + + let textView: UITextView = { + let textView = UITextView() + textView.isEditable = false + textView.isSelectable = true + textView.font = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body) + textView.adjustsFontForContentSizeCategory = true + return textView + }() + + let busyIndicator: UIActivityIndicatorView = { + let busyIndicator = UIActivityIndicatorView(style: .medium) + busyIndicator.hidesWhenStopped = true + return busyIndicator + }() + + let paragraphStyle: NSParagraphStyle = { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.setParagraphStyle(NSParagraphStyle.default) + paragraphStyle.lineHeightMultiple = 1.2 + return paragraphStyle + }() + + var isNextLineHighlighted = false + + var logViewHelper: LogViewHelper? + var isFetchingLogEntries = false + private var updateLogEntriesTimer: Timer? + + override func loadView() { + view = UIView() + view.backgroundColor = .systemBackground + view.addSubview(textView) + textView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + textView.topAnchor.constraint(equalTo: view.topAnchor), + textView.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) + ]) + + busyIndicator.startAnimating() + + logViewHelper = LogViewHelper(logFilePath: FileManager.logFileURL?.path) + startUpdatingLogEntries() + } + + override func viewDidLoad() { + title = tr("logViewTitle") + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(saveTapped(sender:))) + } + + func updateLogEntries() { + guard !isFetchingLogEntries else { return } + isFetchingLogEntries = true + logViewHelper?.fetchLogEntriesSinceLastFetch { [weak self] fetchedLogEntries in + guard let self = self else { return } + defer { + self.isFetchingLogEntries = false + } + if self.busyIndicator.isAnimating { + self.busyIndicator.stopAnimating() + } + guard !fetchedLogEntries.isEmpty else { return } + let isScrolledToEnd = self.textView.contentSize.height - self.textView.bounds.height - self.textView.contentOffset.y < 1 + + let richText = NSMutableAttributedString() + let bodyFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body) + let captionFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.caption1) + for logEntry in fetchedLogEntries { + let bgColor: UIColor = self.isNextLineHighlighted ? .systemGray3 : .systemBackground + let fgColor: UIColor = .label + let timestampText = NSAttributedString(string: logEntry.timestamp + "\n", attributes: [.font: captionFont, .backgroundColor: bgColor, .foregroundColor: fgColor, .paragraphStyle: self.paragraphStyle]) + let messageText = NSAttributedString(string: logEntry.message + "\n", attributes: [.font: bodyFont, .backgroundColor: bgColor, .foregroundColor: fgColor, .paragraphStyle: self.paragraphStyle]) + richText.append(timestampText) + richText.append(messageText) + self.isNextLineHighlighted.toggle() + } + self.textView.textStorage.append(richText) + if isScrolledToEnd { + let endOfCurrentText = NSRange(location: (self.textView.text as NSString).length, length: 0) + self.textView.scrollRangeToVisible(endOfCurrentText) + } + } + } + + func startUpdatingLogEntries() { + updateLogEntries() + updateLogEntriesTimer?.invalidate() + let timer = Timer(timeInterval: 1 /* second */, repeats: true) { [weak self] _ in + self?.updateLogEntries() + } + updateLogEntriesTimer = timer + RunLoop.main.add(timer, forMode: .common) + } + + @objc func saveTapped(sender: AnyObject) { + 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: tr("alertUnableToRemovePreviousLogTitle"), message: tr("alertUnableToRemovePreviousLogMessage"), from: self) + return + } + } + + let isWritten = Logger.global?.writeLog(to: destinationURL.path) ?? false + + DispatchQueue.main.async { + guard isWritten else { + ErrorPresenter.showErrorAlert(title: tr("alertUnableToWriteLogTitle"), message: tr("alertUnableToWriteLogMessage"), from: self) + return + } + let activityVC = UIActivityViewController(activityItems: [destinationURL], applicationActivities: nil) + if let sender = sender as? UIBarButtonItem { + activityVC.popoverPresentationController?.barButtonItem = sender + } + activityVC.completionWithItemsHandler = { _, _, _, _ in + // Remove the exported log file after the activity has completed + _ = FileManager.deleteFile(at: destinationURL) + } + self.present(activityVC, animated: true) + } + } + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/MainViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/MainViewController.swift new file mode 100644 index 0000000..8542296 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/MainViewController.swift @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 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 = .systemBackground + let detailNC = UINavigationController(rootViewController: detailVC) + + let masterVC = TunnelsListTableViewController() + let masterNC = UINavigationController(rootViewController: masterVC) + + tunnelsListVC = masterVC + + super.init(nibName: nil, bundle: nil) + + viewControllers = [ masterNC, detailNC ] + + 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 } + + switch result { + case .failure(let error): + ErrorPresenter.showErrorAlert(error: error, from: self) + case .success(let tunnelsManager): + self.tunnelsManager = tunnelsManager + self.tunnelsListVC?.setTunnelsManager(tunnelsManager: tunnelsManager) + + tunnelsManager.activationDelegate = self + + self.onTunnelsManagerReady?(tunnelsManager) + self.onTunnelsManagerReady = nil + } + } + } + + func allTunnelNames() -> [String]? { + guard let tunnelsManager = self.tunnelsManager else { return nil } + return tunnelsManager.mapTunnels { $0.name } + } +} + +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, shouldToggleStatus: Bool) { + let showTunnelDetailBlock: (TunnelsManager) -> Void = { [weak self] tunnelsManager in + guard let self = self else { return } + guard let tunnelsListVC = self.tunnelsListVC else { return } + if let tunnel = tunnelsManager.tunnel(named: tunnelName) { + tunnelsListVC.showTunnelDetail(for: tunnel, animated: false) + if shouldToggleStatus { + if tunnel.status == .inactive { + tunnelsManager.startActivation(of: tunnel) + } else if tunnel.status == .active { + tunnelsManager.startDeactivation(of: tunnel) + } + } + } + } + if let tunnelsManager = tunnelsManager { + showTunnelDetailBlock(tunnelsManager) + } else { + onTunnelsManagerReady = showTunnelDetailBlock + } + } + + func importFromDisposableFile(url: URL) { + let importFromFileBlock: (TunnelsManager) -> Void = { [weak self] tunnelsManager in + TunnelImporter.importFromFile(urls: [url], into: tunnelsManager, sourceVC: self, errorPresenterType: ErrorPresenter.self) { + _ = FileManager.deleteFile(at: url) + } + } + if let tunnelsManager = tunnelsManager { + importFromFileBlock(tunnelsManager) + } else { + onTunnelsManagerReady = importFromFileBlock + } + } +} + +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/Sources/WireGuardApp/UI/iOS/ViewController/QRScanViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/QRScanViewController.swift new file mode 100644 index 0000000..cb297a6 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/QRScanViewController.swift @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import AVFoundation +import UIKit + +protocol QRScanViewControllerDelegate: AnyObject { + 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 = tr("scanQRCodeViewTitle") + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped)) + + let tipLabel = UILabel() + tipLabel.text = tr("scanQRCodeTipText") + tipLabel.adjustsFontSizeToFitWidth = true + tipLabel.textColor = .lightGray + tipLabel.textAlignment = .center + + view.addSubview(tipLabel) + tipLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tipLabel.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + tipLabel.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + 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: tr("alertScanQRCodeCameraUnsupportedTitle"), message: tr("alertScanQRCodeCameraUnsupportedMessage")) + 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.current + let orientation = currentDevice.orientation + let previewLayerConnection = 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? TunnelConfiguration(fromWgQuickConfig: code, called: "Scanned") + guard let tunnelConfiguration = scannedTunnelConfiguration else { + scanDidEncounterError(title: tr("alertScanQRCodeInvalidQRCodeTitle"), message: tr("alertScanQRCodeInvalidQRCodeMessage")) + return + } + + let alert = UIAlertController(title: tr("alertScanQRCodeNamePromptTitle"), message: nil, preferredStyle: .alert) + alert.addTextField(configurationHandler: nil) + alert.addAction(UIAlertAction(title: tr("actionCancel"), style: .cancel) { [weak self] _ in + self?.dismiss(animated: true, completion: nil) + }) + alert.addAction(UIAlertAction(title: tr("actionSave"), style: .default) { [weak self] _ in + guard let title = alert.textFields?[0].text?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else { return } + tunnelConfiguration.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: tr("actionOK"), 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: tr("alertScanQRCodeUnreadableQRCodeTitle"), message: tr("alertScanQRCodeUnreadableQRCodeMessage")) + return + } + + AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate)) + scanDidComplete(withCode: stringValue) + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionDetailTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionDetailTableViewController.swift new file mode 100644 index 0000000..4211560 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionDetailTableViewController.swift @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class SSIDOptionDetailTableViewController: UITableViewController { + + let selectedSSIDs: [String] + + init(title: String, ssids: [String]) { + selectedSSIDs = ssids + super.init(style: .grouped) + self.title = title + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.estimatedRowHeight = 44 + tableView.rowHeight = UITableView.automaticDimension + tableView.allowsSelection = false + + tableView.register(TextCell.self) + } +} + +extension SSIDOptionDetailTableViewController { + override func numberOfSections(in tableView: UITableView) -> Int { + return 1 + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return selectedSSIDs.count + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return tr("tunnelOnDemandSectionTitleSelectedSSIDs") + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: TextCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = selectedSSIDs[indexPath.row] + return cell + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionEditTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionEditTableViewController.swift new file mode 100644 index 0000000..ef9a88c --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/SSIDOptionEditTableViewController.swift @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit +import SystemConfiguration.CaptiveNetwork +import NetworkExtension + +protocol SSIDOptionEditTableViewControllerDelegate: AnyObject { + func ssidOptionSaved(option: ActivateOnDemandViewModel.OnDemandSSIDOption, ssids: [String]) +} + +class SSIDOptionEditTableViewController: UITableViewController { + private enum Section { + case ssidOption + case selectedSSIDs + case addSSIDs + } + + private enum AddSSIDRow { + case addConnectedSSID(connectedSSID: String) + case addNewSSID + } + + weak var delegate: SSIDOptionEditTableViewControllerDelegate? + + private var sections = [Section]() + private var addSSIDRows = [AddSSIDRow]() + + let ssidOptionFields: [ActivateOnDemandViewModel.OnDemandSSIDOption] = [ + .anySSID, + .onlySpecificSSIDs, + .exceptSpecificSSIDs + ] + + var selectedOption: ActivateOnDemandViewModel.OnDemandSSIDOption + var selectedSSIDs: [String] + var connectedSSID: String? + + init(option: ActivateOnDemandViewModel.OnDemandSSIDOption, ssids: [String]) { + selectedOption = option + selectedSSIDs = ssids + super.init(style: .grouped) + loadSections() + addSSIDRows.removeAll() + addSSIDRows.append(.addNewSSID) + + getConnectedSSID { [weak self] ssid in + guard let self = self else { return } + self.connectedSSID = ssid + self.updateCurrentSSIDEntry() + self.updateTableViewAddSSIDRows() + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = tr("tunnelOnDemandSSIDViewTitle") + + tableView.estimatedRowHeight = 44 + tableView.rowHeight = UITableView.automaticDimension + + tableView.register(CheckmarkCell.self) + tableView.register(EditableTextCell.self) + tableView.register(TextCell.self) + tableView.isEditing = true + tableView.allowsSelectionDuringEditing = true + tableView.keyboardDismissMode = .onDrag + } + + func loadSections() { + sections.removeAll() + sections.append(.ssidOption) + if selectedOption != .anySSID { + sections.append(.selectedSSIDs) + sections.append(.addSSIDs) + } + } + + func updateCurrentSSIDEntry() { + if let connectedSSID = connectedSSID, !selectedSSIDs.contains(connectedSSID) { + if let first = addSSIDRows.first, case .addNewSSID = first { + addSSIDRows.insert(.addConnectedSSID(connectedSSID: connectedSSID), at: 0) + } + } else if let first = addSSIDRows.first, case .addConnectedSSID = first { + addSSIDRows.removeFirst() + } + } + + func updateTableViewAddSSIDRows() { + guard let addSSIDSection = sections.firstIndex(of: .addSSIDs) else { return } + let numberOfAddSSIDRows = addSSIDRows.count + let numberOfAddSSIDRowsInTableView = tableView.numberOfRows(inSection: addSSIDSection) + switch (numberOfAddSSIDRowsInTableView, numberOfAddSSIDRows) { + case (1, 2): + tableView.insertRows(at: [IndexPath(row: 0, section: addSSIDSection)], with: .automatic) + case (2, 1): + tableView.deleteRows(at: [IndexPath(row: 0, section: addSSIDSection)], with: .automatic) + default: + break + } + } + + override func viewWillDisappear(_ animated: Bool) { + delegate?.ssidOptionSaved(option: selectedOption, ssids: selectedSSIDs) + } +} + +extension SSIDOptionEditTableViewController { + override func numberOfSections(in tableView: UITableView) -> Int { + return sections.count + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch sections[section] { + case .ssidOption: + return ssidOptionFields.count + case .selectedSSIDs: + return selectedSSIDs.isEmpty ? 1 : selectedSSIDs.count + case .addSSIDs: + return addSSIDRows.count + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch sections[indexPath.section] { + case .ssidOption: + return ssidOptionCell(for: tableView, at: indexPath) + case .selectedSSIDs: + if !selectedSSIDs.isEmpty { + return selectedSSIDCell(for: tableView, at: indexPath) + } else { + return noSSIDsCell(for: tableView, at: indexPath) + } + case .addSSIDs: + return addSSIDCell(for: tableView, at: indexPath) + } + } + + override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { + switch sections[indexPath.section] { + case .ssidOption: + return false + case .selectedSSIDs: + return !selectedSSIDs.isEmpty + case .addSSIDs: + return true + } + } + + override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle { + switch sections[indexPath.section] { + case .ssidOption: + return .none + case .selectedSSIDs: + return .delete + case .addSSIDs: + return .insert + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch sections[section] { + case .ssidOption: + return nil + case .selectedSSIDs: + return tr("tunnelOnDemandSectionTitleSelectedSSIDs") + case .addSSIDs: + return tr("tunnelOnDemandSectionTitleAddSSIDs") + } + } + + private func ssidOptionCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let field = ssidOptionFields[indexPath.row] + let cell: CheckmarkCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = field.localizedUIString + cell.isChecked = selectedOption == field + cell.isEditing = false + return cell + } + + private func noSSIDsCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: TextCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = tr("tunnelOnDemandNoSSIDs") + cell.setTextColor(.secondaryLabel) + cell.setTextAlignment(.center) + return cell + } + + private func selectedSSIDCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: EditableTextCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = selectedSSIDs[indexPath.row] + cell.placeholder = tr("tunnelOnDemandSSIDTextFieldPlaceholder") + cell.isEditing = true + cell.onValueBeingEdited = { [weak self, weak cell] text in + guard let self = self, let cell = cell else { return } + if let row = self.tableView.indexPath(for: cell)?.row { + self.selectedSSIDs[row] = text + self.updateCurrentSSIDEntry() + self.updateTableViewAddSSIDRows() + } + } + return cell + } + + private func addSSIDCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: TextCell = tableView.dequeueReusableCell(for: indexPath) + switch addSSIDRows[indexPath.row] { + case .addConnectedSSID: + cell.message = tr(format: "tunnelOnDemandAddMessageAddConnectedSSID (%@)", connectedSSID!) + case .addNewSSID: + cell.message = tr("tunnelOnDemandAddMessageAddNewSSID") + } + cell.isEditing = true + return cell + } + + override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { + switch sections[indexPath.section] { + case .ssidOption: + assertionFailure() + case .selectedSSIDs: + assert(editingStyle == .delete) + selectedSSIDs.remove(at: indexPath.row) + if !selectedSSIDs.isEmpty { + tableView.deleteRows(at: [indexPath], with: .automatic) + } else { + tableView.reloadRows(at: [indexPath], with: .automatic) + } + updateCurrentSSIDEntry() + updateTableViewAddSSIDRows() + case .addSSIDs: + assert(editingStyle == .insert) + let newSSID: String + switch addSSIDRows[indexPath.row] { + case .addConnectedSSID(let connectedSSID): + newSSID = connectedSSID + case .addNewSSID: + newSSID = "" + } + selectedSSIDs.append(newSSID) + loadSections() + let selectedSSIDsSection = sections.firstIndex(of: .selectedSSIDs)! + let indexPath = IndexPath(row: selectedSSIDs.count - 1, section: selectedSSIDsSection) + if selectedSSIDs.count == 1 { + tableView.reloadRows(at: [indexPath], with: .automatic) + } else { + tableView.insertRows(at: [indexPath], with: .automatic) + } + updateCurrentSSIDEntry() + updateTableViewAddSSIDRows() + if newSSID.isEmpty { + if let selectedSSIDCell = tableView.cellForRow(at: indexPath) as? EditableTextCell { + selectedSSIDCell.beginEditing() + } + } + } + } + + private func getConnectedSSID(completionHandler: @escaping (String?) -> Void) { + #if targetEnvironment(simulator) + completionHandler("Simulator Wi-Fi") + #else + NEHotspotNetwork.fetchCurrent { hotspotNetwork in + completionHandler(hotspotNetwork?.ssid) + } + #endif + } +} + +extension SSIDOptionEditTableViewController { + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + switch sections[indexPath.section] { + case .ssidOption: + return indexPath + case .selectedSSIDs, .addSSIDs: + return nil + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + switch sections[indexPath.section] { + case .ssidOption: + let previousOption = selectedOption + selectedOption = ssidOptionFields[indexPath.row] + guard previousOption != selectedOption else { + tableView.deselectRow(at: indexPath, animated: true) + return + } + loadSections() + if previousOption == .anySSID { + let indexSet = IndexSet(1 ... 2) + tableView.insertSections(indexSet, with: .fade) + } + if selectedOption == .anySSID { + let indexSet = IndexSet(1 ... 2) + tableView.deleteSections(indexSet, with: .fade) + } + tableView.reloadSections(IndexSet(integer: indexPath.section), with: .none) + case .selectedSSIDs, .addSSIDs: + assertionFailure() + } + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/SettingsTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/SettingsTableViewController.swift new file mode 100644 index 0000000..483b779 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/SettingsTableViewController.swift @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit +import os.log + +class SettingsTableViewController: UITableViewController { + + enum SettingsFields { + case iosAppVersion + case goBackendVersion + case exportZipArchive + case viewLog + + var localizedUIString: String { + switch self { + case .iosAppVersion: return tr("settingsVersionKeyWireGuardForIOS") + case .goBackendVersion: return tr("settingsVersionKeyWireGuardGoBackend") + case .exportZipArchive: return tr("settingsExportZipButtonTitle") + case .viewLog: return tr("settingsViewLogButtonTitle") + } + } + } + + let settingsFieldsBySection: [[SettingsFields]] = [ + [.iosAppVersion, .goBackendVersion], + [.exportZipArchive], + [.viewLog] + ] + + 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 = tr("settingsViewTitle") + 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) { + PrivateDataConfirmation.confirmAccess(to: tr("iosExportPrivateData")) { [weak self] in + guard let self = self else { return } + guard let tunnelsManager = self.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 presentLogView() { + let logVC = LogViewController() + navigationController?.pushViewController(logVC, animated: true) + + } +} + +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 tr("settingsSectionTitleAbout") + case 1: + return tr("settingsSectionTitleExportConfigurations") + case 2: + return tr("settingsSectionTitleTunnelLog") + 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.localizedUIString + if field == .iosAppVersion { + var appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown version" + if let appBuild = Bundle.main.object(forInfoDictionaryKey: "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.localizedUIString + cell.onTapped = { [weak self] in + self?.exportConfigurationsAsZipFile(sourceView: cell.button) + } + return cell + } else if field == .viewLog { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.localizedUIString + cell.onTapped = { [weak self] in + self?.presentLogView() + } + return cell + } + fatalError() + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/TunnelDetailTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelDetailTableViewController.swift new file mode 100644 index 0000000..509d123 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelDetailTableViewController.swift @@ -0,0 +1,496 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelDetailTableViewController: UITableViewController { + + private enum Section { + case status + case interface + case peer(index: Int, peer: TunnelViewModel.PeerData) + case onDemand + case delete + } + + static let interfaceFields: [TunnelViewModel.InterfaceField] = [ + .name, .publicKey, .addresses, + .listenPort, .mtu, .dns + ] + + static let peerFields: [TunnelViewModel.PeerField] = [ + .publicKey, .preSharedKey, .endpoint, + .allowedIPs, .persistentKeepAlive, + .rxBytes, .txBytes, .lastHandshakeTime + ] + + static let onDemandFields: [ActivateOnDemandViewModel.OnDemandField] = [ + .onDemand, .ssid + ] + + let tunnelsManager: TunnelsManager + let tunnel: TunnelContainer + var tunnelViewModel: TunnelViewModel + var onDemandViewModel: ActivateOnDemandViewModel + + private var sections = [Section]() + private var interfaceFieldIsVisible = [Bool]() + private var peerFieldIsVisible = [[Bool]]() + + private var statusObservationToken: AnyObject? + private var onDemandObservationToken: AnyObject? + private var reloadRuntimeConfigurationTimer: Timer? + + init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) { + self.tunnelsManager = tunnelsManager + self.tunnel = tunnel + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration) + onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel) + super.init(style: .grouped) + loadSections() + loadVisibleFields() + statusObservationToken = tunnel.observe(\.status) { [weak self] _, _ in + guard let self = self else { return } + if tunnel.status == .active { + self.startUpdatingRuntimeConfiguration() + } else if tunnel.status == .inactive { + self.reloadRuntimeConfiguration() + self.stopUpdatingRuntimeConfiguration() + } + } + onDemandObservationToken = tunnel.observe(\.isActivateOnDemandEnabled) { [weak self] tunnel, _ in + // Handle On-Demand getting turned on/off outside of the app + self?.onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel) + self?.updateActivateOnDemandFields() + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + 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.register(SwitchCell.self) + tableView.register(KeyValueCell.self) + tableView.register(ButtonCell.self) + tableView.register(ChevronCell.self) + + restorationIdentifier = "TunnelDetailVC:\(tunnel.name)" + } + + private func loadSections() { + sections.removeAll() + sections.append(.status) + sections.append(.interface) + for (index, peer) in tunnelViewModel.peersData.enumerated() { + sections.append(.peer(index: index, peer: peer)) + } + sections.append(.onDemand) + sections.append(.delete) + } + + private func loadVisibleFields() { + let visibleInterfaceFields = tunnelViewModel.interfaceData.filterFieldsWithValueOrControl(interfaceFields: TunnelDetailTableViewController.interfaceFields) + interfaceFieldIsVisible = TunnelDetailTableViewController.interfaceFields.map { visibleInterfaceFields.contains($0) } + peerFieldIsVisible = tunnelViewModel.peersData.map { peer in + let visiblePeerFields = peer.filterFieldsWithValueOrControl(peerFields: TunnelDetailTableViewController.peerFields) + return TunnelDetailTableViewController.peerFields.map { visiblePeerFields.contains($0) } + } + } + + override func viewWillAppear(_ animated: Bool) { + if tunnel.status == .active { + self.startUpdatingRuntimeConfiguration() + } + } + + override func viewDidDisappear(_ animated: Bool) { + stopUpdatingRuntimeConfiguration() + } + + @objc func editTapped() { + PrivateDataConfirmation.confirmAccess(to: tr("iosViewPrivateData")) { [weak self] in + guard let self = self else { return } + let editVC = TunnelEditTableViewController(tunnelsManager: self.tunnelsManager, tunnel: self.tunnel) + editVC.delegate = self + let editNC = UINavigationController(rootViewController: editVC) + editNC.modalPresentationStyle = .fullScreen + self.present(editNC, animated: true) + } + } + + func startUpdatingRuntimeConfiguration() { + reloadRuntimeConfiguration() + reloadRuntimeConfigurationTimer?.invalidate() + let reloadTimer = Timer(timeInterval: 1 /* second */, repeats: true) { [weak self] _ in + self?.reloadRuntimeConfiguration() + } + reloadRuntimeConfigurationTimer = reloadTimer + RunLoop.main.add(reloadTimer, forMode: .common) + } + + func stopUpdatingRuntimeConfiguration() { + reloadRuntimeConfigurationTimer?.invalidate() + reloadRuntimeConfigurationTimer = nil + } + + func applyTunnelConfiguration(tunnelConfiguration: TunnelConfiguration) { + // Incorporates changes from tunnelConfiguation. Ignores any changes in peer ordering. + guard let tableView = self.tableView else { return } + let sections = self.sections + let interfaceSectionIndex = sections.firstIndex { + if case .interface = $0 { + return true + } else { + return false + } + }! + let firstPeerSectionIndex = interfaceSectionIndex + 1 + let interfaceFieldIsVisible = self.interfaceFieldIsVisible + let peerFieldIsVisible = self.peerFieldIsVisible + + func handleSectionFieldsModified<T>(fields: [T], fieldIsVisible: [Bool], section: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) { + for (index, field) in fields.enumerated() { + guard let change = changes[field] else { continue } + if case .modified(let newValue) = change { + let row = fieldIsVisible[0 ..< index].filter { $0 }.count + let indexPath = IndexPath(row: row, section: section) + if let cell = tableView.cellForRow(at: indexPath) as? KeyValueCell { + cell.value = newValue + } + } + } + } + + func handleSectionRowsInsertedOrRemoved<T>(fields: [T], fieldIsVisible fieldIsVisibleInput: [Bool], section: Int, changes: [T: TunnelViewModel.Changes.FieldChange]) { + var fieldIsVisible = fieldIsVisibleInput + + var removedIndexPaths = [IndexPath]() + for (index, field) in fields.enumerated().reversed() where changes[field] == .removed { + let row = fieldIsVisible[0 ..< index].filter { $0 }.count + removedIndexPaths.append(IndexPath(row: row, section: section)) + fieldIsVisible[index] = false + } + if !removedIndexPaths.isEmpty { + tableView.deleteRows(at: removedIndexPaths, with: .automatic) + } + + var addedIndexPaths = [IndexPath]() + for (index, field) in fields.enumerated() where changes[field] == .added { + let row = fieldIsVisible[0 ..< index].filter { $0 }.count + addedIndexPaths.append(IndexPath(row: row, section: section)) + fieldIsVisible[index] = true + } + if !addedIndexPaths.isEmpty { + tableView.insertRows(at: addedIndexPaths, with: .automatic) + } + } + + let changes = self.tunnelViewModel.applyConfiguration(other: tunnelConfiguration) + + if !changes.interfaceChanges.isEmpty { + handleSectionFieldsModified(fields: TunnelDetailTableViewController.interfaceFields, fieldIsVisible: interfaceFieldIsVisible, + section: interfaceSectionIndex, changes: changes.interfaceChanges) + } + for (peerIndex, peerChanges) in changes.peerChanges { + handleSectionFieldsModified(fields: TunnelDetailTableViewController.peerFields, fieldIsVisible: peerFieldIsVisible[peerIndex], section: firstPeerSectionIndex + peerIndex, changes: peerChanges) + } + + let isAnyInterfaceFieldAddedOrRemoved = changes.interfaceChanges.contains { $0.value == .added || $0.value == .removed } + let isAnyPeerFieldAddedOrRemoved = changes.peerChanges.contains { $0.changes.contains { $0.value == .added || $0.value == .removed } } + let peersRemovedSectionIndices = changes.peersRemovedIndices.map { firstPeerSectionIndex + $0 } + let peersInsertedSectionIndices = changes.peersInsertedIndices.map { firstPeerSectionIndex + $0 } + + if isAnyInterfaceFieldAddedOrRemoved || isAnyPeerFieldAddedOrRemoved || !peersRemovedSectionIndices.isEmpty || !peersInsertedSectionIndices.isEmpty { + tableView.beginUpdates() + if isAnyInterfaceFieldAddedOrRemoved { + handleSectionRowsInsertedOrRemoved(fields: TunnelDetailTableViewController.interfaceFields, fieldIsVisible: interfaceFieldIsVisible, section: interfaceSectionIndex, changes: changes.interfaceChanges) + } + if isAnyPeerFieldAddedOrRemoved { + for (peerIndex, peerChanges) in changes.peerChanges { + handleSectionRowsInsertedOrRemoved(fields: TunnelDetailTableViewController.peerFields, fieldIsVisible: peerFieldIsVisible[peerIndex], section: firstPeerSectionIndex + peerIndex, changes: peerChanges) + } + } + if !peersRemovedSectionIndices.isEmpty { + tableView.deleteSections(IndexSet(peersRemovedSectionIndices), with: .automatic) + } + if !peersInsertedSectionIndices.isEmpty { + tableView.insertSections(IndexSet(peersInsertedSectionIndices), with: .automatic) + } + self.loadSections() + self.loadVisibleFields() + tableView.endUpdates() + } else { + self.loadSections() + self.loadVisibleFields() + } + } + + private func reloadRuntimeConfiguration() { + tunnel.getRuntimeTunnelConfiguration { [weak self] tunnelConfiguration in + guard let tunnelConfiguration = tunnelConfiguration else { return } + guard let self = self else { return } + self.applyTunnelConfiguration(tunnelConfiguration: tunnelConfiguration) + } + } + + private func updateActivateOnDemandFields() { + guard let onDemandSection = sections.firstIndex(where: { if case .onDemand = $0 { return true } else { return false } }) else { return } + let numberOfTableViewOnDemandRows = tableView.numberOfRows(inSection: onDemandSection) + let ssidRowIndexPath = IndexPath(row: 1, section: onDemandSection) + switch (numberOfTableViewOnDemandRows, onDemandViewModel.isWiFiInterfaceEnabled) { + case (1, true): + tableView.insertRows(at: [ssidRowIndexPath], with: .automatic) + case (2, false): + tableView.deleteRows(at: [ssidRowIndexPath], with: .automatic) + default: + break + } + tableView.reloadSections(IndexSet(integer: onDemandSection), with: .automatic) + } +} + +extension TunnelDetailTableViewController: TunnelEditTableViewControllerDelegate { + func tunnelSaved(tunnel: TunnelContainer) { + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration) + onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel) + loadSections() + loadVisibleFields() + title = tunnel.name + restorationIdentifier = "TunnelDetailVC:\(tunnel.name)" + tableView.reloadData() + } + func tunnelEditingCancelled() { + // Nothing to do + } +} + +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 interfaceFieldIsVisible.filter { $0 }.count + case .peer(let peerIndex, _): + return peerFieldIsVisible[peerIndex].filter { $0 }.count + case .onDemand: + return onDemandViewModel.isWiFiInterfaceEnabled ? 2 : 1 + case .delete: + return 1 + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch sections[section] { + case .status: + return tr("tunnelSectionTitleStatus") + case .interface: + return tr("tunnelSectionTitleInterface") + case .peer: + return tr("tunnelSectionTitlePeer") + case .onDemand: + return tr("tunnelSectionTitleOnDemand") + 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 index, let peer): + return peerCell(for: tableView, at: indexPath, with: peer, peerIndex: index) + 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) + + func update(cell: SwitchCell?, with tunnel: TunnelContainer) { + guard let cell = cell else { return } + + let status = tunnel.status + let isOnDemandEngaged = tunnel.isActivateOnDemandEnabled + + let isSwitchOn = (status == .activating || status == .active || isOnDemandEngaged) + cell.switchView.setOn(isSwitchOn, animated: true) + + if isOnDemandEngaged && !(status == .activating || status == .active) { + cell.switchView.onTintColor = UIColor.systemYellow + } else { + cell.switchView.onTintColor = UIColor.systemGreen + } + + var text: String + switch status { + case .inactive: + text = tr("tunnelStatusInactive") + case .activating: + text = tr("tunnelStatusActivating") + case .active: + text = tr("tunnelStatusActive") + case .deactivating: + text = tr("tunnelStatusDeactivating") + case .reasserting: + text = tr("tunnelStatusReasserting") + case .restarting: + text = tr("tunnelStatusRestarting") + case .waiting: + text = tr("tunnelStatusWaiting") + } + + if tunnel.hasOnDemandRules { + text += isOnDemandEngaged ? tr("tunnelStatusAddendumOnDemand") : "" + cell.switchView.isUserInteractionEnabled = true + cell.isEnabled = true + } else { + cell.switchView.isUserInteractionEnabled = (status == .inactive || status == .active) + cell.isEnabled = (status == .inactive || status == .active) + } + + if tunnel.hasOnDemandRules && !isOnDemandEngaged && status == .inactive { + text = tr("tunnelStatusOnDemandDisabled") + } + + cell.textLabel?.text = text + } + + update(cell: cell, with: tunnel) + cell.statusObservationToken = tunnel.observe(\.status) { [weak cell] tunnel, _ in + update(cell: cell, with: tunnel) + } + cell.isOnDemandEnabledObservationToken = tunnel.observe(\.isActivateOnDemandEnabled) { [weak cell] tunnel, _ in + update(cell: cell, with: tunnel) + } + cell.hasOnDemandRulesObservationToken = tunnel.observe(\.hasOnDemandRules) { [weak cell] tunnel, _ in + update(cell: cell, with: tunnel) + } + + cell.onSwitchToggled = { [weak self] isOn in + guard let self = self else { return } + + if self.tunnel.hasOnDemandRules { + self.tunnelsManager.setOnDemandEnabled(isOn, on: self.tunnel) { error in + if error == nil && !isOn { + self.tunnelsManager.startDeactivation(of: self.tunnel) + } + } + } else { + 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 visibleInterfaceFields = TunnelDetailTableViewController.interfaceFields.enumerated().filter { interfaceFieldIsVisible[$0.offset] }.map { $0.element } + let field = visibleInterfaceFields[indexPath.row] + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + cell.value = tunnelViewModel.interfaceData[field] + return cell + } + + private func peerCell(for tableView: UITableView, at indexPath: IndexPath, with peerData: TunnelViewModel.PeerData, peerIndex: Int) -> UITableViewCell { + let visiblePeerFields = TunnelDetailTableViewController.peerFields.enumerated().filter { peerFieldIsVisible[peerIndex][$0.offset] }.map { $0.element } + let field = visiblePeerFields[indexPath.row] + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + if field == .persistentKeepAlive { + cell.value = tr(format: "tunnelPeerPersistentKeepaliveValue (%@)", peerData[field]) + } else if field == .preSharedKey { + cell.value = tr("tunnelPeerPresharedKeyEnabled") + } else { + cell.value = peerData[field] + } + return cell + } + + private func onDemandCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let field = TunnelDetailTableViewController.onDemandFields[indexPath.row] + if field == .onDemand { + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + cell.value = onDemandViewModel.localizedInterfaceDescription + cell.copyableGesture = false + return cell + } else { + assert(field == .ssid) + if onDemandViewModel.ssidOption == .anySSID { + let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + cell.value = onDemandViewModel.ssidOption.localizedUIString + cell.copyableGesture = false + return cell + } else { + let cell: ChevronCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = field.localizedUIString + cell.detailMessage = onDemandViewModel.localizedSSIDDescription + return cell + } + } + } + + private func deleteConfigurationCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = tr("deleteTunnelButtonTitle") + cell.hasDestructiveAction = true + cell.onTapped = { [weak self] in + guard let self = self else { return } + ConfirmationAlertPresenter.showConfirmationAlert(message: tr("deleteTunnelConfirmationAlertMessage"), + buttonTitle: tr("deleteTunnelConfirmationAlertButtonTitle"), + from: cell, presentingVC: self) { [weak self] in + guard let self = self else { return } + self.tunnelsManager.remove(tunnel: self.tunnel) { error in + if error != nil { + print("Error removing tunnel: \(String(describing: error))") + return + } + } + } + } + return cell + } + +} + +extension TunnelDetailTableViewController { + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + if case .onDemand = sections[indexPath.section], + case .ssid = TunnelDetailTableViewController.onDemandFields[indexPath.row] { + return indexPath + } + return nil + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + if case .onDemand = sections[indexPath.section], + case .ssid = TunnelDetailTableViewController.onDemandFields[indexPath.row] { + let ssidDetailVC = SSIDOptionDetailTableViewController(title: onDemandViewModel.ssidOption.localizedUIString, ssids: onDemandViewModel.selectedSSIDs) + navigationController?.pushViewController(ssidDetailVC, animated: true) + } + tableView.deselectRow(at: indexPath, animated: true) + } +} diff --git a/Sources/WireGuardApp/UI/iOS/ViewController/TunnelEditTableViewController.swift b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelEditTableViewController.swift new file mode 100644 index 0000000..dfb35c6 --- /dev/null +++ b/Sources/WireGuardApp/UI/iOS/ViewController/TunnelEditTableViewController.swift @@ -0,0 +1,503 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018-2023 WireGuard LLC. All Rights Reserved. + +import UIKit + +protocol TunnelEditTableViewControllerDelegate: AnyObject { + func tunnelSaved(tunnel: TunnelContainer) + func tunnelEditingCancelled() +} + +class TunnelEditTableViewController: UITableViewController { + private enum Section { + case interface + case peer(_ peer: TunnelViewModel.PeerData) + case addPeer + case onDemand + + static func == (lhs: Section, rhs: Section) -> Bool { + switch (lhs, rhs) { + case (.interface, .interface), + (.addPeer, .addPeer), + (.onDemand, .onDemand): + return true + case let (.peer(peerA), .peer(peerB)): + return peerA.index == peerB.index + default: + return false + } + } + } + + 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 onDemandFields: [ActivateOnDemandViewModel.OnDemandField] = [ + .nonWiFiInterface, + .wiFiInterface, + .ssid + ] + + let tunnelsManager: TunnelsManager + let tunnel: TunnelContainer? + let tunnelViewModel: TunnelViewModel + var onDemandViewModel: ActivateOnDemandViewModel + private var sections = [Section]() + + // Use this initializer to edit an existing tunnel. + init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) { + self.tunnelsManager = tunnelsManager + self.tunnel = tunnel + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration) + onDemandViewModel = ActivateOnDemandViewModel(tunnel: tunnel) + super.init(style: .grouped) + loadSections() + } + + // Use this initializer to create a new tunnel. + init(tunnelsManager: TunnelsManager) { + self.tunnelsManager = tunnelsManager + tunnel = nil + tunnelViewModel = TunnelViewModel(tunnelConfiguration: nil) + onDemandViewModel = ActivateOnDemandViewModel() + 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 ? tr("newTunnelViewTitle") : tr("editTunnelViewTitle") + 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(TunnelEditKeyValueCell.self) + tableView.register(TunnelEditEditableKeyValueCell.self) + tableView.register(ButtonCell.self) + tableView.register(SwitchCell.self) + tableView.register(ChevronCell.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 alertTitle = (tunnelViewModel.interfaceData.validatedConfiguration == nil || tunnelViewModel.interfaceData.validatedName == nil) ? + tr("alertInvalidInterfaceTitle") : tr("alertInvalidPeerTitle") + ErrorPresenter.showErrorAlert(title: alertTitle, message: errorMessage, from: self) + tableView.reloadData() // Highlight erroring fields + case .saved(let tunnelConfiguration): + let onDemandOption = onDemandViewModel.toOnDemandOption() + if let tunnel = tunnel { + // We're modifying an existing tunnel + tunnelsManager.modify(tunnel: tunnel, tunnelConfiguration: tunnelConfiguration, onDemandOption: onDemandOption) { [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, onDemandOption: onDemandOption) { [weak self] result in + switch result { + case .failure(let error): + ErrorPresenter.showErrorAlert(error: error, from: self) + case .success(let tunnel): + 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 onDemandViewModel.isWiFiInterfaceEnabled { + return 3 + } else { + return 2 + } + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch sections[section] { + case .interface: + return section == 0 ? tr("tunnelSectionTitleInterface") : nil + case .peer: + return tr("tunnelSectionTitlePeer") + case .addPeer: + return nil + case .onDemand: + return tr("tunnelSectionTitleOnDemand") + } + } + + 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.localizedUIString + cell.onTapped = { [weak self] in + guard let self = self else { return } + + self.tunnelViewModel.interfaceData[.privateKey] = PrivateKey().base64Key + 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: TunnelEditKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + cell.value = tunnelViewModel.interfaceData[field] + return cell + } + + private func interfaceFieldKeyValueCell(for tableView: UITableView, at indexPath: IndexPath, with field: TunnelViewModel.InterfaceField) -> UITableViewCell { + let cell: TunnelEditEditableKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + + switch field { + case .name, .privateKey: + cell.placeholderText = tr("tunnelEditPlaceholderTextRequired") + cell.keyboardType = .default + case .addresses: + cell.placeholderText = tr("tunnelEditPlaceholderTextStronglyRecommended") + cell.keyboardType = .numbersAndPunctuation + case .dns: + cell.placeholderText = tunnelViewModel.peersData.contains(where: { $0.shouldStronglyRecommendDNS }) ? tr("tunnelEditPlaceholderTextStronglyRecommended") : tr("tunnelEditPlaceholderTextOptional") + cell.keyboardType = .numbersAndPunctuation + case .listenPort, .mtu: + cell.placeholderText = tr("tunnelEditPlaceholderTextAutomatic") + cell.keyboardType = .numberPad + case .publicKey, .generateKeyPair: + cell.keyboardType = .default + case .status, .toggleStatus: + fatalError("Unexpected interface field") + } + + 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.onValueBeingEdited = { [weak self] value in + self?.tunnelViewModel.interfaceData[field] = value + } + cell.onValueChanged = { [weak self] oldValue, newValue in + guard let self = self else { return } + let isAllowedIPsChanged = self.tunnelViewModel.updateDNSServersInAllowedIPsIfRequired(oldDNSServers: oldValue, newDNSServers: newValue) + if isAllowedIPsChanged { + let section = self.sections.firstIndex { if case .peer = $0 { return true } else { return false } } + if let section = section, let row = self.peerFields.firstIndex(of: .allowedIPs) { + self.tableView.reloadRows(at: [IndexPath(row: row, section: section)], with: .none) + } + } + } + } else { + cell.onValueChanged = { [weak self] _, value in + self?.tunnelViewModel.interfaceData[field] = value + } + } + // 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) + } + } + } + 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.localizedUIString + cell.hasDestructiveAction = true + cell.onTapped = { [weak self, weak peerData] in + guard let self = self, let peerData = peerData else { return } + ConfirmationAlertPresenter.showConfirmationAlert(message: tr("deletePeerConfirmationAlertMessage"), + buttonTitle: tr("deletePeerConfirmationAlertButtonTitle"), + from: cell, presentingVC: self) { [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) + + // swiftlint:disable:next trailing_closure + 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.localizedUIString + 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: TunnelEditEditableKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.localizedUIString + + switch field { + case .publicKey: + cell.placeholderText = tr("tunnelEditPlaceholderTextRequired") + cell.keyboardType = .default + case .preSharedKey, .endpoint: + cell.placeholderText = tr("tunnelEditPlaceholderTextOptional") + cell.keyboardType = .default + case .allowedIPs: + cell.placeholderText = tr("tunnelEditPlaceholderTextOptional") + cell.keyboardType = .numbersAndPunctuation + case .persistentKeepAlive: + cell.placeholderText = tr("tunnelEditPlaceholderTextOff") + cell.keyboardType = .numberPad + case .excludePrivateIPs, .deletePeer: + cell.keyboardType = .default + case .rxBytes, .txBytes, .lastHandshakeTime: + fatalError() + } + + cell.isValueValid = !peerData.fieldsWithError.contains(field) + cell.value = peerData[field] + + if field == .allowedIPs { + let firstInterfaceSection = sections.firstIndex { $0 == .interface }! + let interfaceSubSection = interfaceFieldsBySection.firstIndex { $0.contains(.dns) }! + let dnsRow = interfaceFieldsBySection[interfaceSubSection].firstIndex { $0 == .dns }! + + 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, 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) + } + } + + tableView.reloadRows(at: [IndexPath(row: dnsRow, section: firstInterfaceSection + interfaceSubSection)], with: .none) + } + } 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 = tr("addPeerButtonTitle") + 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 { + let field = onDemandFields[indexPath.row] + if indexPath.row < 2 { + let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = field.localizedUIString + cell.isOn = onDemandViewModel.isEnabled(field: field) + cell.onSwitchToggled = { [weak self] isOn in + guard let self = self else { return } + self.onDemandViewModel.setEnabled(field: field, isEnabled: isOn) + let section = self.sections.firstIndex { $0 == .onDemand }! + let indexPath = IndexPath(row: 2, section: section) + if field == .wiFiInterface { + if isOn { + tableView.insertRows(at: [indexPath], with: .fade) + } else { + tableView.deleteRows(at: [indexPath], with: .fade) + } + } + } + return cell + } else { + let cell: ChevronCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = field.localizedUIString + cell.detailMessage = onDemandViewModel.localizedSSIDDescription + 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) + } +} + +extension TunnelEditTableViewController { + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + if case .onDemand = sections[indexPath.section], indexPath.row == 2 { + return indexPath + } else { + return nil + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + switch sections[indexPath.section] { + case .onDemand: + assert(indexPath.row == 2) + tableView.deselectRow(at: indexPath, animated: true) + let ssidOptionVC = SSIDOptionEditTableViewController(option: onDemandViewModel.ssidOption, ssids: onDemandViewModel.selectedSSIDs) + ssidOptionVC.delegate = self + navigationController?.pushViewController(ssidOptionVC, animated: true) + default: + assertionFailure() + } + } +} + +extension TunnelEditTableViewController: SSIDOptionEditTableViewControllerDelegate { + func ssidOptionSaved(option: ActivateOnDemandViewModel.OnDemandSSIDOption, ssids: [String]) { + onDemandViewModel.selectedSSIDs = ssids + onDemandViewModel.ssidOption = option + onDemandViewModel.fixSSIDOption() + if let onDemandSection = sections.firstIndex(where: { $0 == .onDemand }) { + if let ssidRowIndex = onDemandFields.firstIndex(of: .ssid) { + let indexPath = IndexPath(row: ssidRowIndex, section: onDemandSection) + tableView.reloadRows(at: [indexPath], with: .none) + } + } + } +} 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) + } + } + } +} |