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/EditTunnel/TunnelEditButtonCell.swift | 55 ++ .../UI/iOS/EditTunnel/TunnelEditKeyValueCell.swift | 158 ++++ .../TunnelEditReadOnlyKeyValueCell.swift | 71 ++ .../iOS/EditTunnel/TunnelEditSectionListCell.swift | 30 + .../UI/iOS/EditTunnel/TunnelEditSwitchCell.swift | 47 ++ .../EditTunnel/TunnelEditTableViewController.swift | 488 ++++++++++++ WireGuard/WireGuard/UI/iOS/ErrorPresenter.swift | 2 +- .../WireGuard/UI/iOS/QRScanViewController.swift | 2 +- WireGuard/WireGuard/UI/iOS/ScrollableLabel.swift | 30 +- .../UI/iOS/Settings/SettingsButtonCell.swift | 47 ++ .../UI/iOS/Settings/SettingsKeyValueCell.swift | 29 + .../iOS/Settings/SettingsTableViewController.swift | 199 +++++ .../UI/iOS/SettingsTableViewController.swift | 263 ------- .../TunnelDetailActivateOnDemandCell.swift | 40 + .../iOS/TunnelDetail/TunnelDetailButtonCell.swift | 55 ++ .../TunnelDetail/TunnelDetailKeyValueCell.swift | 107 +++ .../iOS/TunnelDetail/TunnelDetailStatusCell.swift | 85 +++ .../TunnelDetailTableViewController.swift | 219 ++++++ .../UI/iOS/TunnelEditTableViewController.swift | 818 --------------------- .../UI/iOS/TunnelList/BorderedTextButton.swift | 50 ++ .../UI/iOS/TunnelList/TunnelListCell.swift | 111 +++ .../TunnelsListTableViewController.swift | 305 ++++++++ .../UI/iOS/TunnelsListTableViewController.swift | 453 ------------ 23 files changed, 2112 insertions(+), 1552 deletions(-) create mode 100644 WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditButtonCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditKeyValueCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditReadOnlyKeyValueCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditSectionListCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditSwitchCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditTableViewController.swift create mode 100644 WireGuard/WireGuard/UI/iOS/Settings/SettingsButtonCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/Settings/SettingsKeyValueCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/Settings/SettingsTableViewController.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/SettingsTableViewController.swift create mode 100644 WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailActivateOnDemandCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailButtonCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailKeyValueCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailStatusCell.swift create mode 100644 WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailTableViewController.swift delete mode 100644 WireGuard/WireGuard/UI/iOS/TunnelEditTableViewController.swift 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 delete mode 100644 WireGuard/WireGuard/UI/iOS/TunnelsListTableViewController.swift (limited to 'WireGuard/WireGuard/UI/iOS') diff --git a/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditButtonCell.swift b/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditButtonCell.swift new file mode 100644 index 0000000..af70183 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditButtonCell.swift @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelEditButtonCell: UITableViewCell { + var buttonText: String { + get { return button.title(for: .normal) ?? "" } + set(value) { button.setTitle(value, for: .normal) } + } + var hasDestructiveAction: Bool { + get { return button.tintColor == UIColor.red } + set(value) { button.tintColor = value ? UIColor.red : buttonStandardTintColor } + } + var onTapped: (() -> Void)? + + let button: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) + button.titleLabel?.adjustsFontForContentSizeCategory = true + return button + }() + + var buttonStandardTintColor: UIColor + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + buttonStandardTintColor = button.tintColor + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: button.bottomAnchor), + button.centerXAnchor.constraint(equalTo: contentView.centerXAnchor) + ]) + + button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + } + + @objc func buttonTapped() { + onTapped?() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + buttonText = "" + onTapped = nil + hasDestructiveAction = false + } +} diff --git a/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditKeyValueCell.swift b/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditKeyValueCell.swift new file mode 100644 index 0000000..432d75b --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditKeyValueCell.swift @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelEditKeyValueCell: UITableViewCell { + var key: String { + get { return keyLabel.text ?? "" } + set(value) {keyLabel.text = value } + } + var value: String { + get { return valueTextField.text ?? "" } + set(value) { valueTextField.text = value } + } + var placeholderText: String { + get { return valueTextField.placeholder ?? "" } + set(value) { valueTextField.placeholder = value } + } + var isValueValid = true { + didSet { + if isValueValid { + keyLabel.textColor = .black + } else { + keyLabel.textColor = .red + } + } + } + var keyboardType: UIKeyboardType { + get { return valueTextField.keyboardType } + set(value) { valueTextField.keyboardType = value } + } + + var onValueChanged: ((String) -> Void)? + var onValueBeingEdited: ((String) -> Void)? + + let keyLabel: UILabel = { + let keyLabel = UILabel() + keyLabel.font = UIFont.preferredFont(forTextStyle: .body) + keyLabel.adjustsFontForContentSizeCategory = true + return keyLabel + }() + + let valueTextField: UITextField = { + let valueTextField = UITextField() + valueTextField.font = UIFont.preferredFont(forTextStyle: .body) + valueTextField.adjustsFontForContentSizeCategory = true + valueTextField.autocapitalizationType = .none + valueTextField.autocorrectionType = .no + valueTextField.spellCheckingType = .no + return valueTextField + }() + + var isStackedHorizontally = false + var isStackedVertically = false + var contentSizeBasedConstraints = [NSLayoutConstraint]() + + private var textFieldValueOnBeginEditing: String = "" + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(keyLabel) + keyLabel.translatesAutoresizingMaskIntoConstraints = false + keyLabel.textAlignment = .right + let widthRatioConstraint = NSLayoutConstraint(item: keyLabel, attribute: .width, + relatedBy: .equal, + toItem: self, attribute: .width, + multiplier: 0.4, constant: 0) + // The "Persistent Keepalive" key doesn't fit into 0.4 * width on the iPhone SE, + // so set a CR priority > the 0.4-constraint's priority. + widthRatioConstraint.priority = .defaultHigh + 1 + keyLabel.setContentCompressionResistancePriority(.defaultHigh + 2, for: .horizontal) + NSLayoutConstraint.activate([ + keyLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), + keyLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5), + widthRatioConstraint + ]) + + contentView.addSubview(valueTextField) + valueTextField.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + valueTextField.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor), + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: valueTextField.bottomAnchor, multiplier: 0.5) + ]) + valueTextField.delegate = self + + configureForContentSize() + } + + func configureForContentSize() { + var constraints = [NSLayoutConstraint]() + if self.traitCollection.preferredContentSizeCategory.isAccessibilityCategory { + // Stack vertically + if !isStackedVertically { + constraints = [ + valueTextField.topAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), + valueTextField.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), + keyLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor) + ] + isStackedVertically = true + isStackedHorizontally = false + } + } else { + // Stack horizontally + if !isStackedHorizontally { + constraints = [ + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), + valueTextField.leftAnchor.constraint(equalToSystemSpacingAfter: keyLabel.rightAnchor, multiplier: 1), + valueTextField.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) + ] + isStackedHorizontally = true + isStackedVertically = false + } + } + if !constraints.isEmpty { + NSLayoutConstraint.deactivate(self.contentSizeBasedConstraints) + NSLayoutConstraint.activate(constraints) + self.contentSizeBasedConstraints = constraints + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + key = "" + value = "" + placeholderText = "" + isValueValid = true + keyboardType = .default + onValueChanged = nil + onValueBeingEdited = nil + configureForContentSize() + } +} + +extension TunnelEditKeyValueCell: UITextFieldDelegate { + func textFieldDidBeginEditing(_ textField: UITextField) { + textFieldValueOnBeginEditing = textField.text ?? "" + isValueValid = true + } + func textFieldDidEndEditing(_ textField: UITextField) { + let isModified = (textField.text ?? "" != textFieldValueOnBeginEditing) + guard isModified else { return } + if let onValueChanged = onValueChanged { + onValueChanged(textField.text ?? "") + } + } + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if let onValueBeingEdited = onValueBeingEdited { + let modifiedText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string) + onValueBeingEdited(modifiedText) + } + return true + } +} diff --git a/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditReadOnlyKeyValueCell.swift b/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditReadOnlyKeyValueCell.swift new file mode 100644 index 0000000..48c8798 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditReadOnlyKeyValueCell.swift @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelEditReadOnlyKeyValueCell: CopyableLabelTableViewCell { + var key: String { + get { return keyLabel.text ?? "" } + set(value) {keyLabel.text = value } + } + var value: String { + get { return valueLabel.text } + set(value) { valueLabel.text = value } + } + + let keyLabel: UILabel + let valueLabel: ScrollableLabel + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + keyLabel = UILabel() + keyLabel.font = UIFont.preferredFont(forTextStyle: .body) + keyLabel.adjustsFontForContentSizeCategory = true + valueLabel = ScrollableLabel() + valueLabel.label.font = UIFont.preferredFont(forTextStyle: .body) + valueLabel.label.adjustsFontForContentSizeCategory = true + + super.init(style: style, reuseIdentifier: reuseIdentifier) + + keyLabel.textColor = UIColor.gray + valueLabel.textColor = UIColor.gray + + contentView.addSubview(keyLabel) + keyLabel.translatesAutoresizingMaskIntoConstraints = false + keyLabel.textAlignment = .right + let widthRatioConstraint = NSLayoutConstraint(item: keyLabel, attribute: .width, + relatedBy: .equal, + toItem: self, attribute: .width, + multiplier: 0.4, constant: 0) + // In case the key doesn't fit into 0.4 * width, + // so set a CR priority > the 0.4-constraint's priority. + widthRatioConstraint.priority = .defaultHigh + 1 + keyLabel.setContentCompressionResistancePriority(.defaultHigh + 2, for: .horizontal) + NSLayoutConstraint.activate([ + keyLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + keyLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), + widthRatioConstraint + ]) + + contentView.addSubview(valueLabel) + valueLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + valueLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + valueLabel.leftAnchor.constraint(equalToSystemSpacingAfter: keyLabel.rightAnchor, multiplier: 1), + valueLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor) + ]) + } + + override var textToCopy: String? { + return self.valueLabel.text + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + key = "" + value = "" + } +} diff --git a/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditSectionListCell.swift b/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditSectionListCell.swift new file mode 100644 index 0000000..ca0352e --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditSectionListCell.swift @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelEditSelectionListCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel!.text = value } + } + var isChecked: Bool { + didSet { + accessoryType = isChecked ? .checkmark : .none + } + } + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + isChecked = false + super.init(style: .default, reuseIdentifier: reuseIdentifier) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + message = "" + isChecked = false + } +} diff --git a/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditSwitchCell.swift b/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditSwitchCell.swift new file mode 100644 index 0000000..658fb95 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditSwitchCell.swift @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelEditSwitchCell: UITableViewCell { + var message: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel!.text = value } + } + var isOn: Bool { + get { return switchView.isOn } + set(value) { switchView.isOn = value } + } + var isEnabled: Bool { + get { return switchView.isEnabled } + set(value) { + switchView.isEnabled = value + textLabel?.textColor = value ? UIColor.black : UIColor.gray + } + } + + var onSwitchToggled: ((Bool) -> Void)? + + let switchView: UISwitch + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + switchView = UISwitch() + super.init(style: .default, reuseIdentifier: reuseIdentifier) + accessoryView = switchView + switchView.addTarget(self, action: #selector(switchToggled), for: .valueChanged) + } + + @objc func switchToggled() { + onSwitchToggled?(switchView.isOn) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + message = "" + isOn = false + } +} diff --git a/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditTableViewController.swift b/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditTableViewController.swift new file mode 100644 index 0000000..8d055d2 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/EditTunnel/TunnelEditTableViewController.swift @@ -0,0 +1,488 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +protocol TunnelEditTableViewControllerDelegate: class { + func tunnelSaved(tunnel: TunnelContainer) + func tunnelEditingCancelled() +} + +// MARK: TunnelEditTableViewController + +class TunnelEditTableViewController: UITableViewController { + + private enum Section { + case interface + case peer(_ peer: TunnelViewModel.PeerData) + case addPeer + case onDemand + } + + 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 activateOnDemandOptions: [ActivateOnDemandOption] = [ + .useOnDemandOverWiFiOrCellular, + .useOnDemandOverWiFiOnly, + .useOnDemandOverCellularOnly + ] + + let tunnelsManager: TunnelsManager + let tunnel: TunnelContainer? + let tunnelViewModel: TunnelViewModel + var activateOnDemandSetting: ActivateOnDemandSetting + private var sections = [Section]() + + init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) { + // Use this initializer to edit an existing tunnel. + self.tunnelsManager = tunnelsManager + self.tunnel = tunnel + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration()) + activateOnDemandSetting = tunnel.activateOnDemandSetting() + super.init(style: .grouped) + loadSections() + } + + init(tunnelsManager: TunnelsManager, tunnelConfiguration: TunnelConfiguration?) { + // Use this initializer to create a new tunnel. + // If tunnelConfiguration is passed, data will be prepopulated from that configuration. + self.tunnelsManager = tunnelsManager + tunnel = nil + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnelConfiguration) + activateOnDemandSetting = ActivateOnDemandSetting.defaultSetting + super.init(style: .grouped) + loadSections() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + self.title = tunnel == nil ? "New configuration" : "Edit configuration" + self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(saveTapped)) + self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped)) + + self.tableView.estimatedRowHeight = 44 + self.tableView.rowHeight = UITableView.automaticDimension + + self.tableView.register(TunnelEditKeyValueCell.self) + self.tableView.register(TunnelEditReadOnlyKeyValueCell.self) + self.tableView.register(TunnelEditButtonCell.self) + self.tableView.register(TunnelEditSwitchCell.self) + self.tableView.register(TunnelEditSelectionListCell.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() { + self.tableView.endEditing(false) + let tunnelSaveResult = tunnelViewModel.save() + switch tunnelSaveResult { + case .error(let errorMessage): + let erroringConfiguration = (tunnelViewModel.interfaceData.validatedConfiguration == nil) ? "Interface" : "Peer" + ErrorPresenter.showErrorAlert(title: "Invalid \(erroringConfiguration)", message: errorMessage, from: self) + self.tableView.reloadData() // Highlight erroring fields + case .saved(let tunnelConfiguration): + if let tunnel = tunnel { + // We're modifying an existing tunnel + tunnelsManager.modify(tunnel: tunnel, + tunnelConfiguration: tunnelConfiguration, + activateOnDemandSetting: activateOnDemandSetting) { [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, + activateOnDemandSetting: activateOnDemandSetting) { [weak self] result in + if let error = result.error { + ErrorPresenter.showErrorAlert(error: error, from: self) + } else { + let tunnel: TunnelContainer = result.value! + self?.dismiss(animated: true, completion: nil) + self?.delegate?.tunnelSaved(tunnel: tunnel) + } + } + } + } + } + + @objc func cancelTapped() { + dismiss(animated: true, completion: nil) + self.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 activateOnDemandSetting.isActivateOnDemandEnabled { + return 4 + } else { + return 1 + } + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch sections[section] { + case .interface: + return section == 0 ? "Interface" : nil + case .peer: + return "Peer" + case .addPeer: + return nil + case .onDemand: + return "On-Demand Activation" + } + } + + 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: TunnelEditButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.rawValue + cell.onTapped = { [weak self] in + guard let self = self else { return } + + self.tunnelViewModel.interfaceData[.privateKey] = Curve25519.generatePrivateKey().base64EncodedString() + 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: TunnelEditReadOnlyKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.rawValue + cell.value = tunnelViewModel.interfaceData[field] + return cell + } + + private func interfaceFieldKeyValueCell(for tableView: UITableView, at indexPath: IndexPath, with field: TunnelViewModel.InterfaceField) -> UITableViewCell { + let cell: TunnelEditKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.rawValue + + switch field { + case .name, .privateKey: + cell.placeholderText = "Required" + cell.keyboardType = .default + case .addresses, .dns: + cell.placeholderText = "Optional" + cell.keyboardType = .numbersAndPunctuation + case .listenPort, .mtu: + cell.placeholderText = "Automatic" + cell.keyboardType = .numberPad + case .publicKey, .generateKeyPair: + cell.keyboardType = .default + } + + 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.onValueChanged = nil + cell.onValueBeingEdited = { [weak self] value in + self?.tunnelViewModel.interfaceData[field] = value + } + } else { + cell.onValueChanged = { [weak self] value in + self?.tunnelViewModel.interfaceData[field] = value + } + cell.onValueBeingEdited = nil + } + // 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) + } + } + } else { + cell.onValueBeingEdited = nil + } + 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: TunnelEditButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.rawValue + cell.hasDestructiveAction = true + cell.onTapped = { [weak self, weak peerData] in + guard let peerData = peerData else { return } + guard let self = self else { return } + self.showConfirmationAlert(message: "Delete this peer?", buttonTitle: "Delete", from: cell) { [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) + 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: TunnelEditSwitchCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = field.rawValue + 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: TunnelEditKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.rawValue + + switch field { + case .publicKey: + cell.placeholderText = "Required" + cell.keyboardType = .default + case .preSharedKey, .endpoint: + cell.placeholderText = "Optional" + cell.keyboardType = .default + case .allowedIPs: + cell.placeholderText = "Optional" + cell.keyboardType = .numbersAndPunctuation + case .persistentKeepAlive: + cell.placeholderText = "Off" + cell.keyboardType = .numberPad + case .excludePrivateIPs, .deletePeer: + cell.keyboardType = .default + } + + cell.isValueValid = !peerData.fieldsWithError.contains(field) + cell.value = peerData[field] + + if field == .allowedIPs { + 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 { + if 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) + } + } + } + } + } else { + cell.onValueChanged = { [weak peerData] value in + peerData?[field] = value + } + } + + return cell + } + + private func addPeerCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: TunnelEditButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = "Add peer" + 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 { + if indexPath.row == 0 { + let cell: TunnelEditSwitchCell = tableView.dequeueReusableCell(for: indexPath) + cell.message = "Activate on demand" + cell.isOn = activateOnDemandSetting.isActivateOnDemandEnabled + cell.onSwitchToggled = { [weak self] isOn in + guard let self = self else { return } + let indexPaths = (1 ..< 4).map { IndexPath(row: $0, section: indexPath.section) } + if isOn { + self.activateOnDemandSetting.isActivateOnDemandEnabled = true + if self.activateOnDemandSetting.activateOnDemandOption == .none { + self.activateOnDemandSetting.activateOnDemandOption = TunnelViewModel.defaultActivateOnDemandOption() + } + self.loadSections() + self.tableView.insertRows(at: indexPaths, with: .fade) + } else { + self.activateOnDemandSetting.isActivateOnDemandEnabled = false + self.loadSections() + self.tableView.deleteRows(at: indexPaths, with: .fade) + } + } + return cell + } else { + let cell: TunnelEditSelectionListCell = tableView.dequeueReusableCell(for: indexPath) + let rowOption = activateOnDemandOptions[indexPath.row - 1] + let selectedOption = activateOnDemandSetting.activateOnDemandOption + assert(selectedOption != .none) + cell.message = TunnelViewModel.activateOnDemandOptionText(for: rowOption) + cell.isChecked = (selectedOption == rowOption) + 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) + } + + func showConfirmationAlert(message: String, buttonTitle: String, from sourceView: UIView, onConfirmed: @escaping (() -> Void)) { + let destroyAction = UIAlertAction(title: buttonTitle, style: .destructive) { _ in + onConfirmed() + } + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) + let alert = UIAlertController(title: "", message: message, preferredStyle: .actionSheet) + alert.addAction(destroyAction) + alert.addAction(cancelAction) + + // popoverPresentationController will be nil on iPhone and non-nil on iPad + alert.popoverPresentationController?.sourceView = sourceView + alert.popoverPresentationController?.sourceRect = sourceView.bounds + + self.present(alert, animated: true, completion: nil) + } +} + +// MARK: UITableViewDelegate + +extension TunnelEditTableViewController { + override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { + if case .onDemand = sections[indexPath.section], indexPath.row > 0 { + return indexPath + } else { + return nil + } + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + switch sections[indexPath.section] { + case .onDemand: + let option = activateOnDemandOptions[indexPath.row - 1] + assert(option != .none) + activateOnDemandSetting.activateOnDemandOption = option + + let indexPaths = (1 ..< 4).map { IndexPath(row: $0, section: indexPath.section) } + UIView.performWithoutAnimation { + tableView.reloadRows(at: indexPaths, with: .none) + } + default: + assertionFailure() + } + } +} diff --git a/WireGuard/WireGuard/UI/iOS/ErrorPresenter.swift b/WireGuard/WireGuard/UI/iOS/ErrorPresenter.swift index ccbd00d..6d57006 100644 --- a/WireGuard/WireGuard/UI/iOS/ErrorPresenter.swift +++ b/WireGuard/WireGuard/UI/iOS/ErrorPresenter.swift @@ -8,7 +8,7 @@ class ErrorPresenter { static func showErrorAlert(error: WireGuardAppError, from sourceVC: UIViewController?, onPresented: (() -> Void)? = nil, onDismissal: (() -> Void)? = nil) { guard let sourceVC = sourceVC else { return } - let (title, message) = error.alertText() + let (title, message) = error.alertText let okAction = UIAlertAction(title: "OK", style: .default) { _ in onDismissal?() } diff --git a/WireGuard/WireGuard/UI/iOS/QRScanViewController.swift b/WireGuard/WireGuard/UI/iOS/QRScanViewController.swift index ad0fe79..a03b709 100644 --- a/WireGuard/WireGuard/UI/iOS/QRScanViewController.swift +++ b/WireGuard/WireGuard/UI/iOS/QRScanViewController.swift @@ -24,7 +24,7 @@ class QRScanViewController: UIViewController { let tipLabel = UILabel() tipLabel.text = "Tip: Generate with `qrencode -t ansiutf8 < tunnel.conf`" tipLabel.adjustsFontSizeToFitWidth = true - tipLabel.textColor = UIColor.lightGray + tipLabel.textColor = .lightGray tipLabel.textAlignment = .center view.addSubview(tipLabel) diff --git a/WireGuard/WireGuard/UI/iOS/ScrollableLabel.swift b/WireGuard/WireGuard/UI/iOS/ScrollableLabel.swift index f2d0f58..bd6f547 100644 --- a/WireGuard/WireGuard/UI/iOS/ScrollableLabel.swift +++ b/WireGuard/WireGuard/UI/iOS/ScrollableLabel.swift @@ -13,33 +13,31 @@ class ScrollableLabel: UIScrollView { set(value) { label.textColor = value } } - let label: UILabel - - init() { + let label: UILabel = { let label = UILabel() label.translatesAutoresizingMaskIntoConstraints = false label.textAlignment = .right - self.label = label + return label + }() + init() { super.init(frame: CGRect.zero) - self.isDirectionalLockEnabled = true - self.showsHorizontalScrollIndicator = false - self.showsVerticalScrollIndicator = false + isDirectionalLockEnabled = true + showsHorizontalScrollIndicator = false + showsVerticalScrollIndicator = false addSubview(label) label.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - label.leftAnchor.constraint(equalTo: self.contentLayoutGuide.leftAnchor), - label.topAnchor.constraint(equalTo: self.contentLayoutGuide.topAnchor), - label.bottomAnchor.constraint(equalTo: self.contentLayoutGuide.bottomAnchor), - label.rightAnchor.constraint(equalTo: self.contentLayoutGuide.rightAnchor), - label.heightAnchor.constraint(equalTo: self.heightAnchor) + label.leftAnchor.constraint(equalTo: contentLayoutGuide.leftAnchor), + label.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor), + label.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor), + label.rightAnchor.constraint(equalTo: contentLayoutGuide.rightAnchor), + label.heightAnchor.constraint(equalTo: heightAnchor) ]) - // If label has less content, it should expand to fit the scrollView, - // so that right-alignment works in the label. - let expandToFitValueLabelConstraint = NSLayoutConstraint(item: label, attribute: .width, relatedBy: .equal, - toItem: self, attribute: .width, multiplier: 1, constant: 0) + + let expandToFitValueLabelConstraint = NSLayoutConstraint(item: label, attribute: .width, relatedBy: .equal, toItem: self, attribute: .width, multiplier: 1, constant: 0) expandToFitValueLabelConstraint.priority = .defaultLow + 1 expandToFitValueLabelConstraint.isActive = true } diff --git a/WireGuard/WireGuard/UI/iOS/Settings/SettingsButtonCell.swift b/WireGuard/WireGuard/UI/iOS/Settings/SettingsButtonCell.swift new file mode 100644 index 0000000..d795ab4 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/Settings/SettingsButtonCell.swift @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class SettingsButtonCell: UITableViewCell { + var buttonText: String { + get { return button.title(for: .normal) ?? "" } + set(value) { button.setTitle(value, for: .normal) } + } + var onTapped: (() -> Void)? + + let button: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) + button.titleLabel?.adjustsFontForContentSizeCategory = true + return button + }() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: button.bottomAnchor), + button.centerXAnchor.constraint(equalTo: contentView.centerXAnchor) + ]) + + button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + } + + @objc func buttonTapped() { + onTapped?() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + buttonText = "" + onTapped = nil + } +} diff --git a/WireGuard/WireGuard/UI/iOS/Settings/SettingsKeyValueCell.swift b/WireGuard/WireGuard/UI/iOS/Settings/SettingsKeyValueCell.swift new file mode 100644 index 0000000..532f1d1 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/Settings/SettingsKeyValueCell.swift @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class SettingsKeyValueCell: UITableViewCell { + var key: String { + get { return textLabel?.text ?? "" } + set(value) { textLabel?.text = value } + } + var value: String { + get { return detailTextLabel?.text ?? "" } + set(value) { detailTextLabel?.text = value } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .value1, reuseIdentifier: SettingsKeyValueCell.reuseIdentifier) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + key = "" + value = "" + } +} diff --git a/WireGuard/WireGuard/UI/iOS/Settings/SettingsTableViewController.swift b/WireGuard/WireGuard/UI/iOS/Settings/SettingsTableViewController.swift new file mode 100644 index 0000000..c87d452 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/Settings/SettingsTableViewController.swift @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit +import os.log + +class SettingsTableViewController: UITableViewController { + + enum SettingsFields: String { + case iosAppVersion = "WireGuard for iOS" + case goBackendVersion = "WireGuard Go Backend" + case exportZipArchive = "Export zip archive" + case exportLogFile = "Export log file" + } + + let settingsFieldsBySection: [[SettingsFields]] = [ + [.iosAppVersion, .goBackendVersion], + [.exportZipArchive], + [.exportLogFile] + ] + + 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 = "Settings" + navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(doneTapped)) + + tableView.estimatedRowHeight = 44 + tableView.rowHeight = UITableView.automaticDimension + tableView.allowsSelection = false + + tableView.register(SettingsKeyValueCell.self) + tableView.register(SettingsButtonCell.self) + + tableView.tableFooterView = UIImageView(image: UIImage(named: "wireguard.pdf")) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + guard let logo = self.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) { + guard let tunnelsManager = 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 exportLogForLastActivatedTunnel(sourceView: UIView) { + 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: "Log export failed", message: "The pre-existing log could not be cleared", from: self) + return + } + } + + guard let networkExtensionLogFilePath = FileManager.networkExtensionLogFileURL?.path else { + ErrorPresenter.showErrorAlert(title: "Log export failed", message: "Unable to determine extension log path", from: self) + return + } + + let isWritten = Logger.global?.writeLog(mergedWith: networkExtensionLogFilePath, to: destinationURL.path) ?? false + guard isWritten else { + ErrorPresenter.showErrorAlert(title: "Log export failed", message: "Unable to write logs to file", from: self) + return + } + + DispatchQueue.main.async { + let activityVC = UIActivityViewController(activityItems: [destinationURL], applicationActivities: nil) + // popoverPresentationController shall be non-nil on the iPad + activityVC.popoverPresentationController?.sourceView = sourceView + activityVC.popoverPresentationController?.sourceRect = sourceView.bounds + activityVC.completionWithItemsHandler = { _, _, _, _ in + // Remove the exported log file after the activity has completed + _ = FileManager.deleteFile(at: destinationURL) + } + self.present(activityVC, animated: true) + } + } + } +} + +// MARK: UITableViewDataSource + +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 "About" + case 1: + return "Export configurations" + case 2: + return "Tunnel log" + 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: SettingsKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.rawValue + if field == .iosAppVersion { + var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown version" + if let appBuild = Bundle.main.infoDictionary?["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: SettingsButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.rawValue + cell.onTapped = { [weak self] in + self?.exportConfigurationsAsZipFile(sourceView: cell.button) + } + return cell + } else { + assert(field == .exportLogFile) + let cell: SettingsButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = field.rawValue + cell.onTapped = { [weak self] in + self?.exportLogForLastActivatedTunnel(sourceView: cell.button) + } + return cell + } + } +} diff --git a/WireGuard/WireGuard/UI/iOS/SettingsTableViewController.swift b/WireGuard/WireGuard/UI/iOS/SettingsTableViewController.swift deleted file mode 100644 index af9893d..0000000 --- a/WireGuard/WireGuard/UI/iOS/SettingsTableViewController.swift +++ /dev/null @@ -1,263 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit -import os.log - -class SettingsTableViewController: UITableViewController { - - enum SettingsFields: String { - case iosAppVersion = "WireGuard for iOS" - case goBackendVersion = "WireGuard Go Backend" - case exportZipArchive = "Export zip archive" - case exportLogFile = "Export log file" - } - - let settingsFieldsBySection: [[SettingsFields]] = [ - [.iosAppVersion, .goBackendVersion], - [.exportZipArchive], - [.exportLogFile] - ] - - 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 = "Settings" - 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 = self.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) { - guard let tunnelsManager = 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 exportLogForLastActivatedTunnel(sourceView: UIView) { - 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: "Log export failed", message: "The pre-existing log could not be cleared", from: self) - return - } - } - - guard let networkExtensionLogFilePath = FileManager.networkExtensionLogFileURL?.path else { - ErrorPresenter.showErrorAlert(title: "Log export failed", message: "Unable to determine extension log path", from: self) - return - } - - let isWritten = Logger.global?.writeLog(mergedWith: networkExtensionLogFilePath, to: destinationURL.path) ?? false - guard isWritten else { - ErrorPresenter.showErrorAlert(title: "Log export failed", message: "Unable to write logs to file", from: self) - return - } - - DispatchQueue.main.async { - let activityVC = UIActivityViewController(activityItems: [destinationURL], applicationActivities: nil) - // popoverPresentationController shall be non-nil on the iPad - activityVC.popoverPresentationController?.sourceView = sourceView - activityVC.popoverPresentationController?.sourceRect = sourceView.bounds - activityVC.completionWithItemsHandler = { _, _, _, _ in - // Remove the exported log file after the activity has completed - _ = FileManager.deleteFile(at: destinationURL) - } - self.present(activityVC, animated: true) - } - } - } -} - -// MARK: UITableViewDataSource - -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 "About" - case 1: - return "Export configurations" - case 2: - return "Tunnel log" - 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.key = field.rawValue - if field == .iosAppVersion { - var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown version" - if let appBuild = Bundle.main.infoDictionary?["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.rawValue - cell.onTapped = { [weak self] in - self?.exportConfigurationsAsZipFile(sourceView: cell.button) - } - return cell - } else { - assert(field == .exportLogFile) - let cell: ButtonCell = tableView.dequeueReusableCell(for: indexPath) - cell.buttonText = field.rawValue - cell.onTapped = { [weak self] in - self?.exportLogForLastActivatedTunnel(sourceView: cell.button) - } - return cell - } - } -} - -private class KeyValueCell: UITableViewCell { - var key: String { - get { return textLabel?.text ?? "" } - set(value) { textLabel?.text = value } - } - var value: String { - get { return detailTextLabel?.text ?? "" } - set(value) { detailTextLabel?.text = value } - } - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: .value1, reuseIdentifier: KeyValueCell.reuseIdentifier) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - key = "" - value = "" - } -} - -private class ButtonCell: UITableViewCell { - var buttonText: String { - get { return button.title(for: .normal) ?? "" } - set(value) { button.setTitle(value, for: .normal) } - } - var onTapped: (() -> Void)? - - let button: UIButton - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - button = UIButton(type: .system) - button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) - button.titleLabel?.adjustsFontForContentSizeCategory = true - super.init(style: style, reuseIdentifier: reuseIdentifier) - contentView.addSubview(button) - button.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - button.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), - contentView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: button.bottomAnchor), - button.centerXAnchor.constraint(equalTo: contentView.centerXAnchor) - ]) - button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) - } - - @objc func buttonTapped() { - onTapped?() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - buttonText = "" - onTapped = nil - } -} diff --git a/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailActivateOnDemandCell.swift b/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailActivateOnDemandCell.swift new file mode 100644 index 0000000..9507c45 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailActivateOnDemandCell.swift @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelDetailActivateOnDemandCell: UITableViewCell { + var tunnel: TunnelContainer? { + didSet(value) { + update(from: tunnel?.activateOnDemandSetting()) + onDemandStatusObservervationToken = tunnel?.observe(\.isActivateOnDemandEnabled) { [weak self] tunnel, _ in + self?.update(from: tunnel.activateOnDemandSetting()) + } + } + } + + var onDemandStatusObservervationToken: AnyObject? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: .value1, reuseIdentifier: reuseIdentifier) + textLabel?.text = "Activate on demand" + textLabel?.font = UIFont.preferredFont(forTextStyle: .body) + textLabel?.adjustsFontForContentSizeCategory = true + detailTextLabel?.font = UIFont.preferredFont(forTextStyle: .body) + detailTextLabel?.adjustsFontForContentSizeCategory = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(from activateOnDemandSetting: ActivateOnDemandSetting?) { + detailTextLabel?.text = TunnelViewModel.activateOnDemandDetailText(for: activateOnDemandSetting) + } + + override func prepareForReuse() { + super.prepareForReuse() + textLabel?.text = "Activate on demand" + detailTextLabel?.text = "" + } +} diff --git a/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailButtonCell.swift b/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailButtonCell.swift new file mode 100644 index 0000000..8710616 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailButtonCell.swift @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelDetailButtonCell: UITableViewCell { + var buttonText: String { + get { return button.title(for: .normal) ?? "" } + set(value) { button.setTitle(value, for: .normal) } + } + var hasDestructiveAction: Bool { + get { return button.tintColor == UIColor.red } + set(value) { button.tintColor = value ? UIColor.red : buttonStandardTintColor } + } + var onTapped: (() -> Void)? + + let button: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) + button.titleLabel?.adjustsFontForContentSizeCategory = true + return button + }() + + var buttonStandardTintColor: UIColor + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + buttonStandardTintColor = button.tintColor + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(button) + button.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + button.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: button.bottomAnchor), + button.centerXAnchor.constraint(equalTo: contentView.centerXAnchor) + ]) + + button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) + } + + @objc func buttonTapped() { + onTapped?() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + buttonText = "" + onTapped = nil + hasDestructiveAction = false + } +} diff --git a/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailKeyValueCell.swift b/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailKeyValueCell.swift new file mode 100644 index 0000000..cbe1c14 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailKeyValueCell.swift @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelDetailKeyValueCell: CopyableLabelTableViewCell { + var key: String { + get { return keyLabel.text ?? "" } + set(value) { keyLabel.text = value } + } + var value: String { + get { return valueLabel.text } + set(value) { valueLabel.text = value } + } + + override var textToCopy: String? { + return self.valueLabel.text + } + + let keyLabel: UILabel = { + let keyLabel = UILabel() + keyLabel.font = UIFont.preferredFont(forTextStyle: .body) + keyLabel.adjustsFontForContentSizeCategory = true + keyLabel.textColor = .black + return keyLabel + }() + + let valueLabel: ScrollableLabel = { + let valueLabel = ScrollableLabel() + valueLabel.label.font = UIFont.preferredFont(forTextStyle: .body) + valueLabel.label.adjustsFontForContentSizeCategory = true + valueLabel.textColor = .gray + return valueLabel + }() + + var isStackedHorizontally = false + var isStackedVertically = false + var contentSizeBasedConstraints = [NSLayoutConstraint]() + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + + contentView.addSubview(keyLabel) + keyLabel.translatesAutoresizingMaskIntoConstraints = false + keyLabel.textAlignment = .left + NSLayoutConstraint.activate([ + keyLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), + keyLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) + ]) + + contentView.addSubview(valueLabel) + valueLabel.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + valueLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor), + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: valueLabel.bottomAnchor, multiplier: 0.5) + ]) + + keyLabel.setContentCompressionResistancePriority(.defaultHigh + 1, for: .horizontal) + keyLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + valueLabel.setContentHuggingPriority(.defaultLow, for: .horizontal) + + configureForContentSize() + } + + func configureForContentSize() { + var constraints = [NSLayoutConstraint]() + if self.traitCollection.preferredContentSizeCategory.isAccessibilityCategory { + // Stack vertically + if !isStackedVertically { + constraints = [ + valueLabel.topAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), + valueLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), + keyLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor) + ] + isStackedVertically = true + isStackedHorizontally = false + } + } else { + // Stack horizontally + if !isStackedHorizontally { + constraints = [ + contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), + valueLabel.leftAnchor.constraint(equalToSystemSpacingAfter: keyLabel.rightAnchor, multiplier: 1), + valueLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) + ] + isStackedHorizontally = true + isStackedVertically = false + } + } + if !constraints.isEmpty { + NSLayoutConstraint.deactivate(self.contentSizeBasedConstraints) + NSLayoutConstraint.activate(constraints) + self.contentSizeBasedConstraints = constraints + } + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + key = "" + value = "" + configureForContentSize() + } +} diff --git a/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailStatusCell.swift b/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailStatusCell.swift new file mode 100644 index 0000000..855e3ed --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailStatusCell.swift @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class TunnelDetailStatusCell: UITableViewCell { + var tunnel: TunnelContainer? { + didSet(value) { + update(from: tunnel?.status) + statusObservervationToken = tunnel?.observe(\.status) { [weak self] tunnel, _ in + self?.update(from: tunnel.status) + } + } + } + var isSwitchInteractionEnabled: Bool { + get { return statusSwitch.isUserInteractionEnabled } + set(value) { statusSwitch.isUserInteractionEnabled = value } + } + var onSwitchToggled: ((Bool) -> Void)? + private var isOnSwitchToggledHandlerEnabled = true + + let statusSwitch: UISwitch + private var statusObservervationToken: AnyObject? + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + statusSwitch = UISwitch() + super.init(style: .default, reuseIdentifier: TunnelDetailKeyValueCell.reuseIdentifier) + accessoryView = statusSwitch + + statusSwitch.addTarget(self, action: #selector(switchToggled), for: .valueChanged) + } + + @objc func switchToggled() { + if isOnSwitchToggledHandlerEnabled { + onSwitchToggled?(statusSwitch.isOn) + } + } + + private func update(from status: TunnelStatus?) { + guard let status = status else { + reset() + return + } + let text: String + switch status { + case .inactive: + text = "Inactive" + case .activating: + text = "Activating" + case .active: + text = "Active" + case .deactivating: + text = "Deactivating" + case .reasserting: + text = "Reactivating" + case .restarting: + text = "Restarting" + case .waiting: + text = "Waiting" + } + textLabel?.text = text + DispatchQueue.main.async { [weak statusSwitch] in + guard let statusSwitch = statusSwitch else { return } + statusSwitch.isOn = !(status == .deactivating || status == .inactive) + statusSwitch.isUserInteractionEnabled = (status == .inactive || status == .active) + } + textLabel?.textColor = (status == .active || status == .inactive) ? UIColor.black : UIColor.gray + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func reset() { + textLabel?.text = "Invalid" + statusSwitch.isOn = false + textLabel?.textColor = UIColor.gray + statusSwitch.isUserInteractionEnabled = false + } + + override func prepareForReuse() { + super.prepareForReuse() + reset() + } +} diff --git a/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailTableViewController.swift b/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailTableViewController.swift new file mode 100644 index 0000000..af4cf83 --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/TunnelDetail/TunnelDetailTableViewController.swift @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +// MARK: TunnelDetailTableViewController + +class TunnelDetailTableViewController: UITableViewController { + + private enum Section { + case status + case interface + case peer(_ peer: TunnelViewModel.PeerData) + case onDemand + case delete + } + + let interfaceFields: [TunnelViewModel.InterfaceField] = [ + .name, .publicKey, .addresses, + .listenPort, .mtu, .dns + ] + + let peerFields: [TunnelViewModel.PeerField] = [ + .publicKey, .preSharedKey, .endpoint, + .allowedIPs, .persistentKeepAlive + ] + + let tunnelsManager: TunnelsManager + let tunnel: TunnelContainer + var tunnelViewModel: TunnelViewModel + private var sections = [Section]() + + init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) { + self.tunnelsManager = tunnelsManager + self.tunnel = tunnel + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration()) + super.init(style: .grouped) + loadSections() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + self.title = tunnelViewModel.interfaceData[.name] + self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .edit, target: self, action: #selector(editTapped)) + + self.tableView.estimatedRowHeight = 44 + self.tableView.rowHeight = UITableView.automaticDimension + self.tableView.allowsSelection = false + self.tableView.register(TunnelDetailStatusCell.self) + self.tableView.register(TunnelDetailKeyValueCell.self) + self.tableView.register(TunnelDetailButtonCell.self) + self.tableView.register(TunnelDetailActivateOnDemandCell.self) + + // State restoration + self.restorationIdentifier = "TunnelDetailVC:\(tunnel.name)" + } + + private func loadSections() { + sections.removeAll() + sections.append(.status) + sections.append(.interface) + tunnelViewModel.peersData.forEach { sections.append(.peer($0)) } + sections.append(.onDemand) + sections.append(.delete) + } + + @objc func editTapped() { + let editVC = TunnelEditTableViewController(tunnelsManager: tunnelsManager, tunnel: tunnel) + editVC.delegate = self + let editNC = UINavigationController(rootViewController: editVC) + editNC.modalPresentationStyle = .formSheet + present(editNC, animated: true) + } + + func showConfirmationAlert(message: String, buttonTitle: String, from sourceView: UIView, + onConfirmed: @escaping (() -> Void)) { + let destroyAction = UIAlertAction(title: buttonTitle, style: .destructive) { _ in + onConfirmed() + } + let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) + let alert = UIAlertController(title: "", message: message, preferredStyle: .actionSheet) + alert.addAction(destroyAction) + alert.addAction(cancelAction) + + // popoverPresentationController will be nil on iPhone and non-nil on iPad + alert.popoverPresentationController?.sourceView = sourceView + alert.popoverPresentationController?.sourceRect = sourceView.bounds + + self.present(alert, animated: true, completion: nil) + } +} + +// MARK: TunnelEditTableViewControllerDelegate + +extension TunnelDetailTableViewController: TunnelEditTableViewControllerDelegate { + func tunnelSaved(tunnel: TunnelContainer) { + tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration()) + loadSections() + self.title = tunnel.name + self.tableView.reloadData() + } + func tunnelEditingCancelled() { + // Nothing to do + } +} + +// MARK: UITableViewDataSource + +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 tunnelViewModel.interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFields).count + case .peer(let peerData): + return peerData.filterFieldsWithValueOrControl(peerFields: peerFields).count + case .onDemand: + return 1 + case .delete: + return 1 + } + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + switch sections[section] { + case .status: + return "Status" + case .interface: + return "Interface" + case .peer: + return "Peer" + case .onDemand: + return "On-Demand Activation" + 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 peer): + return peerCell(for: tableView, at: indexPath, with: peer) + 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: TunnelDetailStatusCell = tableView.dequeueReusableCell(for: indexPath) + cell.tunnel = self.tunnel + cell.onSwitchToggled = { [weak self] isOn in + guard let self = self else { return } + 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 field = tunnelViewModel.interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFields)[indexPath.row] + let cell: TunnelDetailKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.rawValue + cell.value = tunnelViewModel.interfaceData[field] + return cell + } + + private func peerCell(for tableView: UITableView, at indexPath: IndexPath, with peerData: TunnelViewModel.PeerData) -> UITableViewCell { + let field = peerData.filterFieldsWithValueOrControl(peerFields: peerFields)[indexPath.row] + let cell: TunnelDetailKeyValueCell = tableView.dequeueReusableCell(for: indexPath) + cell.key = field.rawValue + cell.value = peerData[field] + return cell + } + + private func onDemandCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: TunnelDetailActivateOnDemandCell = tableView.dequeueReusableCell(for: indexPath) + cell.tunnel = self.tunnel + return cell + } + + private func deleteConfigurationCell(for tableView: UITableView, at indexPath: IndexPath) -> UITableViewCell { + let cell: TunnelDetailButtonCell = tableView.dequeueReusableCell(for: indexPath) + cell.buttonText = "Delete tunnel" + cell.hasDestructiveAction = true + cell.onTapped = { [weak self] in + guard let self = self else { return } + self.showConfirmationAlert(message: "Delete this tunnel?", buttonTitle: "Delete", from: cell) { [weak self] in + guard let tunnelsManager = self?.tunnelsManager, let tunnel = self?.tunnel else { return } + tunnelsManager.remove(tunnel: tunnel) { error in + if error != nil { + print("Error removing tunnel: \(String(describing: error))") + return + } + } + self?.navigationController?.navigationController?.popToRootViewController(animated: true) + } + } + return cell + } + +} diff --git a/WireGuard/WireGuard/UI/iOS/TunnelEditTableViewController.swift b/WireGuard/WireGuard/UI/iOS/TunnelEditTableViewController.swift deleted file mode 100644 index 0386b0a..0000000 --- a/WireGuard/WireGuard/UI/iOS/TunnelEditTableViewController.swift +++ /dev/null @@ -1,818 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit - -protocol TunnelEditTableViewControllerDelegate: class { - func tunnelSaved(tunnel: TunnelContainer) - func tunnelEditingCancelled() -} - -// MARK: TunnelEditTableViewController - -class TunnelEditTableViewController: UITableViewController { - - private enum Section { - case interface - case peer(_ peer: TunnelViewModel.PeerData) - case addPeer - case onDemand - } - - 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 activateOnDemandOptions: [ActivateOnDemandOption] = [ - .useOnDemandOverWiFiOrCellular, - .useOnDemandOverWiFiOnly, - .useOnDemandOverCellularOnly - ] - - let tunnelsManager: TunnelsManager - let tunnel: TunnelContainer? - let tunnelViewModel: TunnelViewModel - var activateOnDemandSetting: ActivateOnDemandSetting - private var sections = [Section]() - - init(tunnelsManager: TunnelsManager, tunnel: TunnelContainer) { - // Use this initializer to edit an existing tunnel. - self.tunnelsManager = tunnelsManager - self.tunnel = tunnel - tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnel.tunnelConfiguration()) - activateOnDemandSetting = tunnel.activateOnDemandSetting() - super.init(style: .grouped) - loadSections() - } - - init(tunnelsManager: TunnelsManager, tunnelConfiguration: TunnelConfiguration?) { - // Use this initializer to create a new tunnel. - // If tunnelConfiguration is passed, data will be prepopulated from that configuration. - self.tunnelsManager = tunnelsManager - tunnel = nil - tunnelViewModel = TunnelViewModel(tunnelConfiguration: tunnelConfiguration) - activateOnDemandSetting = ActivateOnDemandSetting.defaultSetting - super.init(style: .grouped) - loadSections() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - self.title = tunnel == nil ? "New configuration" : "Edit configuration" - self.navigationItem.rightBarButtonItem = UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(saveTapped)) - self.navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelTapped)) - - self.tableView.estimatedRowHeight = 44 - self.tableView.rowHeight = UITableView.automaticDimension - - self.tableView.register(KeyValueCell.self) - self.tableView.register(ReadOnlyKeyValueCell.self) - self.tableView.register(ButtonCell.self) - self.tableView.register(SwitchCell.self) - self.tableView.register(SelectionListCell.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() { - self.tableView.endEditing(false) - let tunnelSaveResult = tunnelViewModel.save() - switch tunnelSaveResult { - case .error(let errorMessage): - let erroringConfiguration = (tunnelViewModel.interfaceData.validatedConfiguration == nil) ? "Interface" : "Peer" - ErrorPresenter.showErrorAlert(title: "Invalid \(erroringConfiguration)", message: errorMessage, from: self) - self.tableView.reloadData() // Highlight erroring fields - case .saved(let tunnelConfiguration): - if let tunnel = tunnel { - // We're modifying an existing tunnel - tunnelsManager.modify(tunnel: tunnel, - tunnelConfiguration: tunnelConfiguration, - activateOnDemandSetting: activateOnDemandSetting) { [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, - activateOnDemandSetting: activateOnDemandSetting) { [weak self] result in - if let error = result.error { - ErrorPresenter.showErrorAlert(error: error, from: self) - } else { - let tunnel: TunnelContainer = result.value! - self?.dismiss(animated: true, completion: nil) - self?.delegate?.tunnelSaved(tunnel: tunnel) - } - } - } - } - } - - @objc func cancelTapped() { - dismiss(animated: true, completion: nil) - self.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 activateOnDemandSetting.isActivateOnDemandEnabled { - return 4 - } else { - return 1 - } - } - } - - override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - switch sections[section] { - case .interface: - return section == 0 ? "Interface" : nil - case .peer: - return "Peer" - case .addPeer: - return nil - case .onDemand: - return "On-Demand Activation" - } - } - - 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.rawValue - cell.onTapped = { [weak self] in - guard let self = self else { return } - - self.tunnelViewModel.interfaceData[.privateKey] = Curve25519.generatePrivateKey().base64EncodedString() - 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: ReadOnlyKeyValueCell = tableView.dequeueReusableCell(for: indexPath) - cell.key = field.rawValue - cell.value = tunnelViewModel.interfaceData[field] - return cell - } - - private func interfaceFieldKeyValueCell(for tableView: UITableView, at indexPath: IndexPath, with field: TunnelViewModel.InterfaceField) -> UITableViewCell { - let cell: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) - cell.key = field.rawValue - - switch field { - case .name, .privateKey: - cell.placeholderText = "Required" - cell.keyboardType = .default - case .addresses, .dns: - cell.placeholderText = "Optional" - cell.keyboardType = .numbersAndPunctuation - case .listenPort, .mtu: - cell.placeholderText = "Automatic" - cell.keyboardType = .numberPad - case .publicKey, .generateKeyPair: - cell.keyboardType = .default - } - - 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.onValueChanged = nil - cell.onValueBeingEdited = { [weak self] value in - self?.tunnelViewModel.interfaceData[field] = value - } - } else { - cell.onValueChanged = { [weak self] value in - self?.tunnelViewModel.interfaceData[field] = value - } - cell.onValueBeingEdited = nil - } - // 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) - } - } - } else { - cell.onValueBeingEdited = nil - } - 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.rawValue - cell.hasDestructiveAction = true - cell.onTapped = { [weak self, weak peerData] in - guard let peerData = peerData else { return } - guard let self = self else { return } - self.showConfirmationAlert(message: "Delete this peer?", buttonTitle: "Delete", from: cell) { [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) - 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.rawValue - 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: KeyValueCell = tableView.dequeueReusableCell(for: indexPath) - cell.key = field.rawValue - - switch field { - case .publicKey: - cell.placeholderText = "Required" - cell.keyboardType = .default - case .preSharedKey, .endpoint: - cell.placeholderText = "Optional" - cell.keyboardType = .default - case .allowedIPs: - cell.placeholderText = "Optional" - cell.keyboardType = .numbersAndPunctuation - case .persistentKeepAlive: - cell.placeholderText = "Off" - cell.keyboardType = .numberPad - case .excludePrivateIPs, .deletePeer: - cell.keyboardType = .default - } - - cell.isValueValid = !peerData.fieldsWithError.contains(field) - cell.value = peerData[field] - - if field == .allowedIPs { - 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 { - if 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) - } - } - } - } - } 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 = "Add peer" - 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 { - if indexPath.row == 0 { - let cell: SwitchCell = tableView.dequeueReusableCell(for: indexPath) - cell.message = "Activate on demand" - cell.isOn = activateOnDemandSetting.isActivateOnDemandEnabled - cell.onSwitchToggled = { [weak self] isOn in - guard let self = self else { return } - let indexPaths = (1 ..< 4).map { IndexPath(row: $0, section: indexPath.section) } - if isOn { - self.activateOnDemandSetting.isActivateOnDemandEnabled = true - if self.activateOnDemandSetting.activateOnDemandOption == .none { - self.activateOnDemandSetting.activateOnDemandOption = TunnelViewModel.defaultActivateOnDemandOption() - } - self.loadSections() - self.tableView.insertRows(at: indexPaths, with: .fade) - } else { - self.activateOnDemandSetting.isActivateOnDemandEnabled = false - self.loadSections() - self.tableView.deleteRows(at: indexPaths, with: .fade) - } - } - return cell - } else { - let cell: SelectionListCell = tableView.dequeueReusableCell(for: indexPath) - let rowOption = activateOnDemandOptions[indexPath.row - 1] - let selectedOption = activateOnDemandSetting.activateOnDemandOption - assert(selectedOption != .none) - cell.message = TunnelViewModel.activateOnDemandOptionText(for: rowOption) - cell.isChecked = (selectedOption == rowOption) - 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) - } - - func showConfirmationAlert(message: String, buttonTitle: String, from sourceView: UIView, onConfirmed: @escaping (() -> Void)) { - let destroyAction = UIAlertAction(title: buttonTitle, style: .destructive) { _ in - onConfirmed() - } - let cancelAction = UIAlertAction(title: "Cancel", style: .cancel) - let alert = UIAlertController(title: "", message: message, preferredStyle: .actionSheet) - alert.addAction(destroyAction) - alert.addAction(cancelAction) - - // popoverPresentationController will be nil on iPhone and non-nil on iPad - alert.popoverPresentationController?.sourceView = sourceView - alert.popoverPresentationController?.sourceRect = sourceView.bounds - - self.present(alert, animated: true, completion: nil) - } -} - -// MARK: UITableViewDelegate - -extension TunnelEditTableViewController { - override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? { - if case .onDemand = sections[indexPath.section], indexPath.row > 0 { - return indexPath - } else { - return nil - } - } - - override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - switch sections[indexPath.section] { - case .onDemand: - let option = activateOnDemandOptions[indexPath.row - 1] - assert(option != .none) - activateOnDemandSetting.activateOnDemandOption = option - - let indexPaths = (1 ..< 4).map { IndexPath(row: $0, section: indexPath.section) } - UIView.performWithoutAnimation { - tableView.reloadRows(at: indexPaths, with: .none) - } - default: - assertionFailure() - } - } -} - -private class KeyValueCell: UITableViewCell { - var key: String { - get { return keyLabel.text ?? "" } - set(value) {keyLabel.text = value } - } - var value: String { - get { return valueTextField.text ?? "" } - set(value) { valueTextField.text = value } - } - var placeholderText: String { - get { return valueTextField.placeholder ?? "" } - set(value) { valueTextField.placeholder = value } - } - var isValueValid: Bool = true { - didSet { - if isValueValid { - keyLabel.textColor = UIColor.black - } else { - keyLabel.textColor = UIColor.red - } - } - } - var keyboardType: UIKeyboardType { - get { return valueTextField.keyboardType } - set(value) { valueTextField.keyboardType = value } - } - - var onValueChanged: ((String) -> Void)? - var onValueBeingEdited: ((String) -> Void)? - - let keyLabel: UILabel - let valueTextField: UITextField - - var isStackedHorizontally: Bool = false - var isStackedVertically: Bool = false - var contentSizeBasedConstraints = [NSLayoutConstraint]() - - private var textFieldValueOnBeginEditing: String = "" - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - keyLabel = UILabel() - keyLabel.font = UIFont.preferredFont(forTextStyle: .body) - keyLabel.adjustsFontForContentSizeCategory = true - valueTextField = UITextField() - valueTextField.font = UIFont.preferredFont(forTextStyle: .body) - valueTextField.adjustsFontForContentSizeCategory = true - super.init(style: style, reuseIdentifier: reuseIdentifier) - contentView.addSubview(keyLabel) - keyLabel.translatesAutoresizingMaskIntoConstraints = false - keyLabel.textAlignment = .right - let widthRatioConstraint = NSLayoutConstraint(item: keyLabel, attribute: .width, - relatedBy: .equal, - toItem: self, attribute: .width, - multiplier: 0.4, constant: 0) - // The "Persistent Keepalive" key doesn't fit into 0.4 * width on the iPhone SE, - // so set a CR priority > the 0.4-constraint's priority. - widthRatioConstraint.priority = .defaultHigh + 1 - keyLabel.setContentCompressionResistancePriority(.defaultHigh + 2, for: .horizontal) - NSLayoutConstraint.activate([ - keyLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), - keyLabel.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5), - widthRatioConstraint - ]) - contentView.addSubview(valueTextField) - valueTextField.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - valueTextField.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor), - contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: valueTextField.bottomAnchor, multiplier: 0.5) - ]) - valueTextField.delegate = self - - valueTextField.autocapitalizationType = .none - valueTextField.autocorrectionType = .no - valueTextField.spellCheckingType = .no - - configureForContentSize() - } - - func configureForContentSize() { - var constraints = [NSLayoutConstraint]() - if self.traitCollection.preferredContentSizeCategory.isAccessibilityCategory { - // Stack vertically - if !isStackedVertically { - constraints = [ - valueTextField.topAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), - valueTextField.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), - keyLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor) - ] - isStackedVertically = true - isStackedHorizontally = false - } - } else { - // Stack horizontally - if !isStackedHorizontally { - constraints = [ - contentView.layoutMarginsGuide.bottomAnchor.constraint(equalToSystemSpacingBelow: keyLabel.bottomAnchor, multiplier: 0.5), - valueTextField.leftAnchor.constraint(equalToSystemSpacingAfter: keyLabel.rightAnchor, multiplier: 1), - valueTextField.topAnchor.constraint(equalToSystemSpacingBelow: contentView.layoutMarginsGuide.topAnchor, multiplier: 0.5) - ] - isStackedHorizontally = true - isStackedVertically = false - } - } - if !constraints.isEmpty { - NSLayoutConstraint.deactivate(self.contentSizeBasedConstraints) - NSLayoutConstraint.activate(constraints) - self.contentSizeBasedConstraints = constraints - } - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - key = "" - value = "" - placeholderText = "" - isValueValid = true - keyboardType = .default - onValueChanged = nil - onValueBeingEdited = nil - configureForContentSize() - } -} - -extension KeyValueCell: UITextFieldDelegate { - func textFieldDidBeginEditing(_ textField: UITextField) { - textFieldValueOnBeginEditing = textField.text ?? "" - isValueValid = true - } - func textFieldDidEndEditing(_ textField: UITextField) { - let isModified = (textField.text ?? "" != textFieldValueOnBeginEditing) - guard isModified else { return } - if let onValueChanged = onValueChanged { - onValueChanged(textField.text ?? "") - } - } - func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { - if let onValueBeingEdited = onValueBeingEdited { - let modifiedText = ((textField.text ?? "") as NSString).replacingCharacters(in: range, with: string) - onValueBeingEdited(modifiedText) - } - return true - } -} - -private class ReadOnlyKeyValueCell: CopyableLabelTableViewCell { - var key: String { - get { return keyLabel.text ?? "" } - set(value) {keyLabel.text = value } - } - var value: String { - get { return valueLabel.text } - set(value) { valueLabel.text = value } - } - - let keyLabel: UILabel - let valueLabel: ScrollableLabel - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - keyLabel = UILabel() - keyLabel.font = UIFont.preferredFont(forTextStyle: .body) - keyLabel.adjustsFontForContentSizeCategory = true - valueLabel = ScrollableLabel() - valueLabel.label.font = UIFont.preferredFont(forTextStyle: .body) - valueLabel.label.adjustsFontForContentSizeCategory = true - - super.init(style: style, reuseIdentifier: reuseIdentifier) - - keyLabel.textColor = UIColor.gray - valueLabel.textColor = UIColor.gray - - contentView.addSubview(keyLabel) - keyLabel.translatesAutoresizingMaskIntoConstraints = false - keyLabel.textAlignment = .right - let widthRatioConstraint = NSLayoutConstraint(item: keyLabel, attribute: .width, - relatedBy: .equal, - toItem: self, attribute: .width, - multiplier: 0.4, constant: 0) - // In case the key doesn't fit into 0.4 * width, - // so set a CR priority > the 0.4-constraint's priority. - widthRatioConstraint.priority = .defaultHigh + 1 - keyLabel.setContentCompressionResistancePriority(.defaultHigh + 2, for: .horizontal) - NSLayoutConstraint.activate([ - keyLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), - keyLabel.leftAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leftAnchor), - widthRatioConstraint - ]) - - contentView.addSubview(valueLabel) - valueLabel.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - valueLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), - valueLabel.leftAnchor.constraint(equalToSystemSpacingAfter: keyLabel.rightAnchor, multiplier: 1), - valueLabel.rightAnchor.constraint(equalTo: contentView.layoutMarginsGuide.rightAnchor) - ]) - } - - override var textToCopy: String? { - return self.valueLabel.text - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - key = "" - value = "" - } -} - -private class ButtonCell: UITableViewCell { - var buttonText: String { - get { return button.title(for: .normal) ?? "" } - set(value) { button.setTitle(value, for: .normal) } - } - var hasDestructiveAction: Bool { - get { return button.tintColor == UIColor.red } - set(value) { button.tintColor = value ? UIColor.red : buttonStandardTintColor } - } - var onTapped: (() -> Void)? - - let button: UIButton - var buttonStandardTintColor: UIColor - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - button = UIButton(type: .system) - button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) - button.titleLabel?.adjustsFontForContentSizeCategory = true - buttonStandardTintColor = button.tintColor - super.init(style: style, reuseIdentifier: reuseIdentifier) - contentView.addSubview(button) - button.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - button.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), - contentView.layoutMarginsGuide.bottomAnchor.constraint(equalTo: button.bottomAnchor), - button.centerXAnchor.constraint(equalTo: contentView.centerXAnchor) - ]) - button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) - } - - @objc func buttonTapped() { - onTapped?() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - buttonText = "" - onTapped = nil - hasDestructiveAction = false - } -} - -private class SwitchCell: UITableViewCell { - var message: String { - get { return textLabel?.text ?? "" } - set(value) { textLabel!.text = value } - } - var isOn: Bool { - get { return switchView.isOn } - set(value) { switchView.isOn = value } - } - var isEnabled: Bool { - get { return switchView.isEnabled } - set(value) { - switchView.isEnabled = value - textLabel?.textColor = value ? UIColor.black : UIColor.gray - } - } - - var onSwitchToggled: ((Bool) -> Void)? - - let switchView: UISwitch - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - switchView = UISwitch() - super.init(style: .default, reuseIdentifier: reuseIdentifier) - accessoryView = switchView - switchView.addTarget(self, action: #selector(switchToggled), for: .valueChanged) - } - - @objc func switchToggled() { - onSwitchToggled?(switchView.isOn) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - message = "" - isOn = false - } -} - -private class SelectionListCell: UITableViewCell { - var message: String { - get { return textLabel?.text ?? "" } - set(value) { textLabel!.text = value } - } - var isChecked: Bool { - didSet { - accessoryType = isChecked ? .checkmark : .none - } - } - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - isChecked = false - super.init(style: .default, reuseIdentifier: reuseIdentifier) - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func prepareForReuse() { - super.prepareForReuse() - message = "" - isChecked = false - } -} 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 + } +} diff --git a/WireGuard/WireGuard/UI/iOS/TunnelsListTableViewController.swift b/WireGuard/WireGuard/UI/iOS/TunnelsListTableViewController.swift deleted file mode 100644 index efa85e6..0000000 --- a/WireGuard/WireGuard/UI/iOS/TunnelsListTableViewController.swift +++ /dev/null @@ -1,453 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright © 2018 WireGuard LLC. All Rights Reserved. - -import UIKit -import MobileCoreServices -import UserNotifications - -class TunnelsListTableViewController: UIViewController { - - var tunnelsManager: TunnelsManager? - - var busyIndicator: UIActivityIndicatorView? - var centeredAddButton: BorderedTextButton? - var tableView: UITableView? - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = UIColor.white - - // Set up the navigation bar - self.title = "WireGuard" - let addButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(addButtonTapped(sender:))) - self.navigationItem.rightBarButtonItem = addButtonItem - let settingsButtonItem = UIBarButtonItem(title: "Settings", style: .plain, target: self, action: #selector(settingsButtonTapped(sender:))) - self.navigationItem.leftBarButtonItem = settingsButtonItem - - // Set up the busy indicator - let busyIndicator = UIActivityIndicatorView(style: .gray) - busyIndicator.hidesWhenStopped = true - - // Add the busyIndicator, centered - view.addSubview(busyIndicator) - busyIndicator.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - busyIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), - busyIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor) - ]) - busyIndicator.startAnimating() - self.busyIndicator = busyIndicator - - // State restoration - self.restorationIdentifier = "TunnelsListVC" - } - - func setTunnelsManager(tunnelsManager: TunnelsManager) { - if self.tunnelsManager != nil { - // If a tunnels manager is already set, do nothing - return - } - - // Create the table view - - let tableView = UITableView(frame: CGRect.zero, style: .plain) - tableView.estimatedRowHeight = 60 - tableView.rowHeight = UITableView.automaticDimension - tableView.separatorStyle = .none - tableView.register(TunnelCell.self) - - self.view.addSubview(tableView) - tableView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - tableView.leftAnchor.constraint(equalTo: self.view.leftAnchor), - tableView.rightAnchor.constraint(equalTo: self.view.rightAnchor), - tableView.topAnchor.constraint(equalTo: self.view.topAnchor), - tableView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor) - ]) - tableView.dataSource = self - tableView.delegate = self - self.tableView = tableView - - // Add button at the center - - let centeredAddButton = BorderedTextButton() - centeredAddButton.title = "Add a tunnel" - centeredAddButton.isHidden = true - self.view.addSubview(centeredAddButton) - centeredAddButton.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - centeredAddButton.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), - centeredAddButton.centerYAnchor.constraint(equalTo: self.view.centerYAnchor) - ]) - centeredAddButton.onTapped = { [weak self] in - self?.addButtonTapped(sender: centeredAddButton) - } - centeredAddButton.isHidden = (tunnelsManager.numberOfTunnels() > 0) - self.centeredAddButton = centeredAddButton - - // Hide the busy indicator - - self.busyIndicator?.stopAnimating() - - // Keep track of the tunnels manager - - self.tunnelsManager = tunnelsManager - tunnelsManager.tunnelsListDelegate = self - } - - override func viewWillAppear(_: Bool) { - // Remove selection when getting back to the list view on iPhone - if let tableView = self.tableView, 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: TunnelCell = 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) - } -} - -private class TunnelCell: 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 busyIndicator: UIActivityIndicatorView - let statusSwitch: UISwitch - - private var statusObservervationToken: AnyObject? - private var nameObservervationToken: AnyObject? - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - nameLabel = UILabel() - nameLabel.font = UIFont.preferredFont(forTextStyle: .body) - nameLabel.adjustsFontForContentSizeCategory = true - busyIndicator = UIActivityIndicatorView(style: .gray) - busyIndicator.hidesWhenStopped = true - statusSwitch = UISwitch() - 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.numberOfLines = 0 - nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - let bottomAnchorConstraint = contentView.layoutMarginsGuide.bottomAnchor.constraint( - equalToSystemSpacingBelow: nameLabel.bottomAnchor, multiplier: 1) - bottomAnchorConstraint.priority = .defaultLow // Allow this constraint to be broken when animating a cell away during deletion - 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 - ]) - - self.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() - } -} - -class BorderedTextButton: UIView { - let button: UIButton - - 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() { - button = UIButton(type: .system) - button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .body) - button.titleLabel?.adjustsFontForContentSizeCategory = true - super.init(frame: CGRect.zero) - addSubview(button) - button.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - button.centerXAnchor.constraint(equalTo: self.centerXAnchor), - button.centerYAnchor.constraint(equalTo: self.centerYAnchor) - ]) - layer.borderWidth = 1 - layer.cornerRadius = 5 - layer.borderColor = button.tintColor.cgColor - button.addTarget(self, action: #selector(buttonTapped), for: .touchUpInside) - } - - @objc func buttonTapped() { - onTapped?() - } - - required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} -- cgit v1.2.3-59-g8ed1b