Tunnel creation: Refactor by creating a separate view model
This commit is contained in:
parent
9f252d4e37
commit
0fa97c38ed
|
@ -7,6 +7,7 @@
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
6F628C3D217F09E9003482A3 /* TunnelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F628C3C217F09E9003482A3 /* TunnelViewModel.swift */; };
|
||||||
6F693A562179E556008551C1 /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F693A552179E556008551C1 /* Endpoint.swift */; };
|
6F693A562179E556008551C1 /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F693A552179E556008551C1 /* Endpoint.swift */; };
|
||||||
6F7774E1217181B1006A79B3 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7774DF217181B1006A79B3 /* MainViewController.swift */; };
|
6F7774E1217181B1006A79B3 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7774DF217181B1006A79B3 /* MainViewController.swift */; };
|
||||||
6F7774E2217181B1006A79B3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7774E0217181B1006A79B3 /* AppDelegate.swift */; };
|
6F7774E2217181B1006A79B3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F7774E0217181B1006A79B3 /* AppDelegate.swift */; };
|
||||||
|
@ -21,6 +22,7 @@
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
6F628C3C217F09E9003482A3 /* TunnelViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TunnelViewModel.swift; sourceTree = "<group>"; };
|
||||||
6F693A552179E556008551C1 /* Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = "<group>"; };
|
6F693A552179E556008551C1 /* Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = "<group>"; };
|
||||||
6F7774DF217181B1006A79B3 /* MainViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
|
6F7774DF217181B1006A79B3 /* MainViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = "<group>"; };
|
||||||
6F7774E0217181B1006A79B3 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
6F7774E0217181B1006A79B3 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
|
@ -54,6 +56,7 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
6F7774DE217181B1006A79B3 /* iOS */,
|
6F7774DE217181B1006A79B3 /* iOS */,
|
||||||
|
6F628C3C217F09E9003482A3 /* TunnelViewModel.swift */,
|
||||||
);
|
);
|
||||||
path = UI;
|
path = UI;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -206,6 +209,7 @@
|
||||||
6F7774EF21722D97006A79B3 /* TunnelsManager.swift in Sources */,
|
6F7774EF21722D97006A79B3 /* TunnelsManager.swift in Sources */,
|
||||||
6F693A562179E556008551C1 /* Endpoint.swift in Sources */,
|
6F693A562179E556008551C1 /* Endpoint.swift in Sources */,
|
||||||
6F7774E2217181B1006A79B3 /* AppDelegate.swift in Sources */,
|
6F7774E2217181B1006A79B3 /* AppDelegate.swift in Sources */,
|
||||||
|
6F628C3D217F09E9003482A3 /* TunnelViewModel.swift in Sources */,
|
||||||
6F7774EA217229DB006A79B3 /* IPAddressRange.swift in Sources */,
|
6F7774EA217229DB006A79B3 /* IPAddressRange.swift in Sources */,
|
||||||
6F7774E82172020C006A79B3 /* Configuration.swift in Sources */,
|
6F7774E82172020C006A79B3 /* Configuration.swift in Sources */,
|
||||||
6F7774F321774263006A79B3 /* TunnelEditTableViewController.swift in Sources */,
|
6F7774F321774263006A79B3 /* TunnelEditTableViewController.swift in Sources */,
|
||||||
|
|
|
@ -0,0 +1,309 @@
|
||||||
|
//
|
||||||
|
// TunnelViewModel.swift
|
||||||
|
// WireGuard
|
||||||
|
//
|
||||||
|
// Created by Roopesh Chander on 23/10/18.
|
||||||
|
// Copyright © 2018 WireGuard LLC. All rights reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
class TunnelViewModel {
|
||||||
|
|
||||||
|
enum InterfaceEditField: String {
|
||||||
|
case name = "Name"
|
||||||
|
case privateKey = "Private key"
|
||||||
|
case publicKey = "Public key"
|
||||||
|
case generateKeyPair = "Generate keypair"
|
||||||
|
case addresses = "Addresses"
|
||||||
|
case listenPort = "Listen port"
|
||||||
|
case mtu = "MTU"
|
||||||
|
case dns = "DNS servers"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PeerEditField: String {
|
||||||
|
case publicKey = "Public key"
|
||||||
|
case preSharedKey = "Pre-shared key"
|
||||||
|
case endpoint = "Endpoint"
|
||||||
|
case persistentKeepAlive = "Persistent Keepalive"
|
||||||
|
case allowedIPs = "Allowed IPs"
|
||||||
|
case excludePrivateIPs = "Exclude private IPs"
|
||||||
|
case deletePeer = "Delete peer"
|
||||||
|
}
|
||||||
|
|
||||||
|
class InterfaceData {
|
||||||
|
var scratchpad: [InterfaceEditField: String] = [:]
|
||||||
|
var fieldsWithError: Set<InterfaceEditField> = []
|
||||||
|
var validatedConfiguration: InterfaceConfiguration? = nil
|
||||||
|
|
||||||
|
subscript(field: InterfaceEditField) -> String {
|
||||||
|
get {
|
||||||
|
if (scratchpad.isEmpty) {
|
||||||
|
// When starting to read a config, setup the scratchpad.
|
||||||
|
// The scratchpad shall serve as a cache of what we want to show in the UI.
|
||||||
|
populateScratchpad()
|
||||||
|
}
|
||||||
|
return scratchpad[field] ?? ""
|
||||||
|
}
|
||||||
|
set(stringValue) {
|
||||||
|
if (scratchpad.isEmpty) {
|
||||||
|
// When starting to edit a config, setup the scratchpad and remove the configuration.
|
||||||
|
// The scratchpad shall be the sole source of the being-edited configuration.
|
||||||
|
populateScratchpad()
|
||||||
|
}
|
||||||
|
validatedConfiguration = nil
|
||||||
|
if (stringValue.isEmpty) {
|
||||||
|
scratchpad.removeValue(forKey: field)
|
||||||
|
} else {
|
||||||
|
scratchpad[field] = stringValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func populateScratchpad() {
|
||||||
|
// Populate the scratchpad from the configuration object
|
||||||
|
guard let config = validatedConfiguration else { return }
|
||||||
|
scratchpad[.name] = config.name
|
||||||
|
scratchpad[.privateKey] = config.privateKey.base64EncodedString()
|
||||||
|
if (!config.addresses.isEmpty) {
|
||||||
|
scratchpad[.addresses] = config.addresses.map { $0.stringRepresentation() }.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
if let listenPort = config.listenPort {
|
||||||
|
scratchpad[.listenPort] = String(listenPort)
|
||||||
|
}
|
||||||
|
if let mtu = config.mtu {
|
||||||
|
scratchpad[.mtu] = String(mtu)
|
||||||
|
}
|
||||||
|
if let dns = config.dns {
|
||||||
|
scratchpad[.dns] = String(dns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() -> SaveResult<InterfaceConfiguration> {
|
||||||
|
fieldsWithError.removeAll()
|
||||||
|
guard let name = scratchpad[.name] else {
|
||||||
|
fieldsWithError.insert(.name)
|
||||||
|
return .error("Interface name is required")
|
||||||
|
}
|
||||||
|
guard let privateKeyString = scratchpad[.privateKey] else {
|
||||||
|
fieldsWithError.insert(.privateKey)
|
||||||
|
return .error("Interface's private key is required")
|
||||||
|
}
|
||||||
|
guard let privateKey = Data(base64Encoded: privateKeyString), privateKey.count == 32 else {
|
||||||
|
fieldsWithError.insert(.privateKey)
|
||||||
|
return .error("Interface's private key should be a 32-byte key in base64 encoding")
|
||||||
|
}
|
||||||
|
var config = InterfaceConfiguration(name: name, privateKey: privateKey)
|
||||||
|
var errorMessages: [String] = []
|
||||||
|
if let addressesString = scratchpad[.addresses] {
|
||||||
|
var addresses: [IPAddressRange] = []
|
||||||
|
for addressString in addressesString.split(separator: ",") {
|
||||||
|
let trimmedString = addressString.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||||
|
if let address = IPAddressRange(from: trimmedString) {
|
||||||
|
addresses.append(address)
|
||||||
|
} else {
|
||||||
|
fieldsWithError.insert(.addresses)
|
||||||
|
errorMessages.append("Interface addresses should be a list of comma-separated IP addresses in CIDR notation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.addresses = addresses
|
||||||
|
}
|
||||||
|
if let listenPortString = scratchpad[.listenPort] {
|
||||||
|
if let listenPort = UInt64(listenPortString) {
|
||||||
|
config.listenPort = listenPort
|
||||||
|
} else {
|
||||||
|
fieldsWithError.insert(.listenPort)
|
||||||
|
errorMessages.append("Interface's listen port should be a number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let mtuString = scratchpad[.mtu] {
|
||||||
|
if let mtu = UInt64(mtuString) {
|
||||||
|
config.mtu = mtu
|
||||||
|
} else {
|
||||||
|
fieldsWithError.insert(.mtu)
|
||||||
|
errorMessages.append("Interface's MTU should be a number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: Validate DNS
|
||||||
|
if let dnsString = scratchpad[.dns] {
|
||||||
|
config.dns = dnsString
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (errorMessages.isEmpty) else {
|
||||||
|
return .error(errorMessages.first!)
|
||||||
|
}
|
||||||
|
validatedConfiguration = config
|
||||||
|
return .saved(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PeerData {
|
||||||
|
var index: Int
|
||||||
|
var scratchpad: [PeerEditField: String] = [:]
|
||||||
|
var fieldsWithError: Set<PeerEditField> = []
|
||||||
|
var validatedConfiguration: PeerConfiguration? = nil
|
||||||
|
|
||||||
|
init(index: Int) {
|
||||||
|
self.index = index
|
||||||
|
}
|
||||||
|
|
||||||
|
subscript(field: PeerEditField) -> String {
|
||||||
|
get {
|
||||||
|
if (scratchpad.isEmpty) {
|
||||||
|
// When starting to read a config, setup the scratchpad.
|
||||||
|
// The scratchpad shall serve as a cache of what we want to show in the UI.
|
||||||
|
populateScratchpad()
|
||||||
|
}
|
||||||
|
return scratchpad[field] ?? ""
|
||||||
|
}
|
||||||
|
set(stringValue) {
|
||||||
|
if (scratchpad.isEmpty) {
|
||||||
|
// When starting to edit a config, setup the scratchpad and remove the configuration.
|
||||||
|
// The scratchpad shall be the sole source of the being-edited configuration.
|
||||||
|
populateScratchpad()
|
||||||
|
}
|
||||||
|
validatedConfiguration = nil
|
||||||
|
if (stringValue.isEmpty) {
|
||||||
|
scratchpad.removeValue(forKey: field)
|
||||||
|
} else {
|
||||||
|
scratchpad[field] = stringValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func populateScratchpad() {
|
||||||
|
// Populate the scratchpad from the configuration object
|
||||||
|
guard let config = validatedConfiguration else { return }
|
||||||
|
scratchpad[.publicKey] = config.publicKey.base64EncodedString()
|
||||||
|
if let preSharedKey = config.preSharedKey {
|
||||||
|
scratchpad[.preSharedKey] = preSharedKey.base64EncodedString()
|
||||||
|
}
|
||||||
|
if (!config.allowedIPs.isEmpty) {
|
||||||
|
scratchpad[.allowedIPs] = config.allowedIPs.map { $0.stringRepresentation() }.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
if let endpoint = config.endpoint {
|
||||||
|
scratchpad[.endpoint] = endpoint.stringRepresentation()
|
||||||
|
}
|
||||||
|
if let persistentKeepAlive = config.persistentKeepAlive {
|
||||||
|
scratchpad[.persistentKeepAlive] = String(persistentKeepAlive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() -> SaveResult<PeerConfiguration> {
|
||||||
|
fieldsWithError.removeAll()
|
||||||
|
guard let publicKeyString = scratchpad[.publicKey] else {
|
||||||
|
fieldsWithError.insert(.publicKey)
|
||||||
|
return .error("Peer's public key is required")
|
||||||
|
}
|
||||||
|
guard let publicKey = Data(base64Encoded: publicKeyString), publicKey.count == 32 else {
|
||||||
|
fieldsWithError.insert(.publicKey)
|
||||||
|
return .error("Peer's public key should be a 32-byte key in base64 encoding")
|
||||||
|
}
|
||||||
|
var config = PeerConfiguration(publicKey: publicKey)
|
||||||
|
var errorMessages: [String] = []
|
||||||
|
if let preSharedKeyString = scratchpad[.preSharedKey] {
|
||||||
|
if let preSharedKey = Data(base64Encoded: preSharedKeyString), preSharedKey.count == 32 {
|
||||||
|
config.preSharedKey = preSharedKey
|
||||||
|
} else {
|
||||||
|
fieldsWithError.insert(.preSharedKey)
|
||||||
|
errorMessages.append("Peer's pre-shared key should be a 32-byte key in base64 encoding")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let allowedIPsString = scratchpad[.allowedIPs] {
|
||||||
|
var allowedIPs: [IPAddressRange] = []
|
||||||
|
for allowedIPString in allowedIPsString.split(separator: ",") {
|
||||||
|
let trimmedString = allowedIPString.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
||||||
|
if let allowedIP = IPAddressRange(from: trimmedString) {
|
||||||
|
allowedIPs.append(allowedIP)
|
||||||
|
} else {
|
||||||
|
fieldsWithError.insert(.allowedIPs)
|
||||||
|
errorMessages.append("Peer's allowedIPs should be a list of comma-separated IP addresses in CIDR notation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config.allowedIPs = allowedIPs
|
||||||
|
}
|
||||||
|
if let endpointString = scratchpad[.endpoint] {
|
||||||
|
if let endpoint = Endpoint(from: endpointString) {
|
||||||
|
config.endpoint = endpoint
|
||||||
|
} else {
|
||||||
|
fieldsWithError.insert(.endpoint)
|
||||||
|
errorMessages.append("Peer's endpoint should be of the form 'host:port' or '[host]:port'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let persistentKeepAliveString = scratchpad[.persistentKeepAlive] {
|
||||||
|
if let persistentKeepAlive = UInt64(persistentKeepAliveString) {
|
||||||
|
config.persistentKeepAlive = persistentKeepAlive
|
||||||
|
} else {
|
||||||
|
fieldsWithError.insert(.persistentKeepAlive)
|
||||||
|
errorMessages.append("Peer's persistent keepalive should be a number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard (errorMessages.isEmpty) else {
|
||||||
|
return .error(errorMessages.first!)
|
||||||
|
}
|
||||||
|
validatedConfiguration = config
|
||||||
|
return .saved(config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SaveResult<Configuration> {
|
||||||
|
case saved(Configuration)
|
||||||
|
case error(String) // TODO: Localize error messages
|
||||||
|
}
|
||||||
|
|
||||||
|
var interfaceData: InterfaceData
|
||||||
|
var peersData: [PeerData]
|
||||||
|
|
||||||
|
init(tunnelConfiguration: TunnelConfiguration?) {
|
||||||
|
interfaceData = InterfaceData()
|
||||||
|
peersData = []
|
||||||
|
if let tunnelConfiguration = tunnelConfiguration {
|
||||||
|
interfaceData.validatedConfiguration = tunnelConfiguration.interface
|
||||||
|
for (i, peerConfiguration) in tunnelConfiguration.peers.enumerated() {
|
||||||
|
let peerData = PeerData(index: i)
|
||||||
|
peerData.validatedConfiguration = peerConfiguration
|
||||||
|
peersData.append(peerData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendEmptyPeer() {
|
||||||
|
let peer = PeerData(index: peersData.count)
|
||||||
|
peersData.append(peer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deletePeer(peer: PeerData) {
|
||||||
|
let removedPeer = peersData.remove(at: peer.index)
|
||||||
|
assert(removedPeer.index == peer.index)
|
||||||
|
for p in peersData[peer.index ..< peersData.count] {
|
||||||
|
assert(p.index > 0)
|
||||||
|
p.index = p.index - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() -> SaveResult<TunnelConfiguration> {
|
||||||
|
// Attempt to save the interface and all peers, so that all erroring fields are collected
|
||||||
|
let interfaceSaveResult = interfaceData.save()
|
||||||
|
let peerSaveResults = peersData.map { $0.save() }
|
||||||
|
// Collate the results
|
||||||
|
switch (interfaceSaveResult) {
|
||||||
|
case .error(let errorMessage):
|
||||||
|
return .error(errorMessage)
|
||||||
|
case .saved(let interfaceConfiguration):
|
||||||
|
var peerConfigurations: [PeerConfiguration] = []
|
||||||
|
peerConfigurations.reserveCapacity(peerSaveResults.count)
|
||||||
|
for peerSaveResult in peerSaveResults {
|
||||||
|
switch (peerSaveResult) {
|
||||||
|
case .error(let errorMessage):
|
||||||
|
return .error(errorMessage)
|
||||||
|
case .saved(let peerConfiguration):
|
||||||
|
peerConfigurations.append(peerConfiguration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tunnelConfiguration = TunnelConfiguration(interface: interfaceConfiguration)
|
||||||
|
tunnelConfiguration.peers = peerConfigurations
|
||||||
|
return .saved(tunnelConfiguration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,256 +12,23 @@ import UIKit
|
||||||
|
|
||||||
class TunnelEditTableViewController: UITableViewController {
|
class TunnelEditTableViewController: UITableViewController {
|
||||||
|
|
||||||
// MARK: View model
|
let interfaceEditFieldsBySection: [[TunnelViewModel.InterfaceEditField]] = [
|
||||||
|
|
||||||
enum InterfaceEditField: String {
|
|
||||||
case name = "Name"
|
|
||||||
case privateKey = "Private key"
|
|
||||||
case publicKey = "Public key"
|
|
||||||
case generateKeyPair = "Generate keypair"
|
|
||||||
case addresses = "Addresses"
|
|
||||||
case listenPort = "Listen port"
|
|
||||||
case mtu = "MTU"
|
|
||||||
case dns = "DNS servers"
|
|
||||||
}
|
|
||||||
|
|
||||||
let interfaceEditFieldsBySection: [[InterfaceEditField]] = [
|
|
||||||
[.name],
|
[.name],
|
||||||
[.privateKey, .publicKey, .generateKeyPair],
|
[.privateKey, .publicKey, .generateKeyPair],
|
||||||
[.addresses, .listenPort, .mtu, .dns]
|
[.addresses, .listenPort, .mtu, .dns]
|
||||||
]
|
]
|
||||||
|
|
||||||
enum PeerEditField: String {
|
let peerEditFieldsBySection: [[TunnelViewModel.PeerEditField]] = [
|
||||||
case publicKey = "Public key"
|
|
||||||
case preSharedKey = "Pre-shared key"
|
|
||||||
case endpoint = "Endpoint"
|
|
||||||
case persistentKeepAlive = "Persistent Keepalive"
|
|
||||||
case allowedIPs = "Allowed IPs"
|
|
||||||
case excludePrivateIPs = "Exclude private IPs"
|
|
||||||
case deletePeer = "Delete peer"
|
|
||||||
}
|
|
||||||
|
|
||||||
let peerEditFieldsBySection: [[PeerEditField]] = [
|
|
||||||
[.publicKey, .preSharedKey, .endpoint,
|
[.publicKey, .preSharedKey, .endpoint,
|
||||||
.allowedIPs, .excludePrivateIPs,
|
.allowedIPs, .excludePrivateIPs,
|
||||||
.persistentKeepAlive,
|
.persistentKeepAlive,
|
||||||
.deletePeer]
|
.deletePeer]
|
||||||
]
|
]
|
||||||
|
|
||||||
// Scratchpad for entered data
|
let tunnelViewModel: TunnelViewModel
|
||||||
|
|
||||||
class InterfaceData {
|
|
||||||
var scratchpad: [InterfaceEditField: String] = [:]
|
|
||||||
var fieldsWithError: Set<InterfaceEditField> = []
|
|
||||||
var validatedConfiguration: InterfaceConfiguration? = nil
|
|
||||||
subscript(field: InterfaceEditField) -> String {
|
|
||||||
get {
|
|
||||||
ensureScratchpadIsPrepared() // When starting to read a config, setup the scratchpad to serve as a cache
|
|
||||||
return scratchpad[field] ?? ""
|
|
||||||
}
|
|
||||||
set(stringValue) {
|
|
||||||
ensureScratchpadIsPrepared() // When starting to edit a config, setup the scratchpad
|
|
||||||
validatedConfiguration = nil // The configuration will need to be revalidated
|
|
||||||
if (stringValue.isEmpty) {
|
|
||||||
scratchpad.removeValue(forKey: field)
|
|
||||||
} else {
|
|
||||||
scratchpad[field] = stringValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func ensureScratchpadIsPrepared() {
|
|
||||||
guard (scratchpad.isEmpty) else { return } // Already prepared
|
|
||||||
guard let config = validatedConfiguration else { return } // Nothing to prepare it with
|
|
||||||
scratchpad[.name] = config.name
|
|
||||||
scratchpad[.privateKey] = config.privateKey.base64EncodedString()
|
|
||||||
if (!config.addresses.isEmpty) {
|
|
||||||
scratchpad[.addresses] = config.addresses.map { $0.stringRepresentation() }.joined(separator: ", ")
|
|
||||||
}
|
|
||||||
if let listenPort = config.listenPort {
|
|
||||||
scratchpad[.listenPort] = String(listenPort)
|
|
||||||
}
|
|
||||||
if let mtu = config.mtu {
|
|
||||||
scratchpad[.mtu] = String(mtu)
|
|
||||||
}
|
|
||||||
if let dns = config.dns {
|
|
||||||
scratchpad[.dns] = String(dns)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func validate() -> (success: Bool, errorMessage: String) {
|
|
||||||
var firstErrorMessage: String? = nil
|
|
||||||
func setErrorMessage(_ errorMessage: String) {
|
|
||||||
if (firstErrorMessage == nil) {
|
|
||||||
firstErrorMessage = errorMessage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fieldsWithError.removeAll()
|
|
||||||
guard let name = scratchpad[.name] else {
|
|
||||||
fieldsWithError.insert(.name)
|
|
||||||
return(false, "Interface name is required")
|
|
||||||
}
|
|
||||||
guard let privateKeyString = scratchpad[.privateKey] else {
|
|
||||||
fieldsWithError.insert(.privateKey)
|
|
||||||
return (false, "Interface's private key is required")
|
|
||||||
}
|
|
||||||
guard let privateKey = Data(base64Encoded: privateKeyString), privateKey.count == 32 else {
|
|
||||||
fieldsWithError.insert(.privateKey)
|
|
||||||
return(false, "Interface's private key should be a 32-byte key in base64 encoding")
|
|
||||||
}
|
|
||||||
var config = InterfaceConfiguration(name: name, privateKey: privateKey)
|
|
||||||
if let addressesString = scratchpad[.addresses] {
|
|
||||||
var addresses: [IPAddressRange] = []
|
|
||||||
for addressString in addressesString.split(separator: ",") {
|
|
||||||
let trimmedString = addressString.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
|
||||||
if let address = IPAddressRange(from: trimmedString) {
|
|
||||||
addresses.append(address)
|
|
||||||
} else {
|
|
||||||
fieldsWithError.insert(.addresses)
|
|
||||||
setErrorMessage("Interface addresses should be a list of comma-separated IP addresses in CIDR notation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config.addresses = addresses
|
|
||||||
}
|
|
||||||
if let listenPortString = scratchpad[.listenPort] {
|
|
||||||
if let listenPort = UInt64(listenPortString) {
|
|
||||||
config.listenPort = listenPort
|
|
||||||
} else {
|
|
||||||
fieldsWithError.insert(.listenPort)
|
|
||||||
setErrorMessage("Interface's listen port should be a number")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let mtuString = scratchpad[.mtu] {
|
|
||||||
if let mtu = UInt64(mtuString) {
|
|
||||||
config.mtu = mtu
|
|
||||||
} else {
|
|
||||||
fieldsWithError.insert(.mtu)
|
|
||||||
setErrorMessage("Interface's MTU should be a number")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO: Validate DNS
|
|
||||||
if let dnsString = scratchpad[.dns] {
|
|
||||||
config.dns = dnsString
|
|
||||||
}
|
|
||||||
|
|
||||||
if let firstErrorMessage = firstErrorMessage {
|
|
||||||
return (false, firstErrorMessage)
|
|
||||||
}
|
|
||||||
validatedConfiguration = config
|
|
||||||
return (true, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class PeerData {
|
|
||||||
var index: Int
|
|
||||||
var scratchpad: [PeerEditField: String] = [:]
|
|
||||||
var fieldsWithError: Set<PeerEditField> = []
|
|
||||||
var validatedConfiguration: PeerConfiguration? = nil
|
|
||||||
init(index: Int) {
|
|
||||||
self.index = index
|
|
||||||
}
|
|
||||||
subscript(field: PeerEditField) -> String {
|
|
||||||
get {
|
|
||||||
ensureScratchpadIsPrepared() // When starting to read a config, setup the scratchpad to serve as a cache
|
|
||||||
return scratchpad[field] ?? ""
|
|
||||||
}
|
|
||||||
set(stringValue) {
|
|
||||||
ensureScratchpadIsPrepared() // When starting to edit a config, setup the scratchpad
|
|
||||||
validatedConfiguration = nil // The configuration will need to be revalidated
|
|
||||||
if (stringValue.isEmpty) {
|
|
||||||
scratchpad.removeValue(forKey: field)
|
|
||||||
} else {
|
|
||||||
scratchpad[field] = stringValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func ensureScratchpadIsPrepared() {
|
|
||||||
guard (scratchpad.isEmpty) else { return }
|
|
||||||
guard let config = validatedConfiguration else { return }
|
|
||||||
scratchpad[.publicKey] = config.publicKey.base64EncodedString()
|
|
||||||
if let preSharedKey = config.preSharedKey {
|
|
||||||
scratchpad[.preSharedKey] = preSharedKey.base64EncodedString()
|
|
||||||
}
|
|
||||||
if (!config.allowedIPs.isEmpty) {
|
|
||||||
scratchpad[.allowedIPs] = config.allowedIPs.map { $0.stringRepresentation() }.joined(separator: ", ")
|
|
||||||
}
|
|
||||||
if let endpoint = config.endpoint {
|
|
||||||
scratchpad[.endpoint] = endpoint.stringRepresentation()
|
|
||||||
}
|
|
||||||
if let persistentKeepAlive = config.persistentKeepAlive {
|
|
||||||
scratchpad[.persistentKeepAlive] = String(persistentKeepAlive)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func validate() -> (success: Bool, errorMessage: String) {
|
|
||||||
var firstErrorMessage: String? = nil
|
|
||||||
func setErrorMessage(_ errorMessage: String) {
|
|
||||||
if (firstErrorMessage == nil) {
|
|
||||||
firstErrorMessage = errorMessage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fieldsWithError.removeAll()
|
|
||||||
guard let publicKeyString = scratchpad[.publicKey] else {
|
|
||||||
fieldsWithError.insert(.publicKey)
|
|
||||||
return (success: false, errorMessage: "Peer's public key is required")
|
|
||||||
}
|
|
||||||
guard let publicKey = Data(base64Encoded: publicKeyString), publicKey.count == 32 else {
|
|
||||||
fieldsWithError.insert(.publicKey)
|
|
||||||
return (success: false, errorMessage: "Peer's public key should be a 32-byte key in base64 encoding")
|
|
||||||
}
|
|
||||||
var config = PeerConfiguration(publicKey: publicKey)
|
|
||||||
if let preSharedKeyString = scratchpad[.publicKey] {
|
|
||||||
if let preSharedKey = Data(base64Encoded: preSharedKeyString), preSharedKey.count == 32 {
|
|
||||||
config.preSharedKey = preSharedKey
|
|
||||||
} else {
|
|
||||||
fieldsWithError.insert(.preSharedKey)
|
|
||||||
setErrorMessage("Peer's pre-shared key should be a 32-byte key in base64 encoding")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let allowedIPsString = scratchpad[.allowedIPs] {
|
|
||||||
var allowedIPs: [IPAddressRange] = []
|
|
||||||
for allowedIPString in allowedIPsString.split(separator: ",") {
|
|
||||||
let trimmedString = allowedIPString.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
|
|
||||||
if let allowedIP = IPAddressRange(from: trimmedString) {
|
|
||||||
allowedIPs.append(allowedIP)
|
|
||||||
} else {
|
|
||||||
fieldsWithError.insert(.allowedIPs)
|
|
||||||
setErrorMessage("Peer's allowedIPs should be a list of comma-separated IP addresses in CIDR notation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config.allowedIPs = allowedIPs
|
|
||||||
}
|
|
||||||
if let endpointString = scratchpad[.endpoint] {
|
|
||||||
if let endpoint = Endpoint(from: endpointString) {
|
|
||||||
config.endpoint = endpoint
|
|
||||||
} else {
|
|
||||||
fieldsWithError.insert(.endpoint)
|
|
||||||
setErrorMessage("Peer's endpoint should be of the form 'host:port' or '[host]:port'")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let persistentKeepAliveString = scratchpad[.persistentKeepAlive] {
|
|
||||||
if let persistentKeepAlive = UInt64(persistentKeepAliveString) {
|
|
||||||
config.persistentKeepAlive = persistentKeepAlive
|
|
||||||
} else {
|
|
||||||
fieldsWithError.insert(.persistentKeepAlive)
|
|
||||||
setErrorMessage("Peer's persistent keepalive should be a number")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let firstErrorMessage = firstErrorMessage {
|
|
||||||
return (false, firstErrorMessage)
|
|
||||||
}
|
|
||||||
validatedConfiguration = config
|
|
||||||
scratchpad = [:]
|
|
||||||
return (true, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var interfaceData: InterfaceData
|
|
||||||
var peersData: [PeerData]
|
|
||||||
|
|
||||||
// MARK: TunnelEditTableViewController methods
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
interfaceData = InterfaceData()
|
tunnelViewModel = TunnelViewModel(tunnelConfiguration: nil)
|
||||||
peersData = []
|
|
||||||
super.init(style: .grouped)
|
super.init(style: .grouped)
|
||||||
self.modalPresentationStyle = .formSheet
|
self.modalPresentationStyle = .formSheet
|
||||||
}
|
}
|
||||||
|
@ -299,7 +66,7 @@ extension TunnelEditTableViewController {
|
||||||
override func numberOfSections(in tableView: UITableView) -> Int {
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
||||||
let numberOfInterfaceSections = interfaceEditFieldsBySection.count
|
let numberOfInterfaceSections = interfaceEditFieldsBySection.count
|
||||||
let numberOfPeerSections = peerEditFieldsBySection.count
|
let numberOfPeerSections = peerEditFieldsBySection.count
|
||||||
let numberOfPeers = peersData.count
|
let numberOfPeers = tunnelViewModel.peersData.count
|
||||||
|
|
||||||
return numberOfInterfaceSections + (numberOfPeers * numberOfPeerSections) + 1
|
return numberOfInterfaceSections + (numberOfPeers * numberOfPeerSections) + 1
|
||||||
}
|
}
|
||||||
|
@ -307,7 +74,7 @@ extension TunnelEditTableViewController {
|
||||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||||
let numberOfInterfaceSections = interfaceEditFieldsBySection.count
|
let numberOfInterfaceSections = interfaceEditFieldsBySection.count
|
||||||
let numberOfPeerSections = peerEditFieldsBySection.count
|
let numberOfPeerSections = peerEditFieldsBySection.count
|
||||||
let numberOfPeers = peersData.count
|
let numberOfPeers = tunnelViewModel.peersData.count
|
||||||
|
|
||||||
if (section < numberOfInterfaceSections) {
|
if (section < numberOfInterfaceSections) {
|
||||||
// Interface
|
// Interface
|
||||||
|
@ -325,7 +92,7 @@ extension TunnelEditTableViewController {
|
||||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||||
let numberOfInterfaceSections = interfaceEditFieldsBySection.count
|
let numberOfInterfaceSections = interfaceEditFieldsBySection.count
|
||||||
let numberOfPeerSections = peerEditFieldsBySection.count
|
let numberOfPeerSections = peerEditFieldsBySection.count
|
||||||
let numberOfPeers = peersData.count
|
let numberOfPeers = tunnelViewModel.peersData.count
|
||||||
|
|
||||||
if (section < numberOfInterfaceSections) {
|
if (section < numberOfInterfaceSections) {
|
||||||
// Interface
|
// Interface
|
||||||
|
@ -343,13 +110,14 @@ extension TunnelEditTableViewController {
|
||||||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||||
let numberOfInterfaceSections = interfaceEditFieldsBySection.count
|
let numberOfInterfaceSections = interfaceEditFieldsBySection.count
|
||||||
let numberOfPeerSections = peerEditFieldsBySection.count
|
let numberOfPeerSections = peerEditFieldsBySection.count
|
||||||
let numberOfPeers = peersData.count
|
let numberOfPeers = tunnelViewModel.peersData.count
|
||||||
|
|
||||||
let section = indexPath.section
|
let section = indexPath.section
|
||||||
let row = indexPath.row
|
let row = indexPath.row
|
||||||
|
|
||||||
if (section < numberOfInterfaceSections) {
|
if (section < numberOfInterfaceSections) {
|
||||||
// Interface
|
// Interface
|
||||||
|
let interfaceData = tunnelViewModel.interfaceData
|
||||||
let field = interfaceEditFieldsBySection[section][row]
|
let field = interfaceEditFieldsBySection[section][row]
|
||||||
if (field == .generateKeyPair) {
|
if (field == .generateKeyPair) {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelsEditTableViewButtonCell.id, for: indexPath) as! TunnelsEditTableViewButtonCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelsEditTableViewButtonCell.id, for: indexPath) as! TunnelsEditTableViewButtonCell
|
||||||
|
@ -360,49 +128,18 @@ extension TunnelEditTableViewController {
|
||||||
return cell
|
return cell
|
||||||
} else {
|
} else {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelsEditTableViewKeyValueCell.id, for: indexPath) as! TunnelsEditTableViewKeyValueCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelsEditTableViewKeyValueCell.id, for: indexPath) as! TunnelsEditTableViewKeyValueCell
|
||||||
|
// Set key
|
||||||
cell.key = field.rawValue
|
cell.key = field.rawValue
|
||||||
switch (field) {
|
// Set placeholder text
|
||||||
case .name:
|
if (field == .name || field == .privateKey) {
|
||||||
cell.placeholderText = "Required"
|
cell.placeholderText = "Required"
|
||||||
cell.value = interfaceData[.name]
|
} else if (field == .mtu) {
|
||||||
cell.onValueChanged = { [weak interfaceData] value in
|
|
||||||
interfaceData?[.name] = value
|
|
||||||
}
|
|
||||||
case .privateKey:
|
|
||||||
cell.placeholderText = "Required"
|
|
||||||
cell.value = interfaceData[.privateKey]
|
|
||||||
cell.onValueChanged = { [weak interfaceData] value in
|
|
||||||
interfaceData?[.privateKey] = value
|
|
||||||
}
|
|
||||||
case .publicKey:
|
|
||||||
cell.isValueEditable = false
|
|
||||||
cell.value = "Unimplemented"
|
|
||||||
case .generateKeyPair:
|
|
||||||
break
|
|
||||||
case .addresses:
|
|
||||||
cell.value = interfaceData[.addresses]
|
|
||||||
cell.onValueChanged = { [weak interfaceData] value in
|
|
||||||
interfaceData?[.addresses] = value
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case .listenPort:
|
|
||||||
cell.value = interfaceData[.listenPort]
|
|
||||||
cell.onValueChanged = { [weak interfaceData] value in
|
|
||||||
interfaceData?[.listenPort] = value
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case .mtu:
|
|
||||||
cell.placeholderText = "Automatic"
|
cell.placeholderText = "Automatic"
|
||||||
cell.value = interfaceData[.mtu]
|
|
||||||
cell.onValueChanged = { [weak interfaceData] value in
|
|
||||||
interfaceData?[.mtu] = value
|
|
||||||
}
|
}
|
||||||
case .dns:
|
// Bind values to view model
|
||||||
cell.value = interfaceData[.dns]
|
cell.value = interfaceData[field]
|
||||||
cell.onValueChanged = { [weak interfaceData] value in
|
cell.onValueChanged = { [weak interfaceData] value in
|
||||||
interfaceData?[.dns] = value
|
interfaceData?[field] = value
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
@ -410,7 +147,7 @@ extension TunnelEditTableViewController {
|
||||||
// Peer
|
// Peer
|
||||||
let peerIndex = Int((section - numberOfInterfaceSections) / numberOfPeerSections)
|
let peerIndex = Int((section - numberOfInterfaceSections) / numberOfPeerSections)
|
||||||
let peerSectionIndex = (section - numberOfInterfaceSections) % numberOfPeerSections
|
let peerSectionIndex = (section - numberOfInterfaceSections) % numberOfPeerSections
|
||||||
let peerData = peersData[peerIndex]
|
let peerData = tunnelViewModel.peersData[peerIndex]
|
||||||
let field = peerEditFieldsBySection[peerSectionIndex][row]
|
let field = peerEditFieldsBySection[peerSectionIndex][row]
|
||||||
if (field == .deletePeer) {
|
if (field == .deletePeer) {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelsEditTableViewButtonCell.id, for: indexPath) as! TunnelsEditTableViewButtonCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelsEditTableViewButtonCell.id, for: indexPath) as! TunnelsEditTableViewButtonCell
|
||||||
|
@ -433,42 +170,16 @@ extension TunnelEditTableViewController {
|
||||||
return cell
|
return cell
|
||||||
} else {
|
} else {
|
||||||
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelsEditTableViewKeyValueCell.id, for: indexPath) as! TunnelsEditTableViewKeyValueCell
|
let cell = tableView.dequeueReusableCell(withIdentifier: TunnelsEditTableViewKeyValueCell.id, for: indexPath) as! TunnelsEditTableViewKeyValueCell
|
||||||
|
// Set key
|
||||||
cell.key = field.rawValue
|
cell.key = field.rawValue
|
||||||
switch (field) {
|
// Set placeholder text
|
||||||
case .publicKey:
|
if (field == .publicKey) {
|
||||||
cell.placeholderText = "Required"
|
cell.placeholderText = "Required"
|
||||||
cell.value = peerData[.publicKey]
|
|
||||||
cell.onValueChanged = { [weak peerData] value in
|
|
||||||
peerData?[.publicKey] = value
|
|
||||||
}
|
}
|
||||||
case .preSharedKey:
|
// Bind values to view model
|
||||||
cell.value = peerData[.preSharedKey]
|
cell.value = peerData[field]
|
||||||
cell.onValueChanged = { [weak peerData] value in
|
cell.onValueChanged = { [weak peerData] value in
|
||||||
peerData?[.preSharedKey] = value
|
peerData?[field] = value
|
||||||
}
|
|
||||||
break
|
|
||||||
case .endpoint:
|
|
||||||
cell.value = peerData[.endpoint]
|
|
||||||
cell.onValueChanged = { [weak peerData] value in
|
|
||||||
peerData?[.endpoint] = value
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case .persistentKeepAlive:
|
|
||||||
cell.value = peerData[.persistentKeepAlive]
|
|
||||||
cell.onValueChanged = { [weak peerData] value in
|
|
||||||
peerData?[.persistentKeepAlive] = value
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case .allowedIPs:
|
|
||||||
cell.value = peerData[.allowedIPs]
|
|
||||||
cell.onValueChanged = { [weak peerData] value in
|
|
||||||
peerData?[.allowedIPs] = value
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case .excludePrivateIPs:
|
|
||||||
break
|
|
||||||
case .deletePeer:
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
@ -489,29 +200,21 @@ extension TunnelEditTableViewController {
|
||||||
func appendEmptyPeer() -> IndexSet {
|
func appendEmptyPeer() -> IndexSet {
|
||||||
let numberOfInterfaceSections = interfaceEditFieldsBySection.count
|
let numberOfInterfaceSections = interfaceEditFieldsBySection.count
|
||||||
let numberOfPeerSections = peerEditFieldsBySection.count
|
let numberOfPeerSections = peerEditFieldsBySection.count
|
||||||
let numberOfPeers = peersData.count
|
|
||||||
|
|
||||||
let peer = PeerData(index: peersData.count)
|
tunnelViewModel.appendEmptyPeer()
|
||||||
peersData.append(peer)
|
let addedPeerIndex = tunnelViewModel.peersData.count - 1
|
||||||
|
|
||||||
let firstAddedSectionIndex = (numberOfInterfaceSections + numberOfPeers * numberOfPeerSections)
|
let firstAddedSectionIndex = (numberOfInterfaceSections + addedPeerIndex * numberOfPeerSections)
|
||||||
let addedSectionIndices = IndexSet(integersIn: firstAddedSectionIndex ..< firstAddedSectionIndex + numberOfPeerSections)
|
let addedSectionIndices = IndexSet(integersIn: firstAddedSectionIndex ..< firstAddedSectionIndex + numberOfPeerSections)
|
||||||
return addedSectionIndices
|
return addedSectionIndices
|
||||||
}
|
}
|
||||||
|
|
||||||
func deletePeer(peer: PeerData) -> IndexSet {
|
func deletePeer(peer: TunnelViewModel.PeerData) -> IndexSet {
|
||||||
let numberOfInterfaceSections = interfaceEditFieldsBySection.count
|
let numberOfInterfaceSections = interfaceEditFieldsBySection.count
|
||||||
let numberOfPeerSections = peerEditFieldsBySection.count
|
let numberOfPeerSections = peerEditFieldsBySection.count
|
||||||
let numberOfPeers = peersData.count
|
|
||||||
|
|
||||||
assert(peer.index < numberOfPeers)
|
assert(peer.index < tunnelViewModel.peersData.count)
|
||||||
|
tunnelViewModel.deletePeer(peer: peer)
|
||||||
let removedPeer = peersData.remove(at: peer.index)
|
|
||||||
assert(removedPeer.index == peer.index)
|
|
||||||
for p in peersData[peer.index ..< peersData.count] {
|
|
||||||
assert(p.index > 0)
|
|
||||||
p.index = p.index - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
let firstRemovedSectionIndex = (numberOfInterfaceSections + peer.index * numberOfPeerSections)
|
let firstRemovedSectionIndex = (numberOfInterfaceSections + peer.index * numberOfPeerSections)
|
||||||
let removedSectionIndices = IndexSet(integersIn: firstRemovedSectionIndex ..< firstRemovedSectionIndex + numberOfPeerSections)
|
let removedSectionIndices = IndexSet(integersIn: firstRemovedSectionIndex ..< firstRemovedSectionIndex + numberOfPeerSections)
|
||||||
|
|
Loading…
Reference in New Issue