diff --git a/ios/vpn/NewNode VPN.xcodeproj/project.pbxproj b/ios/vpn/NewNode VPN.xcodeproj/project.pbxproj index 16911fdb..426482b5 100644 --- a/ios/vpn/NewNode VPN.xcodeproj/project.pbxproj +++ b/ios/vpn/NewNode VPN.xcodeproj/project.pbxproj @@ -37,6 +37,8 @@ 3CA15F262692D91B00924A2F /* MMWormholeSession.m in Sources */ = {isa = PBXBuildFile; fileRef = 3CA15F172692D91B00924A2F /* MMWormholeSession.m */; }; 3CA15F272692D91B00924A2F /* MMWormholeCoordinatedFileTransiting.m in Sources */ = {isa = PBXBuildFile; fileRef = 3CA15F192692D91B00924A2F /* MMWormholeCoordinatedFileTransiting.m */; }; 3CA15F282692D91B00924A2F /* MMWormholeCoordinatedFileTransiting.m in Sources */ = {isa = PBXBuildFile; fileRef = 3CA15F192692D91B00924A2F /* MMWormholeCoordinatedFileTransiting.m */; }; + 883006382963526F008D495F /* NetworkExtension+NewNode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 883006362963526F008D495F /* NetworkExtension+NewNode.swift */; }; + 883006392963526F008D495F /* TunnelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 883006372963526F008D495F /* TunnelManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -111,6 +113,8 @@ 3CA15F182692D91B00924A2F /* MMWormholeFileTransiting.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = MMWormholeFileTransiting.h; path = MMWormhole/Source/MMWormholeFileTransiting.h; sourceTree = ""; }; 3CA15F192692D91B00924A2F /* MMWormholeCoordinatedFileTransiting.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = MMWormholeCoordinatedFileTransiting.m; path = MMWormhole/Source/MMWormholeCoordinatedFileTransiting.m; sourceTree = ""; }; 3CA15F1A2692D91B00924A2F /* MMWormholeSessionContextTransiting.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = MMWormholeSessionContextTransiting.h; path = MMWormhole/Source/MMWormholeSessionContextTransiting.h; sourceTree = ""; }; + 883006362963526F008D495F /* NetworkExtension+NewNode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NetworkExtension+NewNode.swift"; sourceTree = ""; }; + 883006372963526F008D495F /* TunnelManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TunnelManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -167,6 +171,8 @@ 3C50125F22D4AF5100F8487C /* Info.plist */, 3C50125322D4AF5000F8487C /* AppDelegate.swift */, 3C50125522D4AF5000F8487C /* ViewController.swift */, + 883006362963526F008D495F /* NetworkExtension+NewNode.swift */, + 883006372963526F008D495F /* TunnelManager.swift */, 0F5C8ABB2664FB8800D013D7 /* InfoViewController.swift */, 0FBB0AF92661789F00D89502 /* GradientView.swift */, 0F0A7FF22667C9F20093B85D /* FontUpdater.swift */, @@ -327,11 +333,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 883006392963526F008D495F /* TunnelManager.swift in Sources */, 3C50125622D4AF5000F8487C /* ViewController.swift in Sources */, 3C50125422D4AF5000F8487C /* AppDelegate.swift in Sources */, 3CA15F212692D91B00924A2F /* MMWormholeSessionContextTransiting.m in Sources */, 3CA15F1D2692D91B00924A2F /* MMWormholeSessionMessageTransiting.m in Sources */, 3CA15F1B2692D91B00924A2F /* MMWormholeFileTransiting.m in Sources */, + 883006382963526F008D495F /* NetworkExtension+NewNode.swift in Sources */, 3CA15F252692D91B00924A2F /* MMWormholeSession.m in Sources */, 0F5C8ABC2664FB8800D013D7 /* InfoViewController.swift in Sources */, 3CA15F232692D91B00924A2F /* MMWormholeSessionFileTransiting.m in Sources */, diff --git a/ios/vpn/NewNode VPN/NetworkExtension+NewNode.swift b/ios/vpn/NewNode VPN/NetworkExtension+NewNode.swift new file mode 100644 index 00000000..6ae9cafb --- /dev/null +++ b/ios/vpn/NewNode VPN/NetworkExtension+NewNode.swift @@ -0,0 +1,83 @@ +// +// NetworkExtension+NewNode.swift +// NewNode VPN +// +// Created by Anton Ilinykh on 02.01.2023. +// Copyright © 2023 Clostra. All rights reserved. +// + +import Foundation +import NetworkExtension +import os.log + + +extension NETunnelProviderManager { + static func loadManager(completion: @escaping (Result) -> Void) { + loadAllFromPreferences { managers, error in + guard let managers = managers else { + let error = error ?? NSError(domain: "unknown error", code: 0) + os_log("failed to load managers: %@", type: .error, error.localizedDescription) + completion(.failure(error)) + return + } + + if let manager = managers.first { + completion(.success(manager)) + } else { + let manager = NETunnelProviderManager() + let providerProtocol = NETunnelProviderProtocol() + providerProtocol.providerBundleIdentifier = "com.newnode.vpn.tunnel" + providerProtocol.serverAddress = "NewNode" + manager.protocolConfiguration = providerProtocol + manager.isEnabled = true + manager.localizedDescription = "NewNode" + + manager.saveToPreferences { error in + if let error = error { + os_log("failed to save to preferences: %@", type: .error, error.localizedDescription) + completion(.failure(error)) + return + } + manager.loadFromPreferences { error in + if let error = error { + os_log("failed to load from preferences: %@", type: .error, error.localizedDescription) + completion(.failure(error)) + } else { + completion(.success(manager)) + } + } + } + } + } + } +} + + +extension NEVPNStatus { + var description: String { + switch self { + case .invalid: return "invalid" + case .disconnected: return "disconnected" + case .connecting: return "connecting" + case .connected: return "connected" + case .reasserting: return "reconnecting" + case .disconnecting: return "disconnecting" + @unknown default: return "" + } + } +} + + +extension NEVPNError { + var description: String { + switch code { + case .configurationDisabled: return "configurationDisabled" + case .configurationInvalid: return "configurationInvalid" + case .configurationStale: return "configurationStale" + case .configurationUnknown: return "configurationUnknown" + case .connectionFailed: return "connectionFailed" + case .configurationReadWriteFailed: return "configurationReadWriteFailed" + @unknown default: return "unknown" + } + } +} diff --git a/ios/vpn/NewNode VPN/TunnelManager.swift b/ios/vpn/NewNode VPN/TunnelManager.swift new file mode 100644 index 00000000..243dcb37 --- /dev/null +++ b/ios/vpn/NewNode VPN/TunnelManager.swift @@ -0,0 +1,99 @@ +// +// TunnelManager.swift +// NewNode VPN +// +// Created by Anton Ilinykh on 01.01.2023. +// Copyright © 2023 Clostra. All rights reserved. +// + +import Foundation +import NetworkExtension +import os.log + +enum TunnelManagerState: String { + case invalid + case connecting + case connected + case reasserting + case disconnecting + case disconnected +} + +protocol TunnelManagerDelegate { + func tunnelDidChangeState(_ state: TunnelManagerState) +} + +protocol TunnelManager { + func connect() + func disconnect() +} + +final class TunnelManagerImpl: TunnelManager { + private let delegate: TunnelManagerDelegate + private var connection: NEVPNConnection? + + init(delegate: TunnelManagerDelegate) { + self.delegate = delegate + + NotificationCenter.default.addObserver(forName: NSNotification.Name.NEVPNStatusDidChange, object: nil, queue: .main, using: { note in + guard let session = note.object as? NETunnelProviderSession else { + os_log("unexpected vpn status notification state", type: .error) + return + } + os_log("connection state changed to %s", session.status.description) + switch session.status { + case .connecting: + delegate.tunnelDidChangeState(.connecting) + case .connected: + delegate.tunnelDidChangeState(.connected) + case .disconnecting: + delegate.tunnelDidChangeState(.disconnecting) + case .disconnected: + delegate.tunnelDidChangeState(.disconnected) + case .invalid: + delegate.tunnelDidChangeState(.invalid) + case .reasserting: + delegate.tunnelDidChangeState(.reasserting) + @unknown default: + delegate.tunnelDidChangeState(.invalid) + } + }) + } + + func disconnect() { + connection?.stopVPNTunnel() + } + + func connect() { + delegate.tunnelDidChangeState(.connecting) + NETunnelProviderManager.loadManager { [weak self] result in + switch(result) { + case .success(let manager): + do { + try manager.connection.startVPNTunnel() + self?.connection = manager.connection + } catch { + /* The `confugarationDisabled` error ocurrs when the `manager` + * turns to `disabled` state. It may happen when user + * turn on another VPN or toggles the switch in settings + */ + if let err = error as? NEVPNError, err.code == .configurationDisabled { + manager.isEnabled = true + manager.saveToPreferences() { error in + if let error = error { + os_log("failed to save to preferences: %@", type: .error, error.localizedDescription) + } else { + self?.connect() + } + } + } + os_log("failed to start vpn: %@", type: .error, error.localizedDescription) + self?.delegate.tunnelDidChangeState(.invalid) + } + case .failure(let error): + os_log("failed to load manager: %@", type: .error, error.localizedDescription) + self?.delegate.tunnelDidChangeState(.invalid) + } + } + } +} diff --git a/ios/vpn/NewNode VPN/ViewController.swift b/ios/vpn/NewNode VPN/ViewController.swift index e1dfac64..316bf99d 100644 --- a/ios/vpn/NewNode VPN/ViewController.swift +++ b/ios/vpn/NewNode VPN/ViewController.swift @@ -8,9 +8,12 @@ import os.log import UIKit -import Network -import NetworkExtension +extension ViewController: TunnelManagerDelegate { + func tunnelDidChangeState(_ state: TunnelManagerState) { + update(for: state) + } +} class ViewController: UIViewController { @@ -25,9 +28,10 @@ class ViewController: UIViewController { @IBOutlet weak var usageLabel: UILabel! @IBOutlet weak var logo: UIImageView! @IBOutlet var statTexts: [UILabel]! - let monitor = NWPathMonitor() let wormhole = MMWormhole(applicationGroupIdentifier: "group.com.newnode.vpn", optionalDirectory: nil) + private lazy var tunnelManager = TunnelManagerImpl(delegate: self) + var toggleState: Bool { get { return UserDefaults.standard.bool(forKey: "toggle") @@ -49,13 +53,8 @@ class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - updateLayout(animated: false) + updateLayout(on: toggleState, animated: false) - monitor.pathUpdateHandler = { path in - self.update() - } - monitor.start(queue: .main) - wormhole.listenForMessage(withIdentifier: "DisplayStats", listener: { (message) -> Void in if let o = message as? NSDictionary, let direct = o["direct_bytes"] as? UInt64, let peer = o["peers_bytes"] as? UInt64 { self.updateStatistics(direct: direct, peer: peer) @@ -65,14 +64,13 @@ class ViewController: UIViewController { NotificationCenter.default.addObserver(self, selector:#selector(foreground), name: UIApplication.willEnterForegroundNotification, object: nil) - update() + update(for: .disconnected) if toggleState { stateChanged() } } - func updateLayout(animated: Bool) { - let on = toggleState + func updateLayout(on: Bool, animated: Bool) { spinner.alpha = 0.3 let buttonImage = on ? UIImage(named: "power_button_on") : UIImage(named: "power_button_off") self.powerButton.setImage(buttonImage, for: .normal) @@ -138,64 +136,11 @@ class ViewController: UIViewController { NotificationCenter.default.removeObserver(self) } - func waitForStop(_ manager: NETunnelProviderManager) { - manager.loadFromPreferences(completionHandler: { (error: Error?) in - self.update() - if manager.connection.status == .disconnected { - return - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.waitForStop(manager) - } - }) - } - - func enforceState(_ manager: NETunnelProviderManager) { - os_log("manager.connection.status:%@ toggle:%@", - String(manager.connection.status.rawValue), String(toggleState)) + func stateChanged() { if toggleState { - do { - os_log("starting...") - try manager.connection.startVPNTunnel() - } catch { - os_log("Unexpected error %@", error.localizedDescription) - manager.connection.stopVPNTunnel() - self.waitForStop(manager) - } + tunnelManager.connect() } else { - os_log("stopping...") - manager.connection.stopVPNTunnel() - self.waitForStop(manager) - } - } - - func stateChanged() { - NETunnelProviderManager.loadAllFromPreferences { (managers: [NETunnelProviderManager]?, error: Error?) in - guard let managers = managers else { - os_log("loadAllFromPreferences %@", error?.localizedDescription ?? "") - return - } - if managers.count == 0 { - let manager = NETunnelProviderManager() - let providerProtocol = NETunnelProviderProtocol() - providerProtocol.providerBundleIdentifier = "com.newnode.vpn.tunnel" - providerProtocol.serverAddress = "NewNode" - manager.protocolConfiguration = providerProtocol - manager.isEnabled = true - manager.localizedDescription = "NewNode" - - manager.saveToPreferences(completionHandler: { (error: Error?) in - os_log("saveToPreferences %@", error?.localizedDescription ?? "") - manager.loadFromPreferences(completionHandler: { (error: Error?) in - os_log("loadFromPreferences %@", error?.localizedDescription ?? "") - self.enforceState(manager) - }) - }) - } else if managers.count > 0 { - let manager = managers.first! - self.enforceState(manager) - } - self.update() + tunnelManager.disconnect() } } @@ -204,52 +149,21 @@ class ViewController: UIViewController { os_log("didTapSwitch %@ -> %@", String(oldToggleState), String(!oldToggleState)) toggleState = !oldToggleState stateChanged() - updateLayout(animated: true) + updateLayout(on: toggleState, animated: true) } @objc func foreground() { stateChanged() } - func update() { - NETunnelProviderManager.loadAllFromPreferences { (managers: [NETunnelProviderManager]?, error: Error?) in - guard let managers = managers else { - os_log("loadAllFromPreferences %@", error?.localizedDescription ?? "") - return - } - var status: NEVPNStatus = .disconnected - if managers.count > 0 { - let manager = managers.first! - status = manager.connection.status - } - - self.updateLayout(animated: false) - self.statusLabel.text = NSLocalizedString(status.description, comment: "") - - if status.isTransitional { - self.spinner.startAnimating() - } else { - self.spinner.stopAnimating() - } - } - } -} - - -private extension NEVPNStatus { - var isTransitional: Bool { - [.connecting, .reasserting, .disconnecting].contains(self) - } - - var description: String { - switch self { - case .invalid: return "invalid" - case .disconnected: return "disconnected" - case .connecting: return "connecting" - case .connected: return "connected" - case .reasserting: return "reconnecting" - case .disconnecting: return "disconnecting" - @unknown default: return "" + func update(for state: TunnelManagerState) { + updateLayout(on: toggleState, animated: false) + statusLabel.text = NSLocalizedString(state.rawValue, comment: "") + + if [.connecting, .disconnecting, .reasserting].contains(state) { + spinner.startAnimating() + } else { + spinner.stopAnimating() } } }