diff options
31 files changed, 964 insertions, 1191 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index d41d678a23..81453daf7e 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -21,7 +21,6 @@ 58095C4F2760BA9100890776 /* AddressCacheStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58095C4E2760BA9100890776 /* AddressCacheStore.swift */; }; 58095C512760BBB500890776 /* AddressCacheTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58095C502760BBB400890776 /* AddressCacheTracker.swift */; }; 58095C532760EEC700890776 /* RESTNetworkOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58095C522760EEC700890776 /* RESTNetworkOperation.swift */; }; - 58095C552760F02500890776 /* UpdateAddressCacheOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58095C542760F02500890776 /* UpdateAddressCacheOperation.swift */; }; 58095C572760F47900890776 /* api-ip-address.json in Resources */ = {isa = PBXBuildFile; fileRef = 58095C562760F47900890776 /* api-ip-address.json */; }; 58095C592762155700890776 /* RESTRetryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58095C582762155700890776 /* RESTRetryStrategy.swift */; }; 580CBFB82848D503007878F0 /* OperationConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 580CBFB72848D503007878F0 /* OperationConditionTests.swift */; }; @@ -368,7 +367,6 @@ 58095C4E2760BA9100890776 /* AddressCacheStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCacheStore.swift; sourceTree = "<group>"; }; 58095C502760BBB400890776 /* AddressCacheTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCacheTracker.swift; sourceTree = "<group>"; }; 58095C522760EEC700890776 /* RESTNetworkOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTNetworkOperation.swift; sourceTree = "<group>"; }; - 58095C542760F02500890776 /* UpdateAddressCacheOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateAddressCacheOperation.swift; sourceTree = "<group>"; }; 58095C562760F47900890776 /* api-ip-address.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "api-ip-address.json"; sourceTree = "<group>"; }; 58095C582762155700890776 /* RESTRetryStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTRetryStrategy.swift; sourceTree = "<group>"; }; 580CBFB72848D503007878F0 /* OperationConditionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationConditionTests.swift; sourceTree = "<group>"; }; @@ -640,7 +638,6 @@ 58095C4E2760BA9100890776 /* AddressCacheStore.swift */, 58095C4A2760B4F200890776 /* AddressCacheStoreError.swift */, 58095C502760BBB400890776 /* AddressCacheTracker.swift */, - 58095C542760F02500890776 /* UpdateAddressCacheOperation.swift */, ); path = AddressCache; sourceTree = "<group>"; @@ -1430,7 +1427,6 @@ 58B93A1326C3F13600A55733 /* TunnelState.swift in Sources */, 58FEEB46260A028D00A621A8 /* GeoJSON.swift in Sources */, 5815039724D6ECAE00C9C50E /* CustomFormatLogHandler.swift in Sources */, - 58095C552760F02500890776 /* UpdateAddressCacheOperation.swift in Sources */, 5820675E26E6839900655B05 /* PresentAlertOperation.swift in Sources */, 5815039D24D6ECE600C9C50E /* TextFileOutputStream.swift in Sources */, 58CE5E64224146200008646E /* AppDelegate.swift in Sources */, diff --git a/ios/MullvadVPN/AccountViewController.swift b/ios/MullvadVPN/AccountViewController.swift index 4a4dd542ed..9753d8aac9 100644 --- a/ios/MullvadVPN/AccountViewController.swift +++ b/ios/MullvadVPN/AccountViewController.swift @@ -309,6 +309,10 @@ class AccountViewController: UIViewController, AppStorePaymentObserver, TunnelOb // MARK: - TunnelObserver + func tunnelManagerDidLoadConfiguration(_ manager: TunnelManager) { + // no-op + } + func tunnelManager(_ manager: TunnelManager, didUpdateTunnelState tunnelState: TunnelState) { // no-op } diff --git a/ios/MullvadVPN/AddressCache/AddressCacheTracker.swift b/ios/MullvadVPN/AddressCache/AddressCacheTracker.swift index bbfaeb08c8..70397032ca 100644 --- a/ios/MullvadVPN/AddressCache/AddressCacheTracker.swift +++ b/ios/MullvadVPN/AddressCache/AddressCacheTracker.swift @@ -7,11 +7,19 @@ // import UIKit -import BackgroundTasks import Logging extension AddressCache { + class Tracker { + /// Shared instance. + static let shared: AddressCache.Tracker = { + return AddressCache.Tracker( + apiProxy: REST.ProxyFactory.shared.createAPIProxy(), + store: AddressCache.Store.shared + ) + }() + /// Update interval (in seconds). private static let updateInterval: TimeInterval = 60 * 60 * 24 @@ -43,58 +51,71 @@ extension AddressCache { return operationQueue }() - /// Queue used for synchronizing access to instance members. - private let stateQueue = DispatchQueue(label: "AddressCache.Tracker.stateQueue") + /// Lock used for synchronizing member access. + private let nslock = NSLock() /// Designated initializer - init(apiProxy: REST.APIProxy, store: AddressCache.Store) { + private init(apiProxy: REST.APIProxy, store: AddressCache.Store) { self.apiProxy = apiProxy self.store = store } func startPeriodicUpdates() { - stateQueue.async { - guard !self.isPeriodicUpdatesEnabled else { - return - } + nslock.lock() + defer { nslock.unlock() } - self.logger.debug("Start periodic address cache updates") + guard !isPeriodicUpdatesEnabled else { + return + } - self.isPeriodicUpdatesEnabled = true + logger.debug("Start periodic address cache updates.") - let scheduleDate = self.nextScheduleDate() + isPeriodicUpdatesEnabled = true - self.logger.debug("Schedule address cache update on \(scheduleDate.logFormatDate())") + let scheduleDate = _nextScheduleDate() - self.scheduleEndpointsUpdate(startTime: .now() + scheduleDate.timeIntervalSinceNow) - } + logger.debug("Schedule address cache update at \(scheduleDate.logFormatDate()).") + + scheduleEndpointsUpdate(startTime: .now() + scheduleDate.timeIntervalSinceNow) } func stopPeriodicUpdates() { - stateQueue.async { - guard self.isPeriodicUpdatesEnabled else { return } + nslock.lock() + defer { nslock.unlock() } - self.logger.debug("Stop periodic address cache updates") + guard isPeriodicUpdatesEnabled else { return } - self.isPeriodicUpdatesEnabled = false + logger.debug("Stop periodic address cache updates.") - self.timer?.cancel() - self.timer = nil - } + isPeriodicUpdatesEnabled = false + + timer?.cancel() + timer = nil } - func updateEndpoints(completionHandler: ((_ completion: OperationCompletion<CacheUpdateResult, Error>) -> Void)? = nil) -> Cancellable { - let operation = UpdateAddressCacheOperation( - dispatchQueue: stateQueue, - apiProxy: apiProxy, - store: store, - updateInterval: Self.updateInterval, - completionHandler: { [weak self] completion in - self?.handleCacheUpdateCompletion(completion) + func updateEndpoints( + completionHandler: ((OperationCompletion<Bool, Error>) -> Void)? = nil + ) -> Cancellable + { + let operation = ResultBlockOperation<Bool, Error> { operation in + guard self.nextScheduleDate() <= Date() else { + operation.finish(completion: .success(false)) + return + } - completionHandler?(completion) + let task = self.apiProxy.getAddressList(retryStrategy: .default) { completion in + operation.finish( + completion: self.handleResponse(completion: completion) + ) } - ) + + operation.addCancellationBlock { + task.cancel() + } + } + + operation.completionQueue = .main + operation.completionHandler = completionHandler operation.addObserver( BackgroundObserver(name: "Update endpoints", cancelUponExpiration: true) @@ -105,6 +126,45 @@ extension AddressCache { return operation } + func nextScheduleDate() -> Date { + nslock.lock() + defer { nslock.unlock() } + + return _nextScheduleDate() + } + + private func handleResponse( + completion: OperationCompletion<[AnyIPEndpoint], REST.Error> + ) -> OperationCompletion<Bool, Error> + { + let mappedCompletion = completion + .flatMapError { error -> OperationCompletion<[AnyIPEndpoint], REST.Error> in + if case URLError.cancelled = error { + return .cancelled + } else { + return .failure(error) + } + } + .tryMap { endpoints -> Bool in + try store.setEndpoints(endpoints) + + return true + } + + nslock.lock() + lastFailureAttemptDate = mappedCompletion.isSuccess ? nil : Date() + nslock.unlock() + + if let error = mappedCompletion.error { + logger.error( + chainedError: AnyChainedError(error), + message: "Failed to update address cache." + ) + } + + return mappedCompletion + } + private func scheduleEndpointsUpdate(startTime: DispatchWallTime) { let newTimer = DispatchSource.makeTimerSource() newTimer.setEventHandler { [weak self] in @@ -119,104 +179,33 @@ extension AddressCache { } private func handleTimer() { - _ = updateEndpoints { result in + _ = updateEndpoints { _ in + self.nslock.lock() + defer { self.nslock.unlock() } + guard self.isPeriodicUpdatesEnabled else { return } - let scheduleDate = self.nextScheduleDate() + let scheduleDate = self._nextScheduleDate() - self.logger.debug("Schedule next address cache update on \(scheduleDate.logFormatDate())") + self.logger.debug("Schedule next address cache update at \(scheduleDate.logFormatDate()).") self.scheduleEndpointsUpdate(startTime: .now() + scheduleDate.timeIntervalSinceNow) } } - private func nextScheduleDate() -> Date { - if let lastFailureAttemptDate = lastFailureAttemptDate { - return Date(timeInterval: Self.retryInterval, since: lastFailureAttemptDate) - } else { - let updatedAt = store.getLastUpdateDate() - - return Date(timeInterval: Self.updateInterval, since: updatedAt) - } - } - - private func handleCacheUpdateCompletion(_ completion: OperationCompletion<AddressCache.CacheUpdateResult, Error>) { - switch completion { - case .success(let updateResult): - switch updateResult { - case .finished: - logger.debug("Finished updating address cache.") - case .throttled: - logger.debug("Address cache update was throttled.") - } - - lastFailureAttemptDate = nil - - case .failure(let error): - logger.error(chainedError: AnyChainedError(error), message: "Failed to update address cache.") - lastFailureAttemptDate = Date() - - case .cancelled: - logger.debug("Address cache update was cancelled.") - lastFailureAttemptDate = Date() - } - } - - } -} - -// MARK: - Background tasks - -@available(iOS 13.0, *) -extension AddressCache.Tracker { - - /// Register background task with scheduler. - func registerBackgroundTask() { - let taskIdentifier = ApplicationConfiguration.addressCacheUpdateTaskIdentifier - - let isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in - self.handleBackgroundTask(task as! BGProcessingTask) - } - - if isRegistered { - logger.debug("Registered address cache update task") - } else { - logger.error("Failed to register address cache update task") - } - } - - /// Create and submit task request to scheduler. - func scheduleBackgroundTask() throws { - let beginDate = nextScheduleDate() - - logger.debug("Schedule address cache update task on \(beginDate.logFormatDate())") - - let taskIdentifier = ApplicationConfiguration.addressCacheUpdateTaskIdentifier - - let request = BGProcessingTaskRequest(identifier: taskIdentifier) - request.earliestBeginDate = beginDate - request.requiresNetworkConnectivity = true - - return try BGTaskScheduler.shared.submit(request) - } - - /// Background task handler. - private func handleBackgroundTask(_ task: BGProcessingTask) { - logger.debug("Start address cache update task") - - let cancellable = updateEndpoints { completion in - do { - // Schedule next background task - try self.scheduleBackgroundTask() - } catch { - self.logger.error(chainedError: AnyChainedError(error), message: "Failed to schedule next address cache update task") - } + private func _nextScheduleDate() -> Date { + let nextDate = lastFailureAttemptDate.map { date in + return Date( + timeInterval: Self.retryInterval, + since: date + ) + } ?? Date( + timeInterval: Self.updateInterval, + since: store.getLastUpdateDate() + ) - task.setTaskCompleted(success: completion.isSuccess) + return max(nextDate, Date()) } - task.expirationHandler = { - cancellable.cancel() - } } } diff --git a/ios/MullvadVPN/AddressCache/UpdateAddressCacheOperation.swift b/ios/MullvadVPN/AddressCache/UpdateAddressCacheOperation.swift deleted file mode 100644 index 4eaeda18e1..0000000000 --- a/ios/MullvadVPN/AddressCache/UpdateAddressCacheOperation.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// UpdateAddressCacheOperation.swift -// MullvadVPN -// -// Created by pronebird on 08/12/2021. -// Copyright © 2021 Mullvad VPN AB. All rights reserved. -// - -import Foundation - -extension AddressCache { - - enum CacheUpdateResult { - /// Address cache update was throttled as it was requested too early. - case throttled(_ lastUpdateDate: Date) - - /// Address cache is successfully updated. - case finished - } - - class UpdateAddressCacheOperation: ResultOperation<CacheUpdateResult, Error> { - private let apiProxy: REST.APIProxy - private let store: AddressCache.Store - private let updateInterval: TimeInterval - - private var requestTask: Cancellable? - - init( - dispatchQueue: DispatchQueue, - apiProxy: REST.APIProxy, - store: AddressCache.Store, - updateInterval: TimeInterval, - completionHandler: CompletionHandler? - ) - { - self.apiProxy = apiProxy - self.store = store - self.updateInterval = updateInterval - - super.init( - dispatchQueue: dispatchQueue, - completionQueue: dispatchQueue, - completionHandler: completionHandler - ) - } - - override func main() { - let lastUpdate = store.getLastUpdateDate() - let nextUpdate = Date(timeInterval: updateInterval, since: lastUpdate) - - guard nextUpdate <= Date() else { - finish(completion: .success(.throttled(lastUpdate))) - return - } - - requestTask = apiProxy.getAddressList(retryStrategy: .default) { [weak self] completion in - self?.dispatchQueue.async { - self?.handleResponse(completion) - } - } - } - - override func operationDidCancel() { - requestTask?.cancel() - requestTask = nil - } - - private func handleResponse(_ completion: OperationCompletion<[AnyIPEndpoint], REST.Error>) { - let mappedCompletion = completion - .flatMapError { error -> OperationCompletion<[AnyIPEndpoint], Error> in - if case URLError.cancelled = error { - return .cancelled - } else { - return .failure(error) - } - } - .tryMap { endpoints -> CacheUpdateResult in - try store.setEndpoints(endpoints) - - return .finished - } - - finish(completion: mappedCompletion) - } - } -} diff --git a/ios/MullvadVPN/AppDelegate.swift b/ios/MullvadVPN/AppDelegate.swift index 9c853b018b..1084014e3d 100644 --- a/ios/MullvadVPN/AppDelegate.swift +++ b/ios/MullvadVPN/AppDelegate.swift @@ -17,7 +17,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? - private var logger: Logger? + private var logger: Logger! #if targetEnvironment(simulator) private let simulatorTunnelProvider = SimulatorTunnelProviderHost() @@ -36,23 +36,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private var connectController: ConnectViewController? private weak var settingsNavController: SettingsNavigationController? - private lazy var addressCacheTracker: AddressCache.Tracker = { - return AddressCache.Tracker( - apiProxy: REST.ProxyFactory.shared.createAPIProxy(), - store: AddressCache.Store.shared - ) - }() - - private var cachedRelays: RelayCache.CachedRelays? { - didSet { - if let cachedRelays = cachedRelays { - self.selectLocationViewController?.setCachedRelays(cachedRelays) - } - } - } private var relayConstraints: RelayConstraints? - private let notificationManager = NotificationManager() + private let operationQueue: AsyncOperationQueue = { + let operationQueue = AsyncOperationQueue() + operationQueue.maxConcurrentOperationCount = 1 + return operationQueue + }() // MARK: - Application lifecycle @@ -60,7 +50,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Setup logging initLoggingSystem(bundleIdentifier: Bundle.main.bundleIdentifier!) - self.logger = Logger(label: "AppDelegate") + logger = Logger(label: "AppDelegate") #if targetEnvironment(simulator) // Configure mock tunnel provider on simulator @@ -69,54 +59,73 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if #available(iOS 13.0, *) { // Register background tasks on iOS 13 - RelayCache.Tracker.shared.registerAppRefreshTask() - TunnelManager.shared.registerBackgroundTask() - addressCacheTracker.registerBackgroundTask() + registerBackgroundTasks() } else { // Set background refresh interval on iOS 12 - application.setMinimumBackgroundFetchInterval(ApplicationConfiguration.minimumBackgroundFetchInterval) + application.setMinimumBackgroundFetchInterval( + ApplicationConfiguration.minimumBackgroundFetchInterval + ) } - // Assign user notification center delegate - UNUserNotificationCenter.current().delegate = self - - // Create an app window - self.window = UIWindow(frame: UIScreen.main.bounds) - - // Set an empty view controller while loading tunnels - self.window?.rootViewController = LaunchViewController() + setupPaymentHandler() + setupNotificationHandler() // Add relay cache observer RelayCache.Tracker.shared.addObserver(self) - // Load initial relays - RelayCache.Tracker.shared.read { result in - DispatchQueue.main.async { - switch result { - case .success(let cachedRelays): - self.cachedRelays = cachedRelays + // Start initialization + let setupTunnelManagerOperation = AsyncBlockOperation(dispatchQueue: .main) { blockOperation in + TunnelManager.shared.loadConfiguration { error in + dispatchPrecondition(condition: .onQueue(.main)) - case .failure(let error): - self.logger?.error(chainedError: error, message: "Failed to load initial relays") + if let error = error { + self.logger.error(chainedError: error, message: "Failed to load tunnels") + + // TODO: avoid throwing fatal error and show the problem report UI instead. + fatalError( + error.displayChain(message: "Failed to load VPN tunnel configuration") + ) } + + blockOperation.finish() } } - // Load tunnels - TunnelManager.shared.loadConfiguration { error in - dispatchPrecondition(condition: .onQueue(.main)) + let setupUIOperation = AsyncBlockOperation(dispatchQueue: .main) { + self.logger.debug("Finished initialization. Show user interface.") - if let error = error { - self.logger?.error(chainedError: error, message: "Failed to load tunnels") + self.relayConstraints = TunnelManager.shared.tunnelSettings?.relayConstraints - // TODO: avoid throwing fatal error and show the problem report UI instead. - fatalError(error.displayChain(message: "Failed to load VPN tunnel configuration")) - } else { - self.relayConstraints = TunnelManager.shared.tunnelSettings?.relayConstraints - self.didFinishInitialization() + self.rootContainer = RootContainerViewController() + self.rootContainer?.delegate = self + self.window?.rootViewController = self.rootContainer + + switch UIDevice.current.userInterfaceIdiom { + case .pad: + self.setupPadUI() + + case .phone: + self.setupPhoneUI() + + default: + fatalError() } + + NotificationManager.shared.updateNotifications() + AppStorePaymentManager.shared.startPaymentQueueMonitoring() } + operationQueue.addOperations([ + setupTunnelManagerOperation, + setupUIOperation + ], waitUntilFinished: false) + + // Create an app window + self.window = UIWindow(frame: UIScreen.main.bounds) + + // Set an empty view controller while loading tunnels + self.window?.rootViewController = LaunchViewController() + // Show the window self.window?.makeKeyAndVisible() @@ -124,6 +133,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } func applicationDidBecomeActive(_ application: UIApplication) { + // Refresh tunnel status. + TunnelManager.shared.refreshTunnelStatus() + // Start periodic relays updates RelayCache.Tracker.shared.startPeriodicUpdates() @@ -131,7 +143,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { TunnelManager.shared.startPeriodicPrivateKeyRotation() // Start periodic API address list updates - addressCacheTracker.startPeriodicUpdates() + AddressCache.Tracker.shared.startPeriodicUpdates() // Reveal application content occlusionWindow.isHidden = true @@ -146,7 +158,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { TunnelManager.shared.stopPeriodicPrivateKeyRotation() // Stop periodic API address list updates - addressCacheTracker.stopPeriodicUpdates() + AddressCache.Tracker.shared.stopPeriodicUpdates() // Hide application content occlusionWindow.makeKeyAndVisible() @@ -158,19 +170,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - logger?.info("Start background refresh") - - var addressCacheFetchResult: UIBackgroundFetchResult? - var relaysFetchResult: UIBackgroundFetchResult? - var rotatePrivateKeyFetchResult: UIBackgroundFetchResult? - - let operationQueue = AsyncOperationQueue() + func application( + _ application: UIApplication, + performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) + { + logger.debug("Start background refresh.") - let updateAddressCacheOperation = AsyncBlockOperation(dispatchQueue: .main) { operation in - let handle = self.addressCacheTracker.updateEndpoints { completion in - addressCacheFetchResult = completion.backgroundFetchResult - operation.finish() + let updateAddressCacheOperation = ResultBlockOperation<Bool, Error> { operation in + let handle = AddressCache.Tracker.shared.updateEndpoints { completion in + operation.finish(completion: completion) } operation.addCancellationBlock { @@ -178,19 +187,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - let updateRelaysOperation = AsyncBlockOperation(dispatchQueue: .main) { operation in + let updateRelaysOperation = ResultBlockOperation<RelayCache.FetchResult, RelayCache.Error> + { operation in let handle = RelayCache.Tracker.shared.updateRelays { completion in - switch completion { - case .success(let result): - self.logger?.debug("Finished updating relays: \(result).") - case .failure(let error): - self.logger?.error(chainedError: error, message: "Failed to update relays.") - case .cancelled: - break - } - - relaysFetchResult = completion.backgroundFetchResult - operation.finish() + operation.finish(completion: completion) } operation.addCancellationBlock { @@ -198,124 +198,234 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - let rotatePrivateKeyOperation = AsyncBlockOperation(dispatchQueue: .main) { operation in - let handle = TunnelManager.shared.rotatePrivateKey { completion in - switch completion { - case .success(let rotationResult): - self.logger?.debug("Finished rotating the key: \(rotationResult).") - case .failure(let error): - self.logger?.error(chainedError: error, message: "Failed to rotate the key.") - case .cancelled: - break - } - - rotatePrivateKeyFetchResult = completion.backgroundFetchResult - operation.finish() + let rotatePrivateKeyOperation = ResultBlockOperation<Bool, TunnelManager.Error> + { operation in + let handle = TunnelManager.shared.rotatePrivateKey(forceRotate: false) { completion in + operation.finish(completion: completion) } operation.addCancellationBlock { handle.cancel() } } + rotatePrivateKeyOperation.addDependencies([ + updateRelaysOperation, + updateAddressCacheOperation + ]) - rotatePrivateKeyOperation.addDependencies([updateRelaysOperation, updateAddressCacheOperation]) + let operations = [ + updateAddressCacheOperation, + updateRelaysOperation, + rotatePrivateKeyOperation + ] + + let completeOperation = TransformOperation<UIBackgroundFetchResult, Void, Never>( + dispatchQueue: .main + ) + + completeOperation.setExecutionBlock { backgroundFetchResult in + self.logger.debug("Finish background refresh. Status: \(backgroundFetchResult).") - let backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask(withName: "AppDelegate.performFetch") { - operationQueue.cancelAllOperations() + completionHandler(backgroundFetchResult) } - let fetchOperations = [updateAddressCacheOperation, updateRelaysOperation, rotatePrivateKeyOperation] + completeOperation.injectMany(context: [UIBackgroundFetchResult]()) + .injectCompletion(from: updateAddressCacheOperation, via: { results, completion in + results.append(completion.backgroundFetchResult { $0 }) + }) + .injectCompletion(from: updateRelaysOperation, via: { results, completion in + results.append(completion.backgroundFetchResult { $0 == .newContent }) + }) + .injectCompletion(from: rotatePrivateKeyOperation, via: { results, completion in + results.append(completion.backgroundFetchResult { $0 }) + }) + .reduce { operationResults in + let initialResult = operationResults.first ?? .failed + let backgroundFetchResult = operationResults + .reduce(initialResult) { partialResult, other in + return partialResult.combine(with: other) + } - let completionOperation = BlockOperation { - let operationResults = [addressCacheFetchResult, relaysFetchResult, rotatePrivateKeyFetchResult].compactMap { $0 } - let initialResult = operationResults.first ?? .failed - let backgroundFetchResult = operationResults.reduce(initialResult) { partialResult, other in - return partialResult.combine(with: other) + return backgroundFetchResult } - self.logger?.info("Finish background refresh with \(backgroundFetchResult)") + let groupOperation = GroupOperation(operations: operations) + groupOperation.addObserver( + BackgroundObserver(name: "Background refresh", cancelUponExpiration: true) + ) - completionHandler(backgroundFetchResult) + let operationQueue = AsyncOperationQueue() + operationQueue.addOperation(groupOperation) + operationQueue.addOperation(completeOperation) + } + + // MARK: - Background tasks + @available(iOS 13, *) + private func registerBackgroundTasks() { + registerAppRefreshTask() + registerAddressCacheUpdateTask() + registerKeyRotationTask() + } + + @available(iOS 13.0, *) + private func registerAppRefreshTask() { + let isRegistered = BGTaskScheduler.shared.register( + forTaskWithIdentifier: ApplicationConfiguration.appRefreshTaskIdentifier, + using: nil + ) { task in + let handle = RelayCache.Tracker.shared.updateRelays { completion in + task.setTaskCompleted(success: completion.isSuccess) + } + + task.expirationHandler = { + handle.cancel() + } + + self.scheduleAppRefreshTask() + } - UIApplication.shared.endBackgroundTask(backgroundTaskIdentifier) + if isRegistered { + logger.debug("Registered app refresh task.") + } else { + logger.error("Failed to register app refresh task.") } + } + + @available(iOS 13.0, *) + private func registerKeyRotationTask() { + let isRegistered = BGTaskScheduler.shared.register( + forTaskWithIdentifier: ApplicationConfiguration.privateKeyRotationTaskIdentifier, + using: nil + ) { task in + let handle = TunnelManager.shared.rotatePrivateKey(forceRotate: false) { completion in + self.scheduleKeyRotationTask() + + task.setTaskCompleted(success: completion.isSuccess) + } - completionOperation.addDependencies(fetchOperations) + task.expirationHandler = { + handle.cancel() + } + } - operationQueue.addOperations(fetchOperations, waitUntilFinished: false) - OperationQueue.main.addOperation(completionOperation) + if isRegistered { + logger.debug("Registered private key rotation task.") + } else { + logger.error("Failed to register private key rotation task.") + } } - // MARK: - Private + @available(iOS 13.0, *) + private func registerAddressCacheUpdateTask() { + let isRegistered = BGTaskScheduler.shared.register( + forTaskWithIdentifier: ApplicationConfiguration.addressCacheUpdateTaskIdentifier, + using: nil + ) { task in + let handle = AddressCache.Tracker.shared.updateEndpoints { completion in + self.scheduleAddressCacheUpdateTask() + + task.setTaskCompleted(success: completion.isSuccess) + } + + task.expirationHandler = { + handle.cancel() + } + } + + if isRegistered { + logger.debug("Registered address cache update task.") + } else { + logger.error("Failed to register address cache update task.") + } + } @available(iOS 13.0, *) - private func scheduleBackgroundTasks() { + func scheduleBackgroundTasks() { + scheduleAppRefreshTask() + scheduleKeyRotationTask() + scheduleAddressCacheUpdateTask() + } + + @available(iOS 13.0, *) + private func scheduleAppRefreshTask() { do { - try RelayCache.Tracker.shared.scheduleAppRefreshTask() + let date = RelayCache.Tracker.shared.getNextUpdateDate() + + let request = BGAppRefreshTaskRequest( + identifier: ApplicationConfiguration.appRefreshTaskIdentifier + ) + request.earliestBeginDate = date + + logger.debug("Schedule app refresh task at \(date.logFormatDate()).") - logger?.debug("Scheduled app refresh task.") + try BGTaskScheduler.shared.submit(request) } catch { - logger?.error( + logger.error( chainedError: AnyChainedError(error), message: "Could not schedule app refresh task." ) } + } + @available(iOS 13.0, *) + private func scheduleKeyRotationTask() { do { - try TunnelManager.shared.scheduleBackgroundTask() + guard let date = TunnelManager.shared.getNextKeyRotationDate() else { + return + } + + let request = BGProcessingTaskRequest( + identifier: ApplicationConfiguration.privateKeyRotationTaskIdentifier + ) + request.requiresNetworkConnectivity = true + request.earliestBeginDate = date + + logger.debug("Schedule key rotation task at \(date.logFormatDate()).") - logger?.debug("Scheduled private key rotation task.") + try BGTaskScheduler.shared.submit(request) } catch { - logger?.error( + logger.error( chainedError: AnyChainedError(error), message: "Could not schedule private key rotation task." ) } + } + @available(iOS 13.0, *) + private func scheduleAddressCacheUpdateTask() { do { - try addressCacheTracker.scheduleBackgroundTask() + let date = AddressCache.Tracker.shared.nextScheduleDate() + + let request = BGProcessingTaskRequest( + identifier: ApplicationConfiguration.addressCacheUpdateTaskIdentifier + ) + request.requiresNetworkConnectivity = true + request.earliestBeginDate = date - self.logger?.debug("Scheduled address cache update task.") + logger.debug("Schedule address cache update task at \(date.logFormatDate()).") + + try BGTaskScheduler.shared.submit(request) } catch { - self.logger?.error( + logger.error( chainedError: AnyChainedError(error), message: "Could not schedule address cache update task." ) } } - private func didFinishInitialization() { - self.logger?.debug("Finished initialization. Show user interface.") - - self.rootContainer = RootContainerViewController() - self.rootContainer?.delegate = self - self.window?.rootViewController = self.rootContainer - - switch UIDevice.current.userInterfaceIdiom { - case .pad: - self.setupPadUI() - - case .phone: - self.setupPhoneUI() + // MARK: - Private - default: - fatalError() - } + private func setupPaymentHandler() { + AppStorePaymentManager.shared.delegate = self + AppStorePaymentManager.shared.addPaymentObserver(TunnelManager.shared) + } - notificationManager.notificationProviders = [ + private func setupNotificationHandler() { + NotificationManager.shared.notificationProviders = [ AccountExpiryNotificationProvider(), TunnelErrorNotificationProvider() ] - notificationManager.updateNotifications() - - startPaymentQueueHandling() - } - - private func startPaymentQueueHandling() { - let paymentManager = AppStorePaymentManager.shared - paymentManager.delegate = self - paymentManager.addPaymentObserver(TunnelManager.shared) - paymentManager.startPaymentQueueMonitoring() + UNUserNotificationCenter.current().delegate = self } private func setupPadUI() { @@ -393,7 +503,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { private func makeConnectViewController() -> ConnectViewController { let connectController = ConnectViewController() connectController.delegate = self - notificationManager.delegate = connectController.notificationController + NotificationManager.shared.delegate = self return connectController } @@ -402,12 +512,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let selectLocationController = SelectLocationViewController() selectLocationController.delegate = self - if let cachedRelays = cachedRelays { + if let cachedRelays = RelayCache.Tracker.shared.getCachedRelays() { selectLocationController.setCachedRelays(cachedRelays) } if let relayLocation = relayConstraints?.location.value { - selectLocationController.setSelectedRelayLocation(relayLocation, animated: false, scrollPosition: .middle) + selectLocationController.setSelectedRelayLocation( + relayLocation, + animated: false, + scrollPosition: .middle + ) } return selectLocationController @@ -544,6 +658,13 @@ extension AppDelegate: RootContainerViewControllerDelegate { } } +// MARK: - NotificationManagerDelegate +extension AppDelegate: NotificationManagerDelegate { + func notificationManagerDidUpdateInAppNotifications(_ manager: NotificationManager, notifications: [InAppNotificationDescriptor]) { + connectController?.notificationController.setNotifications(notifications, animated: true) + } +} + // MARK: - LoginViewControllerDelegate extension AppDelegate: LoginViewControllerDelegate { @@ -554,11 +675,11 @@ extension AppDelegate: LoginViewControllerDelegate { TunnelManager.shared.setAccount(action: .existing(accountNumber)) { operationCompletion in switch operationCompletion { case .success: - self.logger?.debug("Logged in with existing account.") + self.logger.debug("Logged in with existing account.") // RootContainer's settings button will be re-enabled in `loginViewControllerDidLogin` case .failure(let error): - self.logger?.error(chainedError: error, message: "Failed to log in with existing account.") + self.logger.error(chainedError: error, message: "Failed to log in with existing account.") fallthrough case .cancelled: @@ -575,11 +696,11 @@ extension AppDelegate: LoginViewControllerDelegate { TunnelManager.shared.setAccount(action: .new) { operationCompletion in switch operationCompletion { case .success: - self.logger?.debug("Logged in with new account number.") + self.logger.debug("Logged in with new account number.") // RootContainer's settings button will be re-enabled in `loginViewControllerDidLogin` case .failure(let error): - self.logger?.error(chainedError: error, message: "Failed to log in with new account.") + self.logger.error(chainedError: error, message: "Failed to log in with new account.") fallthrough case .cancelled: @@ -703,9 +824,9 @@ extension AppDelegate: SelectLocationViewControllerDelegate { self.relayConstraints = relayConstraints if let error = error { - self.logger?.error(chainedError: error, message: "Failed to update relay constraints") + self.logger.error(chainedError: error, message: "Failed to update relay constraints") } else { - self.logger?.debug("Updated relay constraints: \(relayConstraints)") + self.logger.debug("Updated relay constraints: \(relayConstraints)") TunnelManager.shared.startTunnel() } } @@ -761,9 +882,9 @@ extension AppDelegate: UIAdaptivePresentationControllerDelegate { }) } else { if let containerView = presentationController.containerView { - self.rootContainer?.addSettingsButtonToPresentationContainer(containerView) + rootContainer?.addSettingsButtonToPresentationContainer(containerView) } else { - logger?.warning("Cannot obtain the containerView for presentation controller when presenting with adaptive style \(actualStyle.rawValue) and missing transition coordinator.") + logger.warning("Cannot obtain the containerView for presentation controller when presenting with adaptive style \(actualStyle.rawValue) and missing transition coordinator.") } } } @@ -774,9 +895,7 @@ extension AppDelegate: UIAdaptivePresentationControllerDelegate { extension AppDelegate: RelayCacheObserver { func relayCache(_ relayCache: RelayCache.Tracker, didUpdateCachedRelays cachedRelays: RelayCache.CachedRelays) { - DispatchQueue.main.async { - self.cachedRelays = cachedRelays - } + selectLocationViewController?.setCachedRelays(cachedRelays) } } @@ -825,12 +944,16 @@ extension AppDelegate: UISplitViewControllerDelegate { extension AppDelegate: UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - if response.notification.request.identifier == accountExpiryNotificationIdentifier, - response.actionIdentifier == UNNotificationDefaultActionIdentifier { - rootContainer?.showSettings(navigateTo: .account, animated: true) + let blockOperation = AsyncBlockOperation(dispatchQueue: .main) { + if response.notification.request.identifier == accountExpiryNotificationIdentifier, + response.actionIdentifier == UNNotificationDefaultActionIdentifier { + self.rootContainer?.showSettings(navigateTo: .account, animated: true) + } + + completionHandler() } - completionHandler() + operationQueue.addOperation(blockOperation) } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { diff --git a/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManager.swift b/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManager.swift index 6fb85bd1dd..6b581edf84 100644 --- a/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManager.swift +++ b/ios/MullvadVPN/AppStorePaymentManager/AppStorePaymentManager.swift @@ -56,20 +56,20 @@ class AppStorePaymentManager: NSObject, SKPaymentTransactionObserver { } } - /// A private hash map that maps each payment to account token + /// A private hash map that maps each payment to account token. private var paymentToAccountToken = [SKPayment: String]() - /// Returns true if the device is able to make payments + /// Returns true if the device is able to make payments. class var canMakePayments: Bool { return SKPaymentQueue.canMakePayments() } init(queue: SKPaymentQueue) { - self.paymentQueue = queue + paymentQueue = queue } func startPaymentQueueMonitoring() { - self.logger.debug("Start payment queue monitoring") + logger.debug("Start payment queue monitoring") paymentQueue.add(self) } diff --git a/ios/MullvadVPN/ConnectViewController.swift b/ios/MullvadVPN/ConnectViewController.swift index 3e2f6926ba..b83ae621e2 100644 --- a/ios/MullvadVPN/ConnectViewController.swift +++ b/ios/MullvadVPN/ConnectViewController.swift @@ -131,6 +131,10 @@ class ConnectViewController: UIViewController, MKMapViewDelegate, RootContainmen // MARK: - TunnelObserver + func tunnelManagerDidLoadConfiguration(_ manager: TunnelManager) { + // no-op + } + func tunnelManager(_ manager: TunnelManager, didUpdateTunnelSettings tunnelSettings: TunnelSettingsV2?) { setNeedsHeaderBarStyleAppearanceUpdate() } @@ -358,7 +362,7 @@ class ConnectViewController: UIViewController, MKMapViewDelegate, RootContainmen } @objc func handleReconnect(_ sender: Any) { - TunnelManager.shared.reconnectTunnel(completionHandler: nil) + TunnelManager.shared.reconnectTunnel() } @objc func handleSelectLocation(_ sender: Any) { @@ -409,13 +413,7 @@ class ConnectViewController: UIViewController, MKMapViewDelegate, RootContainmen return nil } - func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) { - print("mapView regionWillChangeAnimated: \(animated)") - } - func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { - print("mapView regionDidChangeAnimated: \(animated)") - mapRegionAnimationDidEnd?() mapRegionAnimationDidEnd = nil } diff --git a/ios/MullvadVPN/DisplayChainedError.swift b/ios/MullvadVPN/DisplayChainedError.swift index 065baddee4..0120d2fb14 100644 --- a/ios/MullvadVPN/DisplayChainedError.swift +++ b/ios/MullvadVPN/DisplayChainedError.swift @@ -183,6 +183,13 @@ extension TunnelManager.Error: DisplayChainedError { value: "Internal error: account is unset", comment: "" ) + case .unsetTunnel: + return NSLocalizedString( + "UNSET_TUNNEL_ERROR", + tableName: "TunnelManager", + value: "Tunnel is unset.", + comment: "" + ) case .readRelays: return NSLocalizedString( "READ_RELAYS_ERROR", diff --git a/ios/MullvadVPN/NotificationController.swift b/ios/MullvadVPN/NotificationController.swift index 6bb875a8f8..ed7d03e0ee 100644 --- a/ios/MullvadVPN/NotificationController.swift +++ b/ios/MullvadVPN/NotificationController.swift @@ -15,7 +15,7 @@ struct InAppNotificationDescriptor: Equatable { var body: String } -class NotificationController: UIViewController, NotificationManagerDelegate { +class NotificationController: UIViewController { let bannerView: NotificationBannerView = { let bannerView = NotificationBannerView() bannerView.translatesAutoresizingMaskIntoConstraints = false @@ -125,12 +125,7 @@ class NotificationController: UIViewController, NotificationManagerDelegate { } } - private func updateAccessibilityFrame() { - let layoutFrame = bannerView.layoutMarginsGuide.layoutFrame - bannerView.accessibilityFrame = UIAccessibility.convertToScreenCoordinates(layoutFrame, in: view) - } - - private func setNotifications(_ notifications: [InAppNotificationDescriptor], animated: Bool) { + func setNotifications(_ notifications: [InAppNotificationDescriptor], animated: Bool) { let nextNotification = notifications.first if let notification = nextNotification { @@ -141,10 +136,9 @@ class NotificationController: UIViewController, NotificationManagerDelegate { } } - // MARK: - NotificationManagerDelegate - - func notificationManagerDidUpdateInAppNotifications(_ manager: NotificationManager, notifications: [InAppNotificationDescriptor]) { - setNotifications(notifications, animated: true) + private func updateAccessibilityFrame() { + let layoutFrame = bannerView.layoutMarginsGuide.layoutFrame + bannerView.accessibilityFrame = UIAccessibility.convertToScreenCoordinates(layoutFrame, in: view) } } diff --git a/ios/MullvadVPN/NotificationManager.swift b/ios/MullvadVPN/NotificationManager.swift index 0204396c57..d2b1842cd6 100644 --- a/ios/MullvadVPN/NotificationManager.swift +++ b/ios/MullvadVPN/NotificationManager.swift @@ -92,6 +92,10 @@ class NotificationManager: NotificationProviderDelegate { } } + static let shared = NotificationManager() + + private init() {} + func updateNotifications() { assert(Thread.isMainThread) @@ -132,7 +136,10 @@ class NotificationManager: NotificationProviderDelegate { for newRequest in newSystemNotificationRequests { notificationCenter.add(newRequest) { (error) in if let error = error { - self.logger.error("Failed to add notification request with identifier \(newRequest.identifier). Error: \(error.localizedDescription)") + self.logger.error( + chainedError: AnyChainedError(error), + message: "Failed to add notification request with identifier \(newRequest.identifier)." + ) } } } @@ -154,7 +161,10 @@ class NotificationManager: NotificationProviderDelegate { case .notDetermined: userNotificationCenter.requestAuthorization(options: authorizationOptions) { (granted, error) in if let error = error { - self.logger.error("Failed to obtain user notifications authorization: \(error.localizedDescription)") + self.logger.error( + chainedError: AnyChainedError(error), + message: "Failed to obtain user notifications authorization" + ) } completion(granted) } diff --git a/ios/MullvadVPN/Notifications/AccountExpiryNotificationProvider.swift b/ios/MullvadVPN/Notifications/AccountExpiryNotificationProvider.swift index 51633cc85a..5fa9794bf9 100644 --- a/ios/MullvadVPN/Notifications/AccountExpiryNotificationProvider.swift +++ b/ios/MullvadVPN/Notifications/AccountExpiryNotificationProvider.swift @@ -126,6 +126,10 @@ class AccountExpiryNotificationProvider: NotificationProvider, SystemNotificatio // MARK: - TunnelObserver + func tunnelManagerDidLoadConfiguration(_ manager: TunnelManager) { + // no-op + } + func tunnelManager(_ manager: TunnelManager, didUpdateTunnelState tunnelState: TunnelState) { // no-op } diff --git a/ios/MullvadVPN/Notifications/TunnelErrorNotificationProvider.swift b/ios/MullvadVPN/Notifications/TunnelErrorNotificationProvider.swift index de0aab4653..3018400c22 100644 --- a/ios/MullvadVPN/Notifications/TunnelErrorNotificationProvider.swift +++ b/ios/MullvadVPN/Notifications/TunnelErrorNotificationProvider.swift @@ -36,6 +36,12 @@ class TunnelErrorNotificationProvider: NotificationProvider, InAppNotificationPr TunnelManager.shared.addObserver(self) } + // MARK: - TunnelObserver + + func tunnelManagerDidLoadConfiguration(_ manager: TunnelManager) { + // no-op + } + func tunnelManager(_ manager: TunnelManager, didUpdateTunnelState tunnelState: TunnelState) { // Reset error with each new connection attempt if case .connecting = tunnelState { diff --git a/ios/MullvadVPN/Operations/InputInjectionBuilder.swift b/ios/MullvadVPN/Operations/InputInjectionBuilder.swift index 14f2ed9ce3..87fff94146 100644 --- a/ios/MullvadVPN/Operations/InputInjectionBuilder.swift +++ b/ios/MullvadVPN/Operations/InputInjectionBuilder.swift @@ -54,6 +54,23 @@ class InputInjectionBuilder<OperationType, Context> where OperationType: InputOp return self } + func injectCompletion<T, Success, Failure>( + from dependency: T, + via block: @escaping (inout Context, T.Completion) -> Void + ) -> Self + where T: ResultOperation<Success, Failure> + { + inputBlocks.append { context in + if let completion = dependency.completion { + block(&context, completion) + } + } + + operation.addDependency(dependency) + + return self + } + func reduce(_ reduceBlock: @escaping (Context) -> OperationType.Input?) { operation.setInputBlock { for inputBlock in self.inputBlocks { diff --git a/ios/MullvadVPN/Operations/OperationCompletion.swift b/ios/MullvadVPN/Operations/OperationCompletion.swift index 393dbd8ecd..e8055572e1 100644 --- a/ios/MullvadVPN/Operations/OperationCompletion.swift +++ b/ios/MullvadVPN/Operations/OperationCompletion.swift @@ -121,4 +121,12 @@ enum OperationCompletion<Success, Failure: Error> { return success as! NewSuccess } } + + func assertFailure<NewFailure: Error>(_ failureType: NewFailure.Type) + -> OperationCompletion<Success, NewFailure> + { + return mapError { error -> NewFailure in + return error as! NewFailure + } + } } diff --git a/ios/MullvadVPN/PreferencesViewController.swift b/ios/MullvadVPN/PreferencesViewController.swift index a67fd9f47d..d3ab3ffa74 100644 --- a/ios/MullvadVPN/PreferencesViewController.swift +++ b/ios/MullvadVPN/PreferencesViewController.swift @@ -82,6 +82,10 @@ class PreferencesViewController: UITableViewController, PreferencesDataSourceDel // MARK: - TunnelObserver + func tunnelManagerDidLoadConfiguration(_ manager: TunnelManager) { + // no-op + } + func tunnelManager(_ manager: TunnelManager, didUpdateTunnelState tunnelState: TunnelState) { // no-op } diff --git a/ios/MullvadVPN/RelayCache/RelayCacheIO.swift b/ios/MullvadVPN/RelayCache/RelayCacheIO.swift index ccd623d25a..20cbe90f6a 100644 --- a/ios/MullvadVPN/RelayCache/RelayCacheIO.swift +++ b/ios/MullvadVPN/RelayCache/RelayCacheIO.swift @@ -14,8 +14,12 @@ extension RelayCache { extension RelayCache.IO { /// The default cache file location bound by app group container. - static func defaultCacheFileURL(forSecurityApplicationGroupIdentifier appGroupIdentifier: String) -> URL? { - let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) + static func defaultCacheFileURL( + forSecurityApplicationGroupIdentifier appGroupIdentifier: String + ) -> URL? { + let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier + ) return containerURL?.appendingPathComponent("relays.json") } @@ -57,9 +61,11 @@ extension RelayCache.IO { return try result!.get() } - /// Safely read the cache file from disk using file coordinator and fallback to prebundled relays in case if the - /// relay cache file is missing. - static func readWithFallback(cacheFileURL: URL, preBundledRelaysFileURL: URL) throws -> RelayCache.CachedRelays { + /// Safely read the cache file from disk using file coordinator and fallback to prebundled + /// relays in case if the relay cache file is missing. + static func readWithFallback(cacheFileURL: URL, preBundledRelaysFileURL: URL) + throws -> RelayCache.CachedRelays + { do { return try Self.read(cacheFileURL: cacheFileURL) } catch { diff --git a/ios/MullvadVPN/RelayCache/RelayCacheObserver.swift b/ios/MullvadVPN/RelayCache/RelayCacheObserver.swift index 53a9edc299..879157bf60 100644 --- a/ios/MullvadVPN/RelayCache/RelayCacheObserver.swift +++ b/ios/MullvadVPN/RelayCache/RelayCacheObserver.swift @@ -9,5 +9,8 @@ import Foundation protocol RelayCacheObserver: AnyObject { - func relayCache(_ relayCache: RelayCache.Tracker, didUpdateCachedRelays cachedRelays: RelayCache.CachedRelays) + func relayCache( + _ relayCache: RelayCache.Tracker, + didUpdateCachedRelays cachedRelays: RelayCache.CachedRelays + ) } diff --git a/ios/MullvadVPN/RelayCache/RelayCacheTracker.swift b/ios/MullvadVPN/RelayCache/RelayCacheTracker.swift index 218c55f69c..f7ffcf2f72 100644 --- a/ios/MullvadVPN/RelayCache/RelayCacheTracker.swift +++ b/ios/MullvadVPN/RelayCache/RelayCacheTracker.swift @@ -6,46 +6,73 @@ // Copyright © 2019 Mullvad VPN AB. All rights reserved. // -import BackgroundTasks import Foundation import Logging import UIKit extension RelayCache { + /// Type describing the result of an attempt to fetch the new relay list from server. + enum FetchResult: CustomStringConvertible { + /// Request to update relays was throttled. + case throttled + + /// Refreshed relays but the same content was found on remote. + case sameContent + + /// Refreshed relays with new content. + case newContent + + var description: String { + switch self { + case .throttled: + return "throttled" + case .sameContent: + return "same content" + case .newContent: + return "new content" + } + } + } + class Tracker { - /// Relay update interval (in seconds) - private static let relayUpdateInterval: TimeInterval = 60 * 60 + /// Relay update interval (in seconds). + static let relayUpdateInterval: TimeInterval = 60 * 60 - /// Tracker log + /// Tracker log. private let logger = Logger(label: "RelayCacheTracker") - /// The cache location used by the class instance + /// The cache location used by the class instance. private let cacheFileURL: URL - /// The location of prebundled `relays.json` + /// The location of prebundled `relays.json`. private let prebundledRelaysFileURL: URL - /// A dispatch queue used for thread synchronization - private let stateQueue = DispatchQueue(label: "RelayCacheTrackerStateQueue") + /// Lock used for synchronization. + private let nslock = NSLock() /// Internal operation queue. private let operationQueue: OperationQueue = { let operationQueue = AsyncOperationQueue() - operationQueue.name = "RelayCacheTrackerQueue" operationQueue.maxConcurrentOperationCount = 1 return operationQueue }() - /// A timer source used for periodic updates + /// A timer source used for periodic updates. private var timerSource: DispatchSourceTimer? - /// A flag that indicates whether periodic updates are running + /// A flag that indicates whether periodic updates are running. private var isPeriodicUpdatesEnabled = false - /// Observers + /// API proxy. + private let apiProxy = REST.ProxyFactory.shared.createAPIProxy() + + /// Observers. private let observerList = ObserverList<RelayCacheObserver>() + /// Memory cache. + private var cachedRelays: CachedRelays? + /// A shared instance of `RelayCache` static let shared: RelayCache.Tracker = { let cacheFileURL = RelayCache.IO.defaultCacheFileURL(forSecurityApplicationGroupIdentifier: ApplicationConfiguration.securityGroupIdentifier)! @@ -60,102 +87,105 @@ extension RelayCache { private init(cacheFileURL: URL, prebundledRelaysFileURL: URL) { self.cacheFileURL = cacheFileURL self.prebundledRelaysFileURL = prebundledRelaysFileURL + + do { + cachedRelays = try RelayCache.IO.readWithFallback( + cacheFileURL: cacheFileURL, + preBundledRelaysFileURL: prebundledRelaysFileURL + ) + } catch { + logger.error( + chainedError: AnyChainedError(error), + message: "Failed to read the relay cache during initialization." + ) + + _ = updateRelays(completionHandler: nil) + } } func startPeriodicUpdates() { - stateQueue.async { - guard !self.isPeriodicUpdatesEnabled else { return } + nslock.lock() + defer { nslock.unlock() } - self.logger.debug("Start periodic relay updates.") + guard !isPeriodicUpdatesEnabled else { return } - self.isPeriodicUpdatesEnabled = true + logger.debug("Start periodic relay updates.") - do { - let cachedRelays = try RelayCache.IO.read(cacheFileURL: self.cacheFileURL) - let nextUpdate = cachedRelays.updatedAt - .addingTimeInterval(Self.relayUpdateInterval) + isPeriodicUpdatesEnabled = true - self.scheduleRepeatingTimer(startTime: .now() + nextUpdate.timeIntervalSinceNow) - } catch { - self.logger.error( - chainedError: AnyChainedError(error), - message: "Failed to read the relay cache." - ) + let nextUpdate = _getNextUpdateDate() - if let readError = error as? RelayCache.Error, - Self.shouldDownloadRelaysOnReadFailure(readError) { - self.scheduleRepeatingTimer(startTime: .now()) - } - } - } + scheduleRepeatingTimer(startTime: .now() + nextUpdate.timeIntervalSinceNow) } func stopPeriodicUpdates() { - stateQueue.async { - guard self.isPeriodicUpdatesEnabled else { return } + nslock.lock() + defer { nslock.unlock() } - self.logger.debug("Stop periodic relay updates.") + guard isPeriodicUpdatesEnabled else { return } - self.isPeriodicUpdatesEnabled = false + logger.debug("Stop periodic relay updates.") - self.timerSource?.cancel() - self.timerSource = nil - } + isPeriodicUpdatesEnabled = false + + timerSource?.cancel() + timerSource = nil } func updateRelays( - completionHandler: @escaping ( - OperationCompletion<RelayCache.FetchResult, RelayCache.Error> - ) -> Void - ) -> Cancellable { - let operation = UpdateRelaysOperation( - dispatchQueue: stateQueue, - apiProxy: REST.ProxyFactory.shared.createAPIProxy(), - cacheFileURL: self.cacheFileURL, - relayUpdateInterval: Self.relayUpdateInterval, - updateHandler: { [weak self] newCachedRelays in - guard let self = self else { return } + completionHandler: ( + (OperationCompletion<RelayCache.FetchResult, RelayCache.Error>) -> Void + )? = nil + ) -> Cancellable + { + let operation = ResultBlockOperation<RelayCache.FetchResult, RelayCache.Error>( + dispatchQueue: nil + ) { operation in + let cachedRelays = self.getCachedRelays() - DispatchQueue.main.async { - self.observerList.forEach { observer in - observer.relayCache(self, didUpdateCachedRelays: newCachedRelays) - } - } - }, - completionHandler: completionHandler - ) + if self.getNextUpdateDate() > Date() { + operation.finish(completion: .success(.throttled)) + return + } + + let task = self.apiProxy.getRelays( + etag: cachedRelays?.etag, + retryStrategy: .noRetry + ) { completion in + operation.finish( + completion: self.handleResponse(completion: completion) + ) + } + + operation.addCancellationBlock { + task.cancel() + } + } operation.addObserver( BackgroundObserver(name: "Update relays", cancelUponExpiration: true) ) + operation.completionQueue = .main + operation.completionHandler = completionHandler + operationQueue.addOperation(operation) return operation } - func read(completionHandler: @escaping (Result<CachedRelays, RelayCache.Error>) -> Void) { - stateQueue.async { - let result = Result { - try RelayCache.IO.readWithFallback( - cacheFileURL: self.cacheFileURL, - preBundledRelaysFileURL: self.prebundledRelaysFileURL - ) - }.mapError { error in - return error as! RelayCache.Error - } + func getCachedRelays() -> CachedRelays? { + nslock.lock() + defer { nslock.unlock() } - completionHandler(result) - } + return cachedRelays } - func readAndWait() throws -> CachedRelays { - return try stateQueue.sync { - return try RelayCache.IO.readWithFallback( - cacheFileURL: cacheFileURL, - preBundledRelaysFileURL: prebundledRelaysFileURL - ) - } + func getNextUpdateDate() -> Date { + nslock.lock() + defer { nslock.unlock() } + + return _getNextUpdateDate() } // MARK: - Observation @@ -168,300 +198,101 @@ extension RelayCache { observerList.remove(observer) } - // MARK: - Private instance methods - - private func scheduleRepeatingTimer(startTime: DispatchWallTime) { - let timerSource = DispatchSource.makeTimerSource(queue: stateQueue) - timerSource.setEventHandler { [weak self] in - _ = self?.updateRelays(completionHandler: { _ in - // no-op - }) - } - - timerSource.schedule(wallDeadline: startTime, repeating: .seconds(Int(Self.relayUpdateInterval))) - timerSource.activate() - - self.timerSource = timerSource - } - - // MARK: - Private class methods + // MARK: - Private - private class func shouldDownloadRelaysOnReadFailure(_ error: RelayCache.Error) -> Bool { - switch error { - case .readPrebundledRelays, .decodePrebundledRelays, .decodeCache: - return true + private func _getNextUpdateDate() -> Date { + let now = Date() - case .readCache(CocoaError.fileReadNoSuchFile): - return true - - default: - return false + guard let cachedRelays = cachedRelays else { + return now } - } - } -} - -extension RelayCache { + let nextUpdate = cachedRelays.updatedAt.addingTimeInterval(Self.relayUpdateInterval) - /// Type describing the result of an attempt to fetch the new relay list from server. - enum FetchResult: CustomStringConvertible { - /// Request to update relays was throttled. - case throttled - - /// Refreshed relays but the same content was found on remote. - case sameContent - - /// Refreshed relays with new content. - case newContent - - var description: String { - switch self { - case .throttled: - return "throttled" - case .sameContent: - return "same content" - case .newContent: - return "new content" - } + return max(nextUpdate, Date()) } - } - -} - -// MARK: - Background tasks - -@available(iOS 13.0, *) -extension RelayCache.Tracker { - - /// Register app refresh task with scheduler. - func registerAppRefreshTask() { - let taskIdentifier = ApplicationConfiguration.appRefreshTaskIdentifier - - let isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in - self.handleAppRefreshTask(task as! BGAppRefreshTask) - } - - if isRegistered { - logger.debug("Registered app refresh task.") - } else { - logger.error("Failed to register app refresh task.") - } - } - - /// Schedules app refresh task relative to the last relays update. - func scheduleAppRefreshTask() throws { - let cachedRelays = try readAndWait() - let beginDate = cachedRelays.updatedAt.addingTimeInterval(Self.relayUpdateInterval) - - try submitAppRefreshTask(at: beginDate) - } - - /// Create and submit task request to scheduler. - private func submitAppRefreshTask(at beginDate: Date) throws { - let taskIdentifier = ApplicationConfiguration.appRefreshTaskIdentifier - - let request = BGAppRefreshTaskRequest(identifier: taskIdentifier) - request.earliestBeginDate = beginDate - try BGTaskScheduler.shared.submit(request) - } + private func handleResponse( + completion: OperationCompletion<REST.ServerRelaysCacheResponse, REST.Error> + ) -> OperationCompletion<FetchResult, RelayCache.Error> + { + let mappedCompletion = completion + .mapError { error -> RelayCache.Error in + return .rest(error) + } + .tryMap { response -> FetchResult in + switch response { + case .newContent(let etag, let relays): + try self.storeResponse(etag: etag, relays: relays) - /// Background task handler - private func handleAppRefreshTask(_ task: BGAppRefreshTask) { - logger.debug("Start app refresh task.") + return .newContent - let cancellable = updateRelays { completion in - switch completion { - case .success(let fetchResult): - self.logger.debug("Finished updating relays in app refresh task: \(fetchResult).") + case .notModified: + return .sameContent + } + } + .assertFailure(RelayCache.Error.self) - case .failure(let error): - self.logger.error( + if let error = mappedCompletion.error { + logger.error( chainedError: error, - message: "Failed to update relays in app refresh task." + message: "Failed to update relays." ) - - case .cancelled: - self.logger.debug("App refresh task was cancelled.") } - task.setTaskCompleted(success: completion.isSuccess) + return mappedCompletion } - task.expirationHandler = { - cancellable.cancel() - } + private func storeResponse(etag: String?, relays: REST.ServerRelaysResponse) throws { + let numRelays = relays.wireguard.relays.count - // Schedule next refresh - let scheduleDate = Date(timeIntervalSinceNow: Self.relayUpdateInterval) - do { - try submitAppRefreshTask(at: scheduleDate) + logger.info("Downloaded \(numRelays) relays.") - logger.debug("Scheduled next app refresh task at \(scheduleDate.logFormatDate()).") - } catch { - logger.error( - chainedError: AnyChainedError(error), - message: "Failed to schedule next app refresh task." + let newCachedRelays = RelayCache.CachedRelays( + etag: etag, + relays: relays, + updatedAt: Date() ) - } - } -} - -fileprivate class UpdateRelaysOperation: ResultOperation<RelayCache.FetchResult, RelayCache.Error> { - typealias UpdateHandler = (RelayCache.CachedRelays) -> Void - - private let apiProxy: REST.APIProxy - private let cacheFileURL: URL - private let relayUpdateInterval: TimeInterval - - private let logger = Logger(label: "RelayCacheTracker.UpdateRelaysOperation") - - private let updateHandler: UpdateHandler - private var downloadTask: Cancellable? - init( - dispatchQueue: DispatchQueue, - apiProxy: REST.APIProxy, - cacheFileURL: URL, - relayUpdateInterval: TimeInterval, - updateHandler: @escaping UpdateHandler, - completionHandler: @escaping CompletionHandler - ) - { - self.apiProxy = apiProxy - self.cacheFileURL = cacheFileURL - self.relayUpdateInterval = relayUpdateInterval - self.updateHandler = updateHandler + nslock.lock() + cachedRelays = newCachedRelays + nslock.unlock() - super.init( - dispatchQueue: dispatchQueue, - completionQueue: dispatchQueue, - completionHandler: completionHandler - ) - } - - override func main() { - do { - let cachedRelays = try RelayCache.IO.read(cacheFileURL: cacheFileURL) - let nextUpdate = cachedRelays.updatedAt.addingTimeInterval(relayUpdateInterval) - - if nextUpdate <= Date() { - downloadRelays(previouslyCachedRelays: cachedRelays) - } else { - finish(completion: .success(.throttled)) + DispatchQueue.main.async { + self.observerList.forEach { observer in + observer.relayCache(self, didUpdateCachedRelays: newCachedRelays) + } } - } catch { - let error = error as! RelayCache.Error - logger.error( - chainedError: error, - message: "Failed to read the relay cache to determine if it needs to be updated." - ) - - if shouldDownloadRelaysOnReadFailure(error) { - downloadRelays(previouslyCachedRelays: nil) - } else { - finish(completion: .failure(error)) + do { + try RelayCache.IO.write( + cacheFileURL: cacheFileURL, + record: newCachedRelays + ) + } catch { + logger.error( + chainedError: AnyChainedError(error), + message: "Failed to store downloaded relays." + ) + throw error } } - } - - override func operationDidCancel() { - downloadTask?.cancel() - downloadTask = nil - } - - private func didReceiveNewRelays(etag: String?, relays: REST.ServerRelaysResponse) { - let numRelays = relays.wireguard.relays.count - - logger.info("Downloaded \(numRelays) relays.") - - let cachedRelays = RelayCache.CachedRelays( - etag: etag, - relays: relays, - updatedAt: Date() - ) - - do { - try RelayCache.IO.write(cacheFileURL: cacheFileURL, record: cachedRelays) - updateHandler(cachedRelays) - finish(completion: .success(.newContent)) - } catch { - let error = error as! RelayCache.Error - - logger.error( - chainedError: error, - message: "Failed to store downloaded relays." - ) - - finish(completion: .failure(error)) - } - } - - private func didReceiveNotModified(previouslyCachedRelays: RelayCache.CachedRelays) { - var cachedRelays = previouslyCachedRelays - cachedRelays.updatedAt = Date() - - logger.info("Relays haven't changed since last check.") - - do { - try RelayCache.IO.write(cacheFileURL: cacheFileURL, record: cachedRelays) - - finish(completion: .success(.sameContent)) - } catch { - let error = error as! RelayCache.Error + private func scheduleRepeatingTimer(startTime: DispatchWallTime) { + let timerSource = DispatchSource.makeTimerSource() + timerSource.setEventHandler { [weak self] in + _ = self?.updateRelays() + } - logger.error( - chainedError: error, - message: "Failed to update cached relays timestamp." + timerSource.schedule( + wallDeadline: startTime, + repeating: .seconds(Int(Self.relayUpdateInterval)) ) + timerSource.activate() - finish(completion: .failure(error)) - } - } - - private func didFailToDownloadRelays(error: REST.Error) { - logger.error(chainedError: error, message: "Failed to download relays.") - - finish(completion: .failure(.rest(error))) - } - - private func downloadRelays(previouslyCachedRelays: RelayCache.CachedRelays?) { - downloadTask = apiProxy.getRelays(etag: previouslyCachedRelays?.etag, retryStrategy: .noRetry) { [weak self] completion in - guard let self = self else { return } - - self.dispatchQueue.async { - switch completion { - case .success(.newContent(let etag, let relays)): - self.didReceiveNewRelays(etag: etag, relays: relays) - - case .success(.notModified): - self.didReceiveNotModified(previouslyCachedRelays: previouslyCachedRelays!) - - case .failure(let error): - self.didFailToDownloadRelays(error: error) - - case .cancelled: - self.logger.debug("Cancelled relays download.") - self.finish(completion: .cancelled) - } - } + self.timerSource = timerSource } } - private func shouldDownloadRelaysOnReadFailure(_ error: RelayCache.Error) -> Bool { - switch error { - case .readPrebundledRelays, .decodePrebundledRelays, .decodeCache: - return true - - case .readCache(CocoaError.fileReadNoSuchFile): - return true - - default: - return false - } - } } diff --git a/ios/MullvadVPN/Result+UIBackgroundFetchResult.swift b/ios/MullvadVPN/Result+UIBackgroundFetchResult.swift index d1a5679bf7..6cebcaaa75 100644 --- a/ios/MullvadVPN/Result+UIBackgroundFetchResult.swift +++ b/ios/MullvadVPN/Result+UIBackgroundFetchResult.swift @@ -8,38 +8,12 @@ import UIKit -extension OperationCompletion where Success == AddressCache.CacheUpdateResult { - var backgroundFetchResult: UIBackgroundFetchResult { +extension OperationCompletion { + func backgroundFetchResult(_ hasNewData: (Success) -> Bool) -> UIBackgroundFetchResult { switch self { - case .success(.finished): - return .newData - case .success(.throttled), .cancelled: - return .noData - case .failure: - return .failed - } - } -} - -extension OperationCompletion where Success == TunnelManager.KeyRotationResult { - var backgroundFetchResult: UIBackgroundFetchResult { - switch self { - case .success(.finished): - return .newData - case .success(.throttled), .cancelled: - return .noData - case .failure: - return .failed - } - } -} - -extension OperationCompletion where Success == RelayCache.FetchResult { - var backgroundFetchResult: UIBackgroundFetchResult { - switch self { - case .success(.newContent): - return .newData - case .success(.throttled), .success(.sameContent), .cancelled: + case .success(let value): + return hasNewData(value) ? .newData : .noData + case .cancelled: return .noData case .failure: return .failed diff --git a/ios/MullvadVPN/SettingsDataSource.swift b/ios/MullvadVPN/SettingsDataSource.swift index f0c0835c9a..551d78b2f2 100644 --- a/ios/MullvadVPN/SettingsDataSource.swift +++ b/ios/MullvadVPN/SettingsDataSource.swift @@ -234,6 +234,10 @@ class SettingsDataSource: NSObject, TunnelObserver, UITableViewDataSource, UITab // MARK: - TunnelObserver + func tunnelManagerDidLoadConfiguration(_ manager: TunnelManager) { + // no-op + } + func tunnelManager(_ manager: TunnelManager, didFailWithError error: TunnelManager.Error) { // no-op } diff --git a/ios/MullvadVPN/SimulatorTunnelProviderHost.swift b/ios/MullvadVPN/SimulatorTunnelProviderHost.swift index 7ad0d19a34..975eb98ab4 100644 --- a/ios/MullvadVPN/SimulatorTunnelProviderHost.swift +++ b/ios/MullvadVPN/SimulatorTunnelProviderHost.swift @@ -83,14 +83,8 @@ class SimulatorTunnelProviderHost: SimulatorTunnelProviderDelegate { } private func pickRelay() -> RelaySelectorResult? { - let cachedRelays: RelayCache.CachedRelays - do { - cachedRelays = try RelayCache.Tracker.shared.readAndWait() - } catch { - providerLogger.error( - chainedError: AnyChainedError(error), - message: "Failed to read relays when picking relay." - ) + guard let cachedRelays = RelayCache.Tracker.shared.getCachedRelays() else { + providerLogger.error("Failed to obtain relays when picking relay.") return nil } diff --git a/ios/MullvadVPN/TunnelManager/LoadTunnelConfigurationOperation.swift b/ios/MullvadVPN/TunnelManager/LoadTunnelConfigurationOperation.swift index 50fad00647..131bc97873 100644 --- a/ios/MullvadVPN/TunnelManager/LoadTunnelConfigurationOperation.swift +++ b/ios/MullvadVPN/TunnelManager/LoadTunnelConfigurationOperation.swift @@ -13,18 +13,10 @@ class LoadTunnelConfigurationOperation: ResultOperation<(), TunnelManager.Error> private let logger = Logger(label: "LoadTunnelConfigurationOperation") private let state: TunnelManager.State - init( - dispatchQueue: DispatchQueue, - state: TunnelManager.State, - completionHandler: @escaping CompletionHandler - ) { + init(dispatchQueue: DispatchQueue, state: TunnelManager.State) { self.state = state - super.init( - dispatchQueue: dispatchQueue, - completionQueue: dispatchQueue, - completionHandler: completionHandler - ) + super.init(dispatchQueue: dispatchQueue) } override func main() { @@ -40,88 +32,72 @@ class LoadTunnelConfigurationOperation: ResultOperation<(), TunnelManager.Error> } private func didLoadVPNConfigurations(tunnels: [TunnelProviderManagerType]?) { - let tunnelProvider = tunnels?.first - + var returnError: TunnelManager.Error? + var tunnelSettings: TunnelSettingsV2? do { - let tunnelSettings = try SettingsManager.readSettings() - let tunnel = tunnelProvider.map { tunnelProvider in - return Tunnel(tunnelProvider: tunnelProvider) - } - - state.tunnelSettings = tunnelSettings - state.setTunnel(tunnel, shouldRefreshTunnelState: true) - - finish(completion: .success(())) - } catch .itemNotFound as KeychainError { + tunnelSettings = try SettingsManager.readSettings() + } catch .itemNotFound as KeychainError { logger.debug("Settings not found in keychain.") - - state.tunnelSettings = nil - state.setTunnel(nil, shouldRefreshTunnelState: true) - - if let tunnelProvider = tunnelProvider { - removeOrphanedTunnel(tunnelProvider: tunnelProvider) { error in - self.finish(completion: error.map { .failure($0) } ?? .success(())) - } - } else { - finish(completion: .success(())) - } } catch let error as DecodingError { - state.tunnelSettings = nil - state.setTunnel(nil, shouldRefreshTunnelState: true) + logger.error( + chainedError: AnyChainedError(error), + message: "Cannot decode settings. Will attempt to delete them from keychain." + ) do { - logger.error( - chainedError: AnyChainedError(error), - message: "Cannot decode settings. Will attempt to delete them from keychain." - ) - try SettingsManager.deleteSettings() } catch { + returnError = .deleteSettings(error) + logger.error( chainedError: AnyChainedError(error), message: "Failed to delete settings from keychain." ) } + } catch { + returnError = .readSettings(error) - let returnError: TunnelManager.Error = .readSettings(error) + logger.error( + chainedError: AnyChainedError(error), + message: "Unexpected error when reading settings." + ) + } - if let tunnelProvider = tunnelProvider { - removeOrphanedTunnel(tunnelProvider: tunnelProvider) { _ in - self.finish(completion: .failure(returnError)) - } - } else { - finish(completion: .failure(returnError)) - } - } catch { - state.tunnelSettings = nil - state.setTunnel(nil, shouldRefreshTunnelState: true) + let tunnel = tunnels?.first.map { tunnelProvider in + return Tunnel(tunnelProvider: tunnelProvider) + } - let returnError: TunnelManager.Error = .readSettings(error) + if let tunnelSettings = tunnelSettings { + state.tunnelSettings = tunnelSettings + state.setTunnel(tunnel, shouldRefreshTunnelState: true) + state.isLoadedConfiguration = true - if let tunnelProvider = tunnelProvider { - removeOrphanedTunnel(tunnelProvider: tunnelProvider) { _ in - self.finish(completion: .failure(returnError)) - } - } else { - finish(completion: .failure(returnError)) + finish(completion: .success(())) + } else { + let onFinish = { + self.state.tunnelSettings = nil + self.state.setTunnel(nil, shouldRefreshTunnelState: true) + self.state.isLoadedConfiguration = returnError == nil + + self.finish(completion: returnError.map { .failure($0) } ?? .success(())) } - } - } - private func removeOrphanedTunnel(tunnelProvider: TunnelProviderManagerType, completion: @escaping (TunnelManager.Error?) -> Void) { - logger.debug("Remove orphaned VPN configuration.") + if let tunnel = tunnel { + logger.debug("Remove orphaned VPN configuration.") - tunnelProvider.removeFromPreferences { error in - self.dispatchQueue.async { - if let error = error { - self.logger.error( - chainedError: AnyChainedError(error), - message: "Failed to remove VPN configuration." - ) - completion(.removeVPNConfiguration(error)) - } else { - completion(nil) + tunnel.removeFromPreferences { error in + self.dispatchQueue.async { + if let error = error { + self.logger.error( + chainedError: AnyChainedError(error), + message: "Failed to remove VPN configuration." + ) + } + onFinish() + } } + } else { + onFinish() } } } diff --git a/ios/MullvadVPN/TunnelManager/ReloadTunnelOperation.swift b/ios/MullvadVPN/TunnelManager/ReloadTunnelOperation.swift index ab610d1fc8..e1d6429d6f 100644 --- a/ios/MullvadVPN/TunnelManager/ReloadTunnelOperation.swift +++ b/ios/MullvadVPN/TunnelManager/ReloadTunnelOperation.swift @@ -12,24 +12,15 @@ class ReloadTunnelOperation: ResultOperation<(), TunnelManager.Error> { private let state: TunnelManager.State private var task: Cancellable? - init( - queue: DispatchQueue, - state: TunnelManager.State, - completionHandler: @escaping CompletionHandler - ) - { + init(dispatchQueue: DispatchQueue, state: TunnelManager.State) { self.state = state - super.init( - dispatchQueue: queue, - completionQueue: queue, - completionHandler: completionHandler - ) + super.init(dispatchQueue: dispatchQueue) } override func main() { guard let tunnel = self.state.tunnel else { - finish(completion: .failure(.unsetAccount)) + finish(completion: .failure(.unsetTunnel)) return } @@ -38,9 +29,7 @@ class ReloadTunnelOperation: ResultOperation<(), TunnelManager.Error> { task = session.reloadTunnelSettings { [weak self] completion in guard let self = self else { return } - self.dispatchQueue.async { - self.finish(completion: completion.mapError { .reloadTunnel($0) }) - } + self.finish(completion: completion.mapError { .reloadTunnel($0) }) } } diff --git a/ios/MullvadVPN/TunnelManager/RotateKeyOperation.swift b/ios/MullvadVPN/TunnelManager/RotateKeyOperation.swift index bde9f12811..d8a0a4fed1 100644 --- a/ios/MullvadVPN/TunnelManager/RotateKeyOperation.swift +++ b/ios/MullvadVPN/TunnelManager/RotateKeyOperation.swift @@ -10,7 +10,7 @@ import Foundation import Logging import class WireGuardKitTypes.PrivateKey -class RotateKeyOperation: ResultOperation<TunnelManager.KeyRotationResult, TunnelManager.Error> { +class RotateKeyOperation: ResultOperation<Bool, TunnelManager.Error> { private let state: TunnelManager.State private let devicesProxy: REST.DevicesProxy @@ -51,7 +51,7 @@ class RotateKeyOperation: ResultOperation<TunnelManager.KeyRotationResult, Tunne if nextRotationDate > Date() { logger.debug("Throttle private key rotation.") - finish(completion: .success(.throttled(creationDate))) + finish(completion: .success(false)) return } else { logger.debug("Private key is old enough, rotate right away.") @@ -119,7 +119,7 @@ class RotateKeyOperation: ResultOperation<TunnelManager.KeyRotationResult, Tunne state.tunnelSettings = newTunnelSettings - finish(completion: .success(.finished)) + finish(completion: .success(true)) } catch { logger.error( chainedError: AnyChainedError(error), diff --git a/ios/MullvadVPN/TunnelManager/StartTunnelOperation.swift b/ios/MullvadVPN/TunnelManager/StartTunnelOperation.swift index 0bcfa6458b..9723e29863 100644 --- a/ios/MullvadVPN/TunnelManager/StartTunnelOperation.swift +++ b/ios/MullvadVPN/TunnelManager/StartTunnelOperation.swift @@ -45,21 +45,16 @@ class StartTunnelOperation: ResultOperation<(), TunnelManager.Error> { finish(completion: .success(())) case .disconnected, .pendingReconnect: - RelayCache.Tracker.shared.read { readResult in - self.dispatchQueue.async { - switch readResult { - case .success(let cachedRelays): - self.didReceiveRelays( - tunnelSettings: tunnelSettings, - cachedRelays: cachedRelays - ) - - case .failure(let error): - self.finish(completion: .failure(.readRelays(error))) - } - } + guard let cachedRelays = RelayCache.Tracker.shared.getCachedRelays() else { + finish(completion: .failure(.readRelays)) + return } + didReceiveRelays( + tunnelSettings: tunnelSettings, + cachedRelays: cachedRelays + ) + default: // Do not attempt to start the tunnel in all other cases. finish(completion: .success(())) diff --git a/ios/MullvadVPN/TunnelManager/StopTunnelOperation.swift b/ios/MullvadVPN/TunnelManager/StopTunnelOperation.swift index 1b7d008f2d..4ca000562d 100644 --- a/ios/MullvadVPN/TunnelManager/StopTunnelOperation.swift +++ b/ios/MullvadVPN/TunnelManager/StopTunnelOperation.swift @@ -28,7 +28,7 @@ class StopTunnelOperation: ResultOperation<(), TunnelManager.Error> { override func main() { guard let tunnel = state.tunnel else { - finish(completion: .failure(.unsetAccount)) + finish(completion: .failure(.unsetTunnel)) return } diff --git a/ios/MullvadVPN/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift index 8dfd651062..ff74c6213e 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift @@ -6,7 +6,6 @@ // Copyright © 2019 Mullvad VPN AB. All rights reserved. // -import BackgroundTasks import Foundation import NetworkExtension import UIKit @@ -70,12 +69,20 @@ final class TunnelManager: TunnelManagerStateDelegate { private let state: TunnelManager.State private var privateKeyRotationTimer: DispatchSourceTimer? + private var lastKeyRotationData: ( + attempt: Date, + completion: OperationCompletion<Bool, TunnelManager.Error> + )? private var isRunningPeriodicPrivateKeyRotation = false private var tunnelStatusPollTimer: DispatchSourceTimer? private var isPolling = false private var lastConnectingDate: Date? + var isLoadedConfiguration: Bool { + return state.isLoadedConfiguration + } + var accountNumber: String? { return state.tunnelSettings?.account.number } @@ -103,17 +110,10 @@ final class TunnelManager: TunnelManagerStateDelegate { private init(accountsProxy: REST.AccountsProxy, devicesProxy: REST.DevicesProxy) { self.accountsProxy = accountsProxy self.devicesProxy = devicesProxy - self.state = TunnelManager.State(queue: stateQueue) + self.state = TunnelManager.State(delegateQueue: stateQueue) self.state.delegate = self self.operationQueue.name = "TunnelManager.operationQueue" self.operationQueue.underlyingQueue = stateQueue - - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationDidBecomeActive), - name: UIApplication.didBecomeActiveNotification, - object: nil - ) } // MARK: - Periodic private key rotation @@ -125,7 +125,6 @@ final class TunnelManager: TunnelManagerStateDelegate { self.logger.debug("Start periodic private key rotation.") self.isRunningPeriodicPrivateKeyRotation = true - self.updatePrivateKeyRotationTimer() } } @@ -137,62 +136,92 @@ final class TunnelManager: TunnelManagerStateDelegate { self.logger.debug("Stop periodic private key rotation.") self.isRunningPeriodicPrivateKeyRotation = false + self.updatePrivateKeyRotationTimer() + } + } - self.privateKeyRotationTimer?.cancel() - self.privateKeyRotationTimer = nil + func getNextKeyRotationDate() -> Date? { + return stateQueue.sync { + return _getNextKeyRotationDate() } } private func updatePrivateKeyRotationTimer() { dispatchPrecondition(condition: .onQueue(stateQueue)) + privateKeyRotationTimer?.cancel() + privateKeyRotationTimer = nil + guard self.isRunningPeriodicPrivateKeyRotation else { return } - if let tunnelSettings = state.tunnelSettings { - let creationDate = tunnelSettings.device.wgKeyData.creationDate - let scheduleDate = Date( - timeInterval: TunnelManagerConfiguration.privateKeyRotationInterval, - since: creationDate - ) + guard let scheduleDate = _getNextKeyRotationDate() else { return } - schedulePrivateKeyRotationTimer(scheduleDate) - } else { - privateKeyRotationTimer?.cancel() - privateKeyRotationTimer = nil + let timer = DispatchSource.makeTimerSource(queue: stateQueue) + + timer.setEventHandler { [weak self] in + guard let self = self else { return } + + _ = self.rotatePrivateKey(forceRotate: false) { _ in + // no-op + } } + + timer.schedule(wallDeadline: .now() + scheduleDate.timeIntervalSinceNow) + timer.activate() + + privateKeyRotationTimer = timer + + logger.debug("Schedule next private key rotation at \(scheduleDate.logFormatDate()).") } - /// Schedule new private key rotation timer. - private func schedulePrivateKeyRotationTimer(_ scheduleDate: Date) { - dispatchPrecondition(condition: .onQueue(stateQueue)) + private func _getNextKeyRotationDate() -> Date? { + guard let tunnelSettings = state.tunnelSettings else { + return nil + } - let timer = DispatchSource.makeTimerSource(queue: stateQueue) + if case .some(let (lastAttemptDate, completion)) = lastKeyRotationData { + // Do not rotate the key when logged out. + if case .unsetAccount = completion.error { + return nil + } - timer.setEventHandler { [weak self] in - guard let self = self else { return } + // Do not rotate the key if account or device is not found. + if case .rotateKey(.unhandledResponse(_, let serverErrorResponse)) = completion.error, + serverErrorResponse?.code == .invalidAccount || + serverErrorResponse?.code == .deviceNotFound { + return nil + } - _ = self.rotatePrivateKey { completion in - self.stateQueue.async { - if let scheduleDate = self.handlePrivateKeyRotationCompletion(completion) { - guard self.isRunningPeriodicPrivateKeyRotation else { return } + // Retry at equal interval if failed or cancelled. + if !completion.isSuccess { + let date = lastAttemptDate.addingTimeInterval( + TunnelManagerConfiguration.privateKeyRotationFailureRetryInterval + ) - self.schedulePrivateKeyRotationTimer(scheduleDate) - } - } + return max(date, Date()) } } - // Cancel active timer - privateKeyRotationTimer?.cancel() + // Rotate at long intervals otherwise. + let date = tunnelSettings.device.wgKeyData.creationDate + .addingTimeInterval(TunnelManagerConfiguration.privateKeyRotationInterval) - // Assign new timer - privateKeyRotationTimer = timer + return max(date, Date()) + } - // Schedule and activate - timer.schedule(wallDeadline: .now() + scheduleDate.timeIntervalSinceNow) - timer.activate() + private func setFinishedKeyRotation(_ completion: OperationCompletion<Bool, TunnelManager.Error>) { + dispatchPrecondition(condition: .onQueue(stateQueue)) + + lastKeyRotationData = (Date(), completion) + updatePrivateKeyRotationTimer() + } + + private func resetKeyRotationData() { + dispatchPrecondition(condition: .onQueue(stateQueue)) + + lastKeyRotationData = nil + updatePrivateKeyRotationTimer() - logger.debug("Schedule next private key rotation on \(scheduleDate.logFormatDate())") } // MARK: - Public methods @@ -207,7 +236,9 @@ final class TunnelManager: TunnelManagerStateDelegate { let loadTunnelOperation = LoadTunnelConfigurationOperation( dispatchQueue: stateQueue, state: state - ) { [weak self] completion in + ) + loadTunnelOperation.completionQueue = stateQueue + loadTunnelOperation.completionHandler = { [weak self] completion in guard let self = self else { return } dispatchPrecondition(condition: .onQueue(self.stateQueue)) @@ -242,6 +273,13 @@ final class TunnelManager: TunnelManagerStateDelegate { operationQueue.addOperation(groupOperation) } + func refreshTunnelStatus() { + stateQueue.async { + self.logger.debug("Refresh tunnel status due to application becoming active.") + self._refreshTunnelStatus() + } + } + func startTunnel() { let operation = StartTunnelOperation( dispatchQueue: stateQueue, @@ -274,11 +312,7 @@ final class TunnelManager: TunnelManagerStateDelegate { dispatchQueue: stateQueue, state: state ) { [weak self] completion in - guard let self = self else { return } - - dispatchPrecondition(condition: .onQueue(self.stateQueue)) - - guard let error = completion.error else { return } + guard let self = self, let error = completion.error else { return } // Pass tunnel failure to observers DispatchQueue.main.async { @@ -294,8 +328,10 @@ final class TunnelManager: TunnelManagerStateDelegate { operationQueue.addOperation(operation) } - func reconnectTunnel(completionHandler: (() -> Void)?) { - let operation = ReloadTunnelOperation(queue: stateQueue, state: state) { [weak self] completion in + func reconnectTunnel(completionHandler: ((OperationCompletion<(), TunnelManager.Error>) -> Void)? = nil) { + let operation = ReloadTunnelOperation(dispatchQueue: stateQueue, state: state) + + operation.completionHandler = { [weak self] completion in guard let self = self else { return } dispatchPrecondition(condition: .onQueue(self.stateQueue)) @@ -310,16 +346,17 @@ final class TunnelManager: TunnelManagerStateDelegate { switch self.tunnelState { case .connecting, .reconnecting: self.logger.debug("Refresh tunnel status due to reconnect.") - self.refreshTunnelStatus() + self._refreshTunnelStatus() default: break } DispatchQueue.main.async { - completionHandler?() + completionHandler?(completion) } } + operation.completionQueue = stateQueue operation.addObserver( BackgroundObserver(name: "Reconnect tunnel", cancelUponExpiration: true) @@ -355,9 +392,7 @@ final class TunnelManager: TunnelManagerStateDelegate { operation.completionHandler = { [weak self] completion in guard let self = self else { return } - dispatchPrecondition(condition: .onQueue(self.stateQueue)) - - self.updatePrivateKeyRotationTimer() + self.resetKeyRotationData() DispatchQueue.main.async { completionHandler(completion) @@ -428,59 +463,29 @@ final class TunnelManager: TunnelManagerStateDelegate { return operation } - func regeneratePrivateKey(completionHandler: ((TunnelManager.Error?) -> Void)? = nil) { - let operation = RotateKeyOperation( - dispatchQueue: stateQueue, - state: state, - devicesProxy: devicesProxy, - rotationInterval: nil - ) { [weak self] completion in - guard let self = self else { return } - - dispatchPrecondition(condition: .onQueue(self.stateQueue)) - - switch completion { - case .success: - self.updatePrivateKeyRotationTimer() - self.reconnectTunnel(completionHandler: nil) - - case .failure(let error): - self.logger.error(chainedError: error, message: "Failed to regenerate private key.") - - case .cancelled: - break - } - - DispatchQueue.main.async { - completionHandler?(completion.error) - } + func rotatePrivateKey( + forceRotate: Bool, + completionHandler: @escaping (OperationCompletion<Bool, TunnelManager.Error>) -> Void + ) -> Cancellable { + var rotationInterval: TimeInterval? + if !forceRotate { + rotationInterval = TunnelManagerConfiguration.privateKeyRotationInterval } - operation.addObserver( - BackgroundObserver(name: "Regenerate private key", cancelUponExpiration: true) - ) - - operation.addCondition( - MutuallyExclusive(category: OperationCategory.changeTunnelSettings) - ) - - operationQueue.addOperation(operation) - } - - func rotatePrivateKey(completionHandler: @escaping (OperationCompletion<KeyRotationResult, TunnelManager.Error>) -> Void) -> Cancellable { let operation = RotateKeyOperation( dispatchQueue: stateQueue, state: state, devicesProxy: devicesProxy, - rotationInterval: TunnelManagerConfiguration.privateKeyRotationInterval + rotationInterval: rotationInterval ) { [weak self] completion in guard let self = self else { return } dispatchPrecondition(condition: .onQueue(self.stateQueue)) + self.setFinishedKeyRotation(completion) switch completion { case .success: - self.reconnectTunnel { + self.reconnectTunnel { _ in completionHandler(completion) } @@ -547,15 +552,37 @@ final class TunnelManager: TunnelManagerStateDelegate { // MARK: - TunnelManagerStateDelegate - func tunnelManagerState(_ state: TunnelManager.State, didChangeTunnelSettings newTunnelSettinggs: TunnelSettingsV2?) { + func tunnelManagerState( + _ state: State, + didChangeLoadedConfiguration isLoadedConfiguration: Bool + ) + { + DispatchQueue.main.async { + self.observerList.forEach { observer in + if isLoadedConfiguration { + observer.tunnelManagerDidLoadConfiguration(self) + } + } + } + } + + func tunnelManagerState( + _ state: TunnelManager.State, + didChangeTunnelSettings newTunnelSettings: TunnelSettingsV2? + ) + { DispatchQueue.main.async { self.observerList.forEach { observer in - observer.tunnelManager(self, didUpdateTunnelSettings: newTunnelSettinggs) + observer.tunnelManager(self, didUpdateTunnelSettings: newTunnelSettings) } } } - func tunnelManagerState(_ state: TunnelManager.State, didChangeTunnelStatus newTunnelStatus: TunnelStatus) { + func tunnelManagerState( + _ state: TunnelManager.State, + didChangeTunnelStatus newTunnelStatus: TunnelStatus + ) + { logger.info("Status: \(newTunnelStatus).") switch newTunnelStatus.state { @@ -576,7 +603,12 @@ final class TunnelManager: TunnelManagerStateDelegate { } } - func tunnelManagerState(_ state: TunnelManager.State, didChangeTunnelProvider newTunnelObject: Tunnel?, shouldRefreshTunnelState: Bool) { + func tunnelManagerState( + _ state: TunnelManager.State, + didChangeTunnelProvider newTunnelObject: Tunnel?, + shouldRefreshTunnelState: Bool + ) + { dispatchPrecondition(condition: .onQueue(stateQueue)) // Register for tunnel connection status changes @@ -589,7 +621,7 @@ final class TunnelManager: TunnelManagerStateDelegate { // Update the existing state if shouldRefreshTunnelState { logger.debug("Refresh tunnel status for new tunnel.") - refreshTunnelStatus() + _refreshTunnelStatus() } } @@ -611,10 +643,10 @@ final class TunnelManager: TunnelManagerStateDelegate { statusObserver = nil } - private func refreshTunnelStatus() { + private func _refreshTunnelStatus() { dispatchPrecondition(condition: .onQueue(stateQueue)) - if let connectionStatus = self.state.tunnel?.status { + if let connectionStatus = state.tunnel?.status { updateTunnelStatus(connectionStatus) } } @@ -637,9 +669,7 @@ final class TunnelManager: TunnelManagerStateDelegate { self.startTunnel() } - operation.addCondition( - MutuallyExclusive(category: OperationCategory.tunnelStateUpdate) - ) + operation.addCondition(MutuallyExclusive(category: OperationCategory.tunnelStateUpdate)) // Cancel last VPN status mapping operation lastMapConnectionStatusOperation?.cancel() @@ -648,13 +678,6 @@ final class TunnelManager: TunnelManagerStateDelegate { operationQueue.addOperation(operation) } - @objc private func applicationDidBecomeActive() { - stateQueue.async { - self.logger.debug("Refresh tunnel status due to application becoming active.") - self.refreshTunnelStatus() - } - } - fileprivate func scheduleTunnelSettingsUpdate(taskName: String, modificationBlock: @escaping (inout TunnelSettingsV2) -> Void, completionHandler: @escaping (TunnelManager.Error?) -> Void) { let operation = ResultBlockOperation<Void, TunnelManager.Error>( dispatchQueue: stateQueue @@ -742,7 +765,7 @@ final class TunnelManager: TunnelManagerStateDelegate { guard let self = self else { return } self.logger.debug("Refresh tunnel status (poll).") - self.refreshTunnelStatus() + self._refreshTunnelStatus() } timer.schedule( @@ -769,155 +792,7 @@ final class TunnelManager: TunnelManagerStateDelegate { } -extension TunnelManager { - /// Key rotation result. - enum KeyRotationResult: CustomStringConvertible { - /// Request to rotate the key was throttled. - case throttled(_ lastKeyCreationDate: Date) - - /// New key was generated. - case finished - - var description: String { - switch self { - case .throttled: - return "throttled" - case .finished: - return "finished" - } - } - } -} - -// MARK: - Background tasks - -@available(iOS 13.0, *) -extension TunnelManager { - - /// Register background task with scheduler. - func registerBackgroundTask() { - let taskIdentifier = ApplicationConfiguration.privateKeyRotationTaskIdentifier - - let isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: taskIdentifier, using: nil) { task in - self.handleBackgroundTask(task as! BGProcessingTask) - } - - if isRegistered { - logger.debug("Registered private key rotation task") - } else { - logger.error("Failed to register private key rotation task") - } - } - - /// Schedule background task relative to the private key creation date. - func scheduleBackgroundTask() throws { - guard let tunnelSettings = state.tunnelSettings else { - throw Error.unsetAccount - } - - let creationDate = tunnelSettings.device.wgKeyData.creationDate - let beginDate = Date( - timeInterval: TunnelManagerConfiguration.privateKeyRotationInterval, - since: creationDate - ) - - return try submitBackgroundTask(at: beginDate) - } - - /// Create and submit task request to scheduler. - private func submitBackgroundTask(at beginDate: Date) throws { - let taskIdentifier = ApplicationConfiguration.privateKeyRotationTaskIdentifier - - let request = BGProcessingTaskRequest(identifier: taskIdentifier) - request.earliestBeginDate = beginDate - request.requiresNetworkConnectivity = true - - try BGTaskScheduler.shared.submit(request) - } - - /// Background task handler. - private func handleBackgroundTask(_ task: BGProcessingTask) { - logger.debug("Start private key rotation task") - - let cancellableTask = rotatePrivateKey { completion in - if let scheduleDate = self.handlePrivateKeyRotationCompletion(completion) { - // Schedule next background task - do { - try self.submitBackgroundTask(at: scheduleDate) - - self.logger.debug( - "Scheduled next private key rotation task at \(scheduleDate.logFormatDate())" - ) - } catch { - self.logger.error( - chainedError: AnyChainedError(error), - message: "Failed to schedule next private key rotation task." - ) - } - } - - // Complete current task - task.setTaskCompleted(success: completion.isSuccess) - } - - task.expirationHandler = { - cancellableTask.cancel() - } - } -} - -extension TunnelManager { - fileprivate func handlePrivateKeyRotationCompletion(_ completion: OperationCompletion<KeyRotationResult, TunnelManager.Error>) -> Date? { - switch completion { - case .success(let result): - switch result { - case .finished: - logger.debug("Finished private key rotation.") - case .throttled: - logger.debug("Private key was already rotated earlier.") - } - - return nextScheduleDate(result) - - case .failure(let error): - logger.error(chainedError: error, message: "Failed to rotate private key.") - - return nextRetryScheduleDate(error) - - case .cancelled: - logger.debug("Private key rotation was cancelled.") - - return Date(timeIntervalSinceNow: TunnelManagerConfiguration.privateKeyRotationFailureRetryInterval) - } - } - - fileprivate func nextScheduleDate(_ result: KeyRotationResult) -> Date { - switch result { - case .finished: - return Date(timeIntervalSinceNow: TunnelManagerConfiguration.privateKeyRotationInterval) - - case .throttled(let lastKeyCreationDate): - return Date(timeInterval: TunnelManagerConfiguration.privateKeyRotationInterval, since: lastKeyCreationDate) - } - } - - fileprivate func nextRetryScheduleDate(_ error: TunnelManager.Error) -> Date? { - switch error { - case .unsetAccount: - // Do not retry if logged out. - return nil - - case .rotateKey(.unhandledResponse(_, let serverErrorResponse)) - where serverErrorResponse?.code == .invalidAccount || - serverErrorResponse?.code == .deviceNotFound: - // Do not retry if account or device were removed. - return nil - - default: - return Date(timeIntervalSinceNow: TunnelManagerConfiguration.privateKeyRotationFailureRetryInterval) - } - } -} +// MARK: - AppStore payment observer extension TunnelManager: AppStorePaymentObserver { func appStorePaymentManager(_ manager: AppStorePaymentManager, diff --git a/ios/MullvadVPN/TunnelManager/TunnelManagerError.swift b/ios/MullvadVPN/TunnelManager/TunnelManagerError.swift index fb1fd03c7f..48ea2f97b1 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelManagerError.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelManagerError.swift @@ -14,6 +14,9 @@ extension TunnelManager { /// Account is unset. case unsetAccount + /// Tunnel is not set yet. + case unsetTunnel + /// Failure to start the VPN tunnel via system call. case startVPNTunnel(Swift.Error) @@ -39,7 +42,7 @@ extension TunnelManager { case deleteSettings(Swift.Error) /// Failure to read relays cache. - case readRelays(RelayCache.Error) + case readRelays /// Failure to find a relay satisfying the given constraints. case cannotSatisfyRelayConstraints @@ -72,6 +75,8 @@ extension TunnelManager { switch self { case .unsetAccount: return "Account is unset." + case .unsetTunnel: + return "Tunnel is unset." case .startVPNTunnel: return "Failed to start the VPN tunnel." case .loadAllVPNConfigurations: diff --git a/ios/MullvadVPN/TunnelManager/TunnelManagerState.swift b/ios/MullvadVPN/TunnelManager/TunnelManagerState.swift index 990e8e482f..002e3e3862 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelManagerState.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelManagerState.swift @@ -10,19 +10,37 @@ import Foundation import NetworkExtension protocol TunnelManagerStateDelegate: AnyObject { - func tunnelManagerState(_ state: TunnelManager.State, didChangeTunnelSettings newTunnelSettings: TunnelSettingsV2?) - func tunnelManagerState(_ state: TunnelManager.State, didChangeTunnelStatus newTunnelStatus: TunnelStatus) - func tunnelManagerState(_ state: TunnelManager.State, didChangeTunnelProvider newTunnelObject: Tunnel?, shouldRefreshTunnelState: Bool) + + func tunnelManagerState( + _ state: TunnelManager.State, + didChangeLoadedConfiguration isLoadedConfiguration: Bool + ) + + func tunnelManagerState( + _ state: TunnelManager.State, + didChangeTunnelSettings newTunnelSettings: TunnelSettingsV2? + ) + + func tunnelManagerState( + _ state: TunnelManager.State, + didChangeTunnelStatus newTunnelStatus: TunnelStatus + ) + + func tunnelManagerState( + _ state: TunnelManager.State, + didChangeTunnelProvider newTunnelObject: Tunnel?, + shouldRefreshTunnelState: Bool + ) } extension TunnelManager { class State { - let queue: DispatchQueue weak var delegate: TunnelManagerStateDelegate? + let delegateQueue: DispatchQueue - private let queueMarkerKey = DispatchSpecificKey<Bool>() - + private let nslock = NSLock() + private var _isLoadedConfiguration = false private var _tunnelSettings: TunnelSettingsV2? private var _tunnelObject: Tunnel? private var _tunnelStatus = TunnelStatus( @@ -31,73 +49,97 @@ extension TunnelManager { state: .disconnected ) - var tunnelSettings: TunnelSettingsV2? { + var isLoadedConfiguration: Bool { get { - return performBlock { - return _tunnelSettings + nslock.lock() + defer { nslock.unlock() } + + return _isLoadedConfiguration + } + set { + nslock.lock() + defer { nslock.unlock() } + + guard _isLoadedConfiguration != newValue else { return } + + _isLoadedConfiguration = newValue + + delegateQueue.async { + self.delegate?.tunnelManagerState( + self, + didChangeLoadedConfiguration: newValue + ) } } + } + + var tunnelSettings: TunnelSettingsV2? { + get { + nslock.lock() + defer { nslock.unlock() } + + return _tunnelSettings + } set { - performBlock { - if _tunnelSettings != newValue { - _tunnelSettings = newValue + nslock.lock() + defer { nslock.unlock() } + + guard _tunnelSettings != newValue else { return } + + _tunnelSettings = newValue - delegate?.tunnelManagerState(self, didChangeTunnelSettings: newValue) - } + delegateQueue.async { + self.delegate?.tunnelManagerState(self, didChangeTunnelSettings: newValue) } } } var tunnel: Tunnel? { - return performBlock { - return _tunnelObject - } + nslock.lock() + defer { nslock.unlock() } + + return _tunnelObject } var tunnelStatus: TunnelStatus { get { - return performBlock { - return _tunnelStatus - } + nslock.lock() + defer { nslock.unlock() } + + return _tunnelStatus } set { - performBlock { - if _tunnelStatus != newValue { - _tunnelStatus = newValue + nslock.lock() + defer { nslock.unlock() } - delegate?.tunnelManagerState(self, didChangeTunnelStatus: newValue) - } - } - } - } + guard _tunnelStatus != newValue else { return } - init(queue: DispatchQueue) { - self.queue = queue + _tunnelStatus = newValue - queue.setSpecific(key: queueMarkerKey, value: true) + delegateQueue.async { + self.delegate?.tunnelManagerState(self, didChangeTunnelStatus: newValue) + } + } } - deinit { - queue.setSpecific(key: queueMarkerKey, value: nil) + init(delegateQueue: DispatchQueue) { + self.delegateQueue = delegateQueue } func setTunnel(_ newTunnelObject: Tunnel?, shouldRefreshTunnelState: Bool) { - performBlock { - if _tunnelObject != newTunnelObject { - _tunnelObject = newTunnelObject + nslock.lock() + defer { nslock.unlock() } - delegate?.tunnelManagerState(self, didChangeTunnelProvider: newTunnelObject, shouldRefreshTunnelState: shouldRefreshTunnelState) - } - } - } + guard _tunnelObject != newTunnelObject else { return } - private func performBlock<T>(_ block: () -> T) -> T { - let isTargetQueue = DispatchQueue.getSpecific(key: queueMarkerKey) ?? false + _tunnelObject = newTunnelObject - if isTargetQueue { - return block() - } else { - return queue.sync(execute: block) + delegateQueue.async { + self.delegate?.tunnelManagerState( + self, + didChangeTunnelProvider: newTunnelObject, + shouldRefreshTunnelState: shouldRefreshTunnelState + ) } } } diff --git a/ios/MullvadVPN/TunnelManager/TunnelObserver.swift b/ios/MullvadVPN/TunnelManager/TunnelObserver.swift index 6e1934e30e..93ab9d8849 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelObserver.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelObserver.swift @@ -9,6 +9,7 @@ import Foundation protocol TunnelObserver: AnyObject { + func tunnelManagerDidLoadConfiguration(_ manager: TunnelManager) func tunnelManager(_ manager: TunnelManager, didUpdateTunnelState tunnelState: TunnelState) func tunnelManager(_ manager: TunnelManager, didUpdateTunnelSettings tunnelSettings: TunnelSettingsV2?) func tunnelManager(_ manager: TunnelManager, didFailWithError error: TunnelManager.Error) diff --git a/ios/MullvadVPN/WireguardKeysViewController.swift b/ios/MullvadVPN/WireguardKeysViewController.swift index 655d665b8f..e772290cd2 100644 --- a/ios/MullvadVPN/WireguardKeysViewController.swift +++ b/ios/MullvadVPN/WireguardKeysViewController.swift @@ -104,6 +104,10 @@ class WireguardKeysViewController: UIViewController, TunnelObserver { // MARK: - TunnelObserver + func tunnelManagerDidLoadConfiguration(_ manager: TunnelManager) { + // no-op + } + func tunnelManager(_ manager: TunnelManager, didUpdateTunnelState tunnelState: TunnelState) { // no-op } @@ -252,8 +256,8 @@ class WireguardKeysViewController: UIViewController, TunnelObserver { private func regeneratePrivateKey() { self.updateViewState(.regeneratingKey) - TunnelManager.shared.regeneratePrivateKey { [weak self] error in - if let error = error { + _ = TunnelManager.shared.rotatePrivateKey(forceRotate: true) { [weak self] completion in + if let error = completion.error { self?.showKeyRegenerationFailureAlert(error) self?.updateViewState(.regeneratedKey(false)) } else { |
