From 34b9023f67d1de46fdcb5975a91e17aae2bf5849 Mon Sep 17 00:00:00 2001 From: "Jason A. Donenfeld" Date: Wed, 31 Oct 2018 01:00:27 +0100 Subject: [PATCH] UI: More elegant copy-to-clipboard behavior --- WireGuard/WireGuard.xcodeproj/project.pbxproj | 4 + WireGuard/WireGuard/UI/TunnelViewModel.swift | 3 +- .../UI/iOS/CopyableLabelTableViewCell.swift | 51 +++++++++++ .../iOS/TunnelDetailTableViewController.swift | 84 +++++++------------ .../iOS/TunnelEditTableViewController.swift | 4 +- 5 files changed, 87 insertions(+), 59 deletions(-) create mode 100644 WireGuard/WireGuard/UI/iOS/CopyableLabelTableViewCell.swift diff --git a/WireGuard/WireGuard.xcodeproj/project.pbxproj b/WireGuard/WireGuard.xcodeproj/project.pbxproj index 27d1137..efdf1dd 100644 --- a/WireGuard/WireGuard.xcodeproj/project.pbxproj +++ b/WireGuard/WireGuard.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 6BB8400421892C920003598F /* CopyableLabelTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BB8400321892C920003598F /* CopyableLabelTableViewCell.swift */; }; 6F5D0C1521832391000F85AD /* DNSResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F5D0C1421832391000F85AD /* DNSResolver.swift */; }; 6F5D0C1D218352EF000F85AD /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F5D0C1C218352EF000F85AD /* PacketTunnelProvider.swift */; }; 6F5D0C22218352EF000F85AD /* WireGuardNetworkExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 6F5D0C1A218352EF000F85AD /* WireGuardNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -72,6 +73,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 6BB8400321892C920003598F /* CopyableLabelTableViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CopyableLabelTableViewCell.swift; sourceTree = ""; }; 6F5D0C1421832391000F85AD /* DNSResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DNSResolver.swift; sourceTree = ""; }; 6F5D0C1A218352EF000F85AD /* WireGuardNetworkExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WireGuardNetworkExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 6F5D0C1C218352EF000F85AD /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = ""; }; @@ -186,6 +188,7 @@ 6F7774DE217181B1006A79B3 /* iOS */ = { isa = PBXGroup; children = ( + 6BB8400321892C920003598F /* CopyableLabelTableViewCell.swift */, 6FDEF7E52185EFAF00D8FBF6 /* QRScanViewController.swift */, 6F7774E0217181B1006A79B3 /* AppDelegate.swift */, 6F7774DF217181B1006A79B3 /* MainViewController.swift */, @@ -439,6 +442,7 @@ 6F7774EF21722D97006A79B3 /* TunnelsManager.swift in Sources */, 6F5D0C1521832391000F85AD /* DNSResolver.swift in Sources */, 6F5D0C482183C6A3000F85AD /* PacketTunnelOptionsGenerator.swift in Sources */, + 6BB8400421892C920003598F /* CopyableLabelTableViewCell.swift in Sources */, 6F693A562179E556008551C1 /* Endpoint.swift in Sources */, 6FDEF7E62185EFB200D8FBF6 /* QRScanViewController.swift in Sources */, 6F6899A62180447E0012E523 /* x25519.c in Sources */, diff --git a/WireGuard/WireGuard/UI/TunnelViewModel.swift b/WireGuard/WireGuard/UI/TunnelViewModel.swift index fe50cbc..56fa372 100644 --- a/WireGuard/WireGuard/UI/TunnelViewModel.swift +++ b/WireGuard/WireGuard/UI/TunnelViewModel.swift @@ -10,7 +10,6 @@ class TunnelViewModel { case privateKey = "Private key" case publicKey = "Public key" case generateKeyPair = "Generate keypair" - case copyPublicKey = "Copy public key" case addresses = "Addresses" case listenPort = "Listen port" case mtu = "MTU" @@ -18,7 +17,7 @@ class TunnelViewModel { } static let interfaceFieldsWithControl: Set = [ - .generateKeyPair, .copyPublicKey + .generateKeyPair ] enum PeerField: String { diff --git a/WireGuard/WireGuard/UI/iOS/CopyableLabelTableViewCell.swift b/WireGuard/WireGuard/UI/iOS/CopyableLabelTableViewCell.swift new file mode 100644 index 0000000..779fe8f --- /dev/null +++ b/WireGuard/WireGuard/UI/iOS/CopyableLabelTableViewCell.swift @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// Copyright © 2018 WireGuard LLC. All Rights Reserved. + +import UIKit + +class CopyableLabelTableViewCell: UITableViewCell { + var copyableGesture = true + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + let gestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTapGesture(_:))) + self.addGestureRecognizer(gestureRecognizer) + self.isUserInteractionEnabled = true + } + + // MARK: - UIGestureRecognizer + @objc func handleTapGesture(_ recognizer: UIGestureRecognizer) { + if !self.copyableGesture { + return + } + guard recognizer.state == .recognized else { return } + + if let recognizerView = recognizer.view, + let recognizerSuperView = recognizerView.superview, recognizerView.becomeFirstResponder() { + let menuController = UIMenuController.shared + menuController.setTargetRect(self.detailTextLabel?.frame ?? recognizerView.frame, in: self.detailTextLabel?.superview ?? recognizerSuperView) + menuController.setMenuVisible(true, animated: true) + } + } + + override var canBecomeFirstResponder: Bool { + return true + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + return (action == #selector(UIResponderStandardEditActions.copy(_:))) + } + + override func copy(_ sender: Any?) { + UIPasteboard.general.string = self.detailTextLabel?.text + } + + override func prepareForReuse() { + super.prepareForReuse() + self.copyableGesture = true + } +} diff --git a/WireGuard/WireGuard/UI/iOS/TunnelDetailTableViewController.swift b/WireGuard/WireGuard/UI/iOS/TunnelDetailTableViewController.swift index 0cc7806..fe413c9 100644 --- a/WireGuard/WireGuard/UI/iOS/TunnelDetailTableViewController.swift +++ b/WireGuard/WireGuard/UI/iOS/TunnelDetailTableViewController.swift @@ -7,10 +7,9 @@ import UIKit class TunnelDetailTableViewController: UITableViewController { - let interfaceFieldsBySection: [[TunnelViewModel.InterfaceField]] = [ - [.name], - [.publicKey, .copyPublicKey], - [.addresses, .listenPort, .mtu, .dns] + let interfaceFields: [TunnelViewModel.InterfaceField] = [ + .name, .publicKey, .addresses, + .listenPort, .mtu, .dns ] let peerFields: [TunnelViewModel.PeerField] = [ @@ -95,32 +94,22 @@ extension TunnelDetailTableViewController: TunnelEditTableViewControllerDelegate extension TunnelDetailTableViewController { override func numberOfSections(in tableView: UITableView) -> Int { - let interfaceData = tunnelViewModel.interfaceData - let numberOfInterfaceSections = (0 ..< interfaceFieldsBySection.count).filter { section in - (!interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFieldsBySection[section]).isEmpty) - }.count - let numberOfPeerSections = tunnelViewModel.peersData.count - - return 1 + numberOfInterfaceSections + numberOfPeerSections + 1 + return 3 + tunnelViewModel.peersData.count } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { let interfaceData = tunnelViewModel.interfaceData - let numberOfInterfaceSections = (0 ..< interfaceFieldsBySection.count).filter { section in - (!interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFieldsBySection[section]).isEmpty) - }.count let numberOfPeerSections = tunnelViewModel.peersData.count if (section == 0) { // Status return 1 - } else if (section < (1 + numberOfInterfaceSections)) { + } else if (section == 1) { // Interface - return interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFieldsBySection[section - 1]).count - } else if ((numberOfPeerSections > 0) && (section < (1 + numberOfInterfaceSections + numberOfPeerSections))) { + return interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFields).count + } else if ((numberOfPeerSections > 0) && (section < (2 + numberOfPeerSections))) { // Peer - let peerIndex = (section - numberOfInterfaceSections - 1) - let peerData = tunnelViewModel.peersData[peerIndex] + let peerData = tunnelViewModel.peersData[section - 2] return peerData.filterFieldsWithValueOrControl(peerFields: peerFields).count } else { // Delete tunnel @@ -129,32 +118,25 @@ extension TunnelDetailTableViewController { } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { - let interfaceData = tunnelViewModel.interfaceData - let numberOfInterfaceSections = (0 ..< interfaceFieldsBySection.count).filter { section in - (!interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFieldsBySection[section]).isEmpty) - }.count let numberOfPeerSections = tunnelViewModel.peersData.count if (section == 0) { // Status return "Status" - } else if (section < 1 + numberOfInterfaceSections) { + } else if (section == 1) { // Interface - return (section == 1) ? "Interface" : nil - } else if ((numberOfPeerSections > 0) && (section < (1 + numberOfInterfaceSections + numberOfPeerSections))) { + return "Interface" + } else if ((numberOfPeerSections > 0) && (section < (2 + numberOfPeerSections))) { // Peer return "Peer" } else { - // Add peer + // Delete tunnel return nil } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let interfaceData = tunnelViewModel.interfaceData - let numberOfInterfaceSections = (0 ..< interfaceFieldsBySection.count).filter { section in - (!interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFieldsBySection[section]).isEmpty) - }.count let numberOfPeerSections = tunnelViewModel.peersData.count let section = indexPath.section @@ -186,32 +168,22 @@ extension TunnelDetailTableViewController { } } return cell - } else if (section < 1 + numberOfInterfaceSections) { + } else if (section == 1) { // Interface - let field = interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFieldsBySection[section - 1])[row] - if (field == .copyPublicKey) { - let cell = tableView.dequeueReusableCell(withIdentifier: TunnelDetailTableViewButtonCell.id, for: indexPath) as! TunnelDetailTableViewButtonCell - cell.buttonText = field.rawValue - cell.onTapped = { - UIPasteboard.general.string = interfaceData[.publicKey] - } - return cell - } else { - let cell = tableView.dequeueReusableCell(withIdentifier: TunnelDetailTableViewKeyValueCell.id, for: indexPath) as! TunnelDetailTableViewKeyValueCell - // Set key and value - cell.key = field.rawValue - cell.value = interfaceData[field] - if (field != .publicKey) { - cell.detailTextLabel?.allowsDefaultTighteningForTruncation = true - cell.detailTextLabel?.adjustsFontSizeToFitWidth = true - cell.detailTextLabel?.minimumScaleFactor = 0.85 - } - return cell + let field = interfaceData.filterFieldsWithValueOrControl(interfaceFields: interfaceFields)[row] + let cell = tableView.dequeueReusableCell(withIdentifier: TunnelDetailTableViewKeyValueCell.id, for: indexPath) as! TunnelDetailTableViewKeyValueCell + // Set key and value + cell.key = field.rawValue + cell.value = interfaceData[field] + if (field != .publicKey) { + cell.detailTextLabel?.allowsDefaultTighteningForTruncation = true + cell.detailTextLabel?.adjustsFontSizeToFitWidth = true + cell.detailTextLabel?.minimumScaleFactor = 0.85 } - } else if ((numberOfPeerSections > 0) && (section < (1 + numberOfInterfaceSections + numberOfPeerSections))) { + return cell + } else if ((numberOfPeerSections > 0) && (section < (2 + numberOfPeerSections))) { // Peer - let peerIndex = (section - numberOfInterfaceSections - 1) - let peerData = tunnelViewModel.peersData[peerIndex] + let peerData = tunnelViewModel.peersData[section - 2] let field = peerData.filterFieldsWithValueOrControl(peerFields: peerFields)[row] let cell = tableView.dequeueReusableCell(withIdentifier: TunnelDetailTableViewKeyValueCell.id, for: indexPath) as! TunnelDetailTableViewKeyValueCell @@ -226,7 +198,7 @@ extension TunnelDetailTableViewController { return cell } else { - assert(section == (1 + numberOfInterfaceSections + numberOfPeerSections)) + assert(section == (2 + numberOfPeerSections)) // Delete configuration let cell = tableView.dequeueReusableCell(withIdentifier: TunnelDetailTableViewButtonCell.id, for: indexPath) as! TunnelDetailTableViewButtonCell cell.buttonText = "Delete tunnel" @@ -328,7 +300,7 @@ class TunnelDetailTableViewStatusCell: UITableViewCell { } } -class TunnelDetailTableViewKeyValueCell: UITableViewCell { +class TunnelDetailTableViewKeyValueCell: CopyableLabelTableViewCell { static let id: String = "TunnelDetailTableViewKeyValueCell" var key: String { get { return textLabel?.text ?? "" } @@ -355,7 +327,7 @@ class TunnelDetailTableViewKeyValueCell: UITableViewCell { } class TunnelDetailTableViewButtonCell: UITableViewCell { - static let id: String = "TunnelsEditTableViewButtonCell" + static let id: String = "TunnelDetailTableViewButtonCell" var buttonText: String { get { return button.title(for: .normal) ?? "" } set(value) { button.setTitle(value, for: .normal) } diff --git a/WireGuard/WireGuard/UI/iOS/TunnelEditTableViewController.swift b/WireGuard/WireGuard/UI/iOS/TunnelEditTableViewController.swift index 71187f8..e0b11e4 100644 --- a/WireGuard/WireGuard/UI/iOS/TunnelEditTableViewController.swift +++ b/WireGuard/WireGuard/UI/iOS/TunnelEditTableViewController.swift @@ -361,7 +361,7 @@ extension TunnelEditTableViewController { } } -class TunnelEditTableViewKeyValueCell: UITableViewCell { +class TunnelEditTableViewKeyValueCell: CopyableLabelTableViewCell { static let id: String = "TunnelEditTableViewKeyValueCell" var key: String { get { return keyLabel.text ?? "" } @@ -378,6 +378,7 @@ class TunnelEditTableViewKeyValueCell: UITableViewCell { var isValueEditable: Bool { get { return valueTextField.isEnabled } set(value) { + super.copyableGesture = !value valueTextField.isEnabled = value keyLabel.textColor = value ? UIColor.black : UIColor.gray valueTextField.textColor = value ? UIColor.black : UIColor.gray @@ -409,6 +410,7 @@ class TunnelEditTableViewKeyValueCell: UITableViewCell { keyLabel = UILabel() valueTextField = UITextField() super.init(style: style, reuseIdentifier: reuseIdentifier) + isValueEditable = true contentView.addSubview(keyLabel) keyLabel.translatesAutoresizingMaskIntoConstraints = false keyLabel.textAlignment = .right