diff options
| -rw-r--r-- | ios/.gitignore | 3 | ||||
| -rw-r--r-- | ios/Assets/.gitkeep | 0 | ||||
| -rw-r--r-- | ios/BuildInstructions.md | 9 | ||||
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/project.pbxproj | 21 | ||||
| -rw-r--r-- | ios/MullvadVPN.xcodeproj/xcshareddata/xcschemes/MullvadVPN.xcscheme | 20 | ||||
| -rw-r--r-- | ios/MullvadVPN/MullvadRpc.swift | 241 | ||||
| -rw-r--r-- | ios/MullvadVPN/ObserverList.swift | 53 | ||||
| -rw-r--r-- | ios/MullvadVPN/RelayCache.swift | 364 | ||||
| -rw-r--r-- | ios/MullvadVPN/RelayList.swift | 9 | ||||
| -rwxr-xr-x | ios/update-relays.sh | 20 |
10 files changed, 557 insertions, 183 deletions
diff --git a/ios/.gitignore b/ios/.gitignore index 7c18207d40..7fbc0584a7 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -2,6 +2,9 @@ # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore +## Generated assets +Assets/relays.json + ## Build generated build/ DerivedData/ diff --git a/ios/Assets/.gitkeep b/ios/Assets/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/ios/Assets/.gitkeep diff --git a/ios/BuildInstructions.md b/ios/BuildInstructions.md index 13f84867ce..4f8dddca4d 100644 --- a/ios/BuildInstructions.md +++ b/ios/BuildInstructions.md @@ -147,6 +147,15 @@ xcrun altool --store-password-in-keychain-item <KEYCHAIN_ITEM_NAME> \ [Apple ID website]: https://appleid.apple.com/account/manage +# Install Xcode project dependencies + +Xcode project uses a pre-build action to bundle the relay list with the app, which depends on `jq`. +You can install it with `brew install jq`. See [jq website] for more installation options. + +[jq website]: https://stedolan.github.io/jq/download/ + +The log output is saved to `ios/prebuild.log`. + # Automated build and deployment Build script does not bump the build number, so make sure to do that manually and commit to repo: diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 98627ed500..62f24964fa 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -150,6 +150,8 @@ 58C6B36122C0EC82003C19AD /* AnyIPEndpoint+DNS64.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6B36022C0EC82003C19AD /* AnyIPEndpoint+DNS64.swift */; }; 58C6B36522C10596003C19AD /* AnyIPEndpoint+Wireguard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6B36422C10596003C19AD /* AnyIPEndpoint+Wireguard.swift */; }; 58C6B36722C106FC003C19AD /* WireguardCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6B36622C106FC003C19AD /* WireguardCommand.swift */; }; + 58CC40EF24A601900019D96E /* ObserverList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CC40EE24A601900019D96E /* ObserverList.swift */; }; + 58CC40F024A602780019D96E /* ObserverList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CC40EE24A601900019D96E /* ObserverList.swift */; }; 58CCA010224249A1004F3011 /* ConnectViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA00F224249A1004F3011 /* ConnectViewController.swift */; }; 58CCA01222424D11004F3011 /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA01122424D11004F3011 /* SettingsViewController.swift */; }; 58CCA0162242560B004F3011 /* UIColor+Palette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA0152242560B004F3011 /* UIColor+Palette.swift */; }; @@ -170,6 +172,8 @@ 58F3C0962492617E003E76BE /* AsyncOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58E973DD24850EB600096F90 /* AsyncOperation.swift */; }; 58F3C0A2249CA1E0003E76BE /* HeaderBarView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 58F3C0A1249CA1E0003E76BE /* HeaderBarView.xib */; }; 58F3C0A4249CB069003E76BE /* HeaderBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F3C0A3249CB069003E76BE /* HeaderBarView.swift */; }; + 58F3C0A624A50157003E76BE /* relays.json in Resources */ = {isa = PBXBuildFile; fileRef = 58F3C0A524A50155003E76BE /* relays.json */; }; + 58F3C0A724A50C02003E76BE /* relays.json in Resources */ = {isa = PBXBuildFile; fileRef = 58F3C0A524A50155003E76BE /* relays.json */; }; 58F840AF2464382C0044E708 /* KeychainItemRevision.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F840AE2464382C0044E708 /* KeychainItemRevision.swift */; }; 58F840B02464382C0044E708 /* KeychainItemRevision.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F840AE2464382C0044E708 /* KeychainItemRevision.swift */; }; 58F840B22464491D0044E708 /* ChainedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58F840B12464491D0044E708 /* ChainedError.swift */; }; @@ -325,6 +329,7 @@ 58C6B36022C0EC82003C19AD /* AnyIPEndpoint+DNS64.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyIPEndpoint+DNS64.swift"; sourceTree = "<group>"; }; 58C6B36422C10596003C19AD /* AnyIPEndpoint+Wireguard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyIPEndpoint+Wireguard.swift"; sourceTree = "<group>"; }; 58C6B36622C106FC003C19AD /* WireguardCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireguardCommand.swift; sourceTree = "<group>"; }; + 58CC40EE24A601900019D96E /* ObserverList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObserverList.swift; sourceTree = "<group>"; }; 58CCA00F224249A1004F3011 /* ConnectViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewController.swift; sourceTree = "<group>"; }; 58CCA01122424D11004F3011 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; }; 58CCA0152242560B004F3011 /* UIColor+Palette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+Palette.swift"; sourceTree = "<group>"; }; @@ -352,6 +357,7 @@ 58F19E34228C15BA00C7710B /* SpinnerActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpinnerActivityIndicatorView.swift; sourceTree = "<group>"; }; 58F3C0A1249CA1E0003E76BE /* HeaderBarView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = HeaderBarView.xib; sourceTree = "<group>"; }; 58F3C0A3249CB069003E76BE /* HeaderBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderBarView.swift; sourceTree = "<group>"; }; + 58F3C0A524A50155003E76BE /* relays.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = relays.json; sourceTree = "<group>"; }; 58F840AE2464382C0044E708 /* KeychainItemRevision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainItemRevision.swift; sourceTree = "<group>"; }; 58F840B12464491D0044E708 /* ChainedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChainedError.swift; sourceTree = "<group>"; }; 58FAEDEB245059F000CB0F5B /* KeychainAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainAttributes.swift; sourceTree = "<group>"; }; @@ -509,7 +515,6 @@ 5840250022B1124600E4CFEC /* IpAddress+Codable.swift */, 58C6B34E22BB7AC0003C19AD /* IPAddressRange.swift */, 58561C98239A5D1500BD6B5E /* IPEndpoint.swift */, - 58ADDB3B227B1BD200FAFEA7 /* JsonRpc.swift */, 58FAEDF6245088E100CB0F5B /* Keychain.swift */, 58FAEDEB245059F000CB0F5B /* KeychainAttributes.swift */, 58FAEE0024533A9C00CB0F5B /* KeychainClass.swift */, @@ -530,8 +535,10 @@ 588AE72E2362001F009F9F2E /* MutuallyExclusive.swift */, 58906DDF2445C7A5002F0673 /* NEProviderStopReason+Debug.swift */, 5811DE4F239014550011EB53 /* NEVPNStatus+Debug.swift */, + 58CC40EE24A601900019D96E /* ObserverList.swift */, 580EE1FF24B3218800F9D8A1 /* Operations */, 5845F841236CBACD00B2D93C /* PacketTunnelIpc.swift */, + 58ADDB3B227B1BD200FAFEA7 /* JsonRpc.swift */, 58BFA5C522A7C97F00A6173D /* RelayCache.swift */, 58781CC822AE7CA8009B9D8E /* RelayConstraints.swift */, 5888AD88227B18C40051EB06 /* RelayList.swift */, @@ -610,6 +617,14 @@ path = Configurations; sourceTree = "<group>"; }; + 58F3C0A824A50C0E003E76BE /* Assets */ = { + isa = PBXGroup; + children = ( + 58F3C0A524A50155003E76BE /* relays.json */, + ); + path = Assets; + sourceTree = "<group>"; + }; /* End PBXGroup section */ /* Begin PBXLegacyTarget section */ @@ -782,6 +797,7 @@ buildActionMask = 2147483647; files = ( 58F3C0A2249CA1E0003E76BE /* HeaderBarView.xib in Resources */, + 58F3C0A624A50157003E76BE /* relays.json in Resources */, 58CE5E6E224146210008646E /* LaunchScreen.storyboard in Resources */, 58CE5E6B224146210008646E /* Assets.xcassets in Resources */, 58CE5E69224146200008646E /* Main.storyboard in Resources */, @@ -792,6 +808,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 58F3C0A724A50C02003E76BE /* relays.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -914,6 +931,7 @@ 5845F83A236C6A7200B2D93C /* AutoDisposableSink.swift in Sources */, 5840250422B11AB700E4CFEC /* MullvadEndpoint.swift in Sources */, 58FD5BEC2420F58A00112C88 /* SKPaymentQueuePublisher.swift in Sources */, + 58CC40EF24A601900019D96E /* ObserverList.swift in Sources */, 58CCA01822426713004F3011 /* AccountViewController.swift in Sources */, 5868585524054096000B8131 /* AppButton.swift in Sources */, 5845F842236CBACD00B2D93C /* PacketTunnelIpc.swift in Sources */, @@ -1011,6 +1029,7 @@ 5860F1EB23AA4CF300CEA666 /* Logging.swift in Sources */, 5860F1C223A785C600CEA666 /* WireguardDevice.swift in Sources */, 580EE21624B3231200F9D8A1 /* OperationBlockObserver.swift in Sources */, + 58CC40F024A602780019D96E /* ObserverList.swift in Sources */, 58C6B35522BB87C4003C19AD /* WireguardPrivateKey.swift in Sources */, 58FAEE0424533AC000CB0F5B /* KeychainClass.swift in Sources */, 58AEEF6C2344A49D00C9BBD5 /* TunnelConfigurationManager.swift in Sources */, diff --git a/ios/MullvadVPN.xcodeproj/xcshareddata/xcschemes/MullvadVPN.xcscheme b/ios/MullvadVPN.xcodeproj/xcshareddata/xcschemes/MullvadVPN.xcscheme index 43022eb3fb..effa6427d1 100644 --- a/ios/MullvadVPN.xcodeproj/xcshareddata/xcschemes/MullvadVPN.xcscheme +++ b/ios/MullvadVPN.xcodeproj/xcshareddata/xcschemes/MullvadVPN.xcscheme @@ -1,10 +1,28 @@ <?xml version="1.0" encoding="UTF-8"?> <Scheme LastUpgradeVersion = "1130" - version = "1.3"> + version = "1.7"> <BuildAction parallelizeBuildables = "YES" buildImplicitDependencies = "YES"> + <PreActions> + <ExecutionAction + ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction"> + <ActionContent + title = "Run Script" + scriptText = "exec > $PROJECT_DIR/prebuild.log 2>&1 $PROJECT_DIR/update-relays.sh "> + <EnvironmentBuildable> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "58CE5E5F224146200008646E" + BuildableName = "MullvadVPN.app" + BlueprintName = "MullvadVPN" + ReferencedContainer = "container:MullvadVPN.xcodeproj"> + </BuildableReference> + </EnvironmentBuildable> + </ActionContent> + </ExecutionAction> + </PreActions> <BuildActionEntries> <BuildActionEntry buildForTesting = "YES" diff --git a/ios/MullvadVPN/MullvadRpc.swift b/ios/MullvadVPN/MullvadRpc.swift index 95f1d36683..59c53348fe 100644 --- a/ios/MullvadVPN/MullvadRpc.swift +++ b/ios/MullvadVPN/MullvadRpc.swift @@ -8,7 +8,6 @@ import Foundation import Network -import Combine /// API server URL private let kMullvadAPIURL = URL(string: "https://api.mullvad.net/rpc/")! @@ -73,7 +72,7 @@ class MullvadRpc { } /// An error type emitted by `MullvadRpc` - enum Error: Swift.Error { + enum Error: ChainedError { /// A network communication error case network(URLError) @@ -86,19 +85,19 @@ class MullvadRpc { /// An error occured when encoding the JSON request case encoding(Swift.Error) - var localizedDescription: String { + var errorDescription: String? { switch self { - case .network(let urlError): - return "Network error: \(urlError.localizedDescription)" + case .network: + return "Network error" - case .server(let serverError): - return "Server error: \(serverError.localizedDescription)" + case .server: + return "Server error" - case .encoding(let encodingError): - return "Encoding error: \(encodingError.localizedDescription)" + case .encoding: + return "Encoding error" - case .decoding(let decodingError): - return "Decoding error: \(decodingError.localizedDescription)" + case .decoding: + return "Decoding error" } } } @@ -108,119 +107,96 @@ class MullvadRpc { return MullvadRpc(session: URLSession(configuration: .ephemeral)) } + class func makeJSONEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + encoder.dateEncodingStrategy = .iso8601 + encoder.dataEncodingStrategy = .base64 + return encoder + } + + class func makeJSONDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .iso8601 + decoder.dataDecodingStrategy = .base64 + return decoder + } + init(session: URLSession) { self.session = session } - func createAccount() -> AnyPublisher<String, MullvadRpc.Error> { + func createAccount() -> MullvadRpc.Request<String> { let request = JsonRpcRequest(method: "create_account", params: []) - return makeDataTaskPublisher(request: request) + return MullvadRpc.Request(session: session, request: request) } - func getRelayList() -> AnyPublisher<RelayList, MullvadRpc.Error> { + func getRelayList() -> MullvadRpc.Request<RelayList> { let request = JsonRpcRequest(method: "relay_list_v3", params: []) - return makeDataTaskPublisher(request: request) + return MullvadRpc.Request(session: session, request: request) } - func getAccountExpiry(accountToken: String) -> AnyPublisher<Date, MullvadRpc.Error> { + func getAccountExpiry(accountToken: String) -> MullvadRpc.Request<Date> { let request = JsonRpcRequest(method: "get_expiry", params: [AnyEncodable(accountToken)]) - return makeDataTaskPublisher(request: request) + return MullvadRpc.Request(session: session, request: request) + } + + func getAccountExpiry(request: MullvadRpc.Request<Date>? = nil) -> MullvadRpc.Operation<Date> { + return MullvadRpc.Operation(request: request) } - func pushWireguardKey(accountToken: String, publicKey: Data) -> AnyPublisher<WireguardAssociatedAddresses, MullvadRpc.Error> { + func pushWireguardKey(accountToken: String, publicKey: Data) -> MullvadRpc.Request<WireguardAssociatedAddresses> { let request = JsonRpcRequest(method: "push_wg_key", params: [ AnyEncodable(accountToken), AnyEncodable(publicKey) ]) - return makeDataTaskPublisher(request: request) + return MullvadRpc.Request(session: session, request: request) } - func replaceWireguardKey(accountToken: String, oldPublicKey: Data, newPublicKey: Data) -> AnyPublisher<WireguardAssociatedAddresses, MullvadRpc.Error> { + func replaceWireguardKey(accountToken: String, oldPublicKey: Data, newPublicKey: Data) -> MullvadRpc.Request<WireguardAssociatedAddresses> { let request = JsonRpcRequest(method: "replace_wg_key", params: [ AnyEncodable(accountToken), AnyEncodable(oldPublicKey), AnyEncodable(newPublicKey) ]) - return makeDataTaskPublisher(request: request) + return MullvadRpc.Request(session: session, request: request) } - func checkWireguardKey(accountToken: String, publicKey: Data) -> AnyPublisher<Bool, MullvadRpc.Error> { + func checkWireguardKey(accountToken: String, publicKey: Data) -> MullvadRpc.Request<Bool> { let request = JsonRpcRequest(method: "check_wg_key", params: [ AnyEncodable(accountToken), AnyEncodable(publicKey) ]) - return makeDataTaskPublisher(request: request) + return MullvadRpc.Request(session: session, request: request) } - func removeWireguardKey(accountToken: String, publicKey: Data) -> AnyPublisher<Bool, MullvadRpc.Error> { + func checkWireguardKey(request: MullvadRpc.Request<Bool>? = nil) -> MullvadRpc.Operation<Bool> { + return MullvadRpc.Operation(request: request) + } + + func removeWireguardKey(accountToken: String, publicKey: Data) -> MullvadRpc.Request<Bool> { let request = JsonRpcRequest(method: "remove_wg_key", params: [ AnyEncodable(accountToken), AnyEncodable(publicKey) ]) - return makeDataTaskPublisher(request: request) + return MullvadRpc.Request(session: session, request: request) } - func sendAppStoreReceipt(accountToken: String, receiptData: Data) -> AnyPublisher<SendAppStoreReceiptResponse, MullvadRpc.Error> { + func sendAppStoreReceipt(accountToken: String, receiptData: Data) -> MullvadRpc.Request<SendAppStoreReceiptResponse> { let request = JsonRpcRequest(method: "apple_payment", params: [ AnyEncodable(accountToken), AnyEncodable(receiptData) ]) - return makeDataTaskPublisher(request: request) - } - - private func makeDataTaskPublisher<T: Decodable>(request: JsonRpcRequest) -> AnyPublisher<T, MullvadRpc.Error> { - return Just(request) - .encode(encoder: Self.makeJSONEncoder()) - .mapError { MullvadRpc.Error.encoding($0) } - .map { Self.makeURLRequest(httpBody: $0) } - .flatMap { - self.session.dataTaskPublisher(for: $0) - .mapError { MullvadRpc.Error.network($0) } - .flatMap { (data, httpResponse) in - Just(data) - .decode(type: JsonRpcResponse<T, ResponseCode>.self, decoder: Self.makeJSONDecoder()) - .mapError { MullvadRpc.Error.decoding($0) } - .flatMap { (serverResponse) in - // unwrap JsonRpcResponse.result - serverResponse.result - .mapError { MullvadRpc.Error.server($0) } - .publisher - } - } - }.eraseToAnyPublisher() - } - - private static func makeURLRequest(httpBody: Data) -> URLRequest { - var request = URLRequest(url: kMullvadAPIURL, cachePolicy: .useProtocolCachePolicy, timeoutInterval: kNetworkTimeout) - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpMethod = "POST" - request.httpBody = httpBody - - return request - } - - private static func makeJSONEncoder() -> JSONEncoder { - let encoder = JSONEncoder() - encoder.keyEncodingStrategy = .convertToSnakeCase - encoder.dateEncodingStrategy = .iso8601 - encoder.dataEncodingStrategy = .base64 - return encoder - } - - private static func makeJSONDecoder() -> JSONDecoder { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .iso8601 - decoder.dataDecodingStrategy = .base64 - return decoder + return MullvadRpc.Request(session: session, request: request) } } @@ -252,3 +228,120 @@ extension JsonRpcResponseError: LocalizedError } } } + + +extension MullvadRpc { + + class Request<Response: Decodable> { + typealias RequestCompletionHandler = (Result<Response, MullvadRpc.Error>) -> Void + + private let session: URLSession + private let request: JsonRpcRequest + + private let lock = NSLock() + private var urlSessionTask: URLSessionTask? + + fileprivate init(session: URLSession, request: JsonRpcRequest) { + self.session = session + self.request = request + } + + func start(completionHandler: @escaping RequestCompletionHandler) { + lock.withCriticalBlock { + assert(self.urlSessionTask == nil) + + switch makeURLRequest() { + case .success(let urlRequest): + let task = session.dataTask(with: urlRequest) { (responseData, urlResponse, error) in + switch (responseData, error) { + case (.some(let data), .none): + completionHandler(Self.decodeResponse(data)) + + case (.none, .some(let urlError as URLError)): + completionHandler(.failure(.network(urlError))) + + default: + fatalError() + } + } + self.urlSessionTask = task + task.resume() + + case .failure(let error): + completionHandler(.failure(error)) + } + } + } + + func cancel() { + lock.withCriticalBlock { + self.urlSessionTask?.cancel() + } + } + + func operation() -> MullvadRpc.Operation<Response> { + return MullvadRpc.Operation(request: self) + } + + private func makeURLRequest() -> Result<URLRequest, MullvadRpc.Error> { + do { + let data = try MullvadRpc.makeJSONEncoder().encode(request) + + return .success(Self.makeURLRequest(httpBody: data)) + } catch { + return .failure(.encoding(error)) + } + } + + private static func decodeResponse(_ responseData: Data) -> Result<Response, MullvadRpc.Error> { + do { + let serverResponse = try MullvadRpc.makeJSONDecoder() + .decode(JsonRpcResponse<Response, MullvadRpc.ResponseCode>.self, from: responseData) + + // unwrap JsonRpcResponse.result + return serverResponse.result + .mapError { .server($0) } + } catch { + return .failure(.decoding(error)) + } + } + + private static func makeURLRequest(httpBody: Data) -> URLRequest { + var request = URLRequest( + url: kMullvadAPIURL, + cachePolicy: .useProtocolCachePolicy, + timeoutInterval: kNetworkTimeout + ) + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpMethod = "POST" + request.httpBody = httpBody + + return request + } + } + + class Operation<Response>: AsyncOperation, InputOperation, OutputOperation where Response: Decodable { + typealias Input = Request<Response> + typealias Output = Result<Response, MullvadRpc.Error> + + init(request: Input? = nil) { + super.init() + self.input = request + } + + override func main() { + guard let request = self.input else { + self.finish() + return + } + + request.start { [weak self] (result) in + self?.finish(with: result) + } + } + + override func operationDidCancel() { + input?.cancel() + } + } +} diff --git a/ios/MullvadVPN/ObserverList.swift b/ios/MullvadVPN/ObserverList.swift new file mode 100644 index 0000000000..efc7fa3f1c --- /dev/null +++ b/ios/MullvadVPN/ObserverList.swift @@ -0,0 +1,53 @@ +// +// ObserverList.swift +// MullvadVPN +// +// Created by pronebird on 26/06/2020. +// Copyright © 2020 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +protocol WeakObserverBox: Equatable { + associatedtype Wrapped + + var inner: Wrapped? { get } +} + +class ObserverList<T: WeakObserverBox> { + private let lock = NSRecursiveLock() + private var observers = [T]() + + func append(_ observer: T) { + lock.withCriticalBlock { + if !self.observers.contains(observer) { + self.observers.append(observer) + } + } + } + + func remove(_ observer: T) { + lock.withCriticalBlock { + self.observers.removeAll { $0 == observer } + } + } + + func forEach(_ body: (T) -> Void) { + lock.withCriticalBlock { + var discardObservers = [T]() + self.observers.forEach { (boxedObserver) in + body(boxedObserver) + + if boxedObserver.inner == nil { + discardObservers.append(boxedObserver) + } + } + + if !discardObservers.isEmpty { + self.observers.removeAll { (observer) -> Bool in + return discardObservers.contains(observer) + } + } + } + } +} diff --git a/ios/MullvadVPN/RelayCache.swift b/ios/MullvadVPN/RelayCache.swift index c1e3e79110..7444078e29 100644 --- a/ios/MullvadVPN/RelayCache.swift +++ b/ios/MullvadVPN/RelayCache.swift @@ -7,128 +7,261 @@ // import Foundation -import Combine import os +/// Periodic update interval +private let kUpdateIntervalSeconds = 3600 + /// Error emitted by read and write functions -enum RelayCacheError: Error { - case defaultLocationNotFound - case io(Error) - case coding(Error) +enum RelayCacheError: ChainedError { + case readCache(Error) + case readPrebundledRelays(Error) + case decodePrebundledRelays(Error) + case writeCache(Error) + case encodeCache(Error) + case decodeCache(Error) case rpc(MullvadRpc.Error) + + var errorDescription: String? { + switch self { + case .encodeCache: + return "Encode cache error" + case .decodeCache: + return "Decode cache error" + case .readCache: + return "Read cache error" + case .readPrebundledRelays: + return "Read pre-bundled relays error" + case .decodePrebundledRelays: + return "Decode pre-bundled relays error" + case .writeCache: + return "Write cache error" + case .rpc: + return "RPC error" + } + } +} + +protocol RelayCacheObserver: class { + func relayCache(_ relayCache: RelayCache, didUpdateCachedRelayList cachedRelayList: CachedRelayList) } -/// A enum describing the source of the relay list -enum RelayListSource { - /// The relay list was received from network - case network +private class AnyRelayCacheObserver: WeakObserverBox, RelayCacheObserver { + + typealias Wrapped = RelayCacheObserver + + private(set) weak var inner: RelayCacheObserver? + + init<T: RelayCacheObserver>(_ inner: T) { + self.inner = inner + } + + func relayCache(_ relayCache: RelayCache, didUpdateCachedRelayList cachedRelayList: CachedRelayList) { + inner?.relayCache(relayCache, didUpdateCachedRelayList: cachedRelayList) + } - /// The relay list was read from cache - case cache + static func == (lhs: AnyRelayCacheObserver, rhs: AnyRelayCacheObserver) -> Bool { + return lhs.inner === rhs.inner + } } class RelayCache { - /// Mullvad Rpc client private let rpc: MullvadRpc /// The cache location used by the class instance private let cacheFileURL: URL - /// A queue used for running cache requests that require mutual exclusivity - private let exclusivityQueue = DispatchQueue(label: "net.mullvad.vpn.relay-cache.exclusivity-queue") + /// A dispatch queue used for thread synchronization + private let dispatchQueue = DispatchQueue(label: "net.mullvad.MullvadVPN.RelayCache") + + /// A timer source used for periodic updates + private var timerSource: DispatchSourceTimer? - /// A queue used for execution - private let executionQueue = DispatchQueue(label: "net.mullvad.vpn.relay-cache.execution-queue") + /// A flag that indicates whether periodic updates are running + private var isPeriodicUpdatesEnabled = false + + /// A download task used for relay RPC request + private var downloadRequest: MullvadRpc.Request<RelayList>? /// The default cache file location - static var defaultCacheFileURL: URL? { + static var defaultCacheFileURL: URL { let appGroupIdentifier = ApplicationConfiguration.securityGroupIdentifier - let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) + let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)! + + return containerURL.appendingPathComponent("relays.json") + } - return containerURL.flatMap { URL(fileURLWithPath: "relays.json", relativeTo: $0) } + /// The path to the pre-bundled relays.json file + private static var preBundledRelaysFileURL: URL { + return Bundle.main.url(forResource: "relays", withExtension: "json")! } - init(cacheFileURL: URL, networkSession: URLSession) { + /// Observers + private let observerList = ObserverList<AnyRelayCacheObserver>() + + /// A shared instance of `RelayCache` + static let shared = RelayCache(cacheFileURL: defaultCacheFileURL, networkSession: URLSession(configuration: .ephemeral)) + + private init(cacheFileURL: URL, networkSession: URLSession) { rpc = MullvadRpc(session: networkSession) self.cacheFileURL = cacheFileURL } - class func withDefaultLocation(networkSession: URLSession) -> Result<RelayCache, RelayCacheError> { - if let cacheFileURL = defaultCacheFileURL { - return .success(RelayCache(cacheFileURL: cacheFileURL, networkSession: networkSession)) - } else { - return .failure(.defaultLocationNotFound) + func startPeriodicUpdates(completionHandler: (() -> Void)?) { + dispatchQueue.async { + guard !self.isPeriodicUpdatesEnabled else { + completionHandler?() + return + } + + self.isPeriodicUpdatesEnabled = true + + switch Self.read(cacheFileURL: self.cacheFileURL) { + case .success(let cachedRelayList): + if let nextUpdate = Self.nextUpdateDate(lastUpdatedAt: cachedRelayList.updatedAt) { + let startTime = Self.makeWalltime(fromDate: nextUpdate) + self.scheduleRepeatingTimer(startTime: startTime) + } + + case .failure(let readError): + readError.logChain(message: "Failed to read the relay cache") + + if Self.shouldDownloadRelaysOnReadFailure(readError) { + self.scheduleRepeatingTimer(startTime: .now()) + } + } + + completionHandler?() } } + func stopPeriodicUpdates(completionHandler: (() -> Void)?) { + dispatchQueue.async { + self.isPeriodicUpdatesEnabled = false - class func withDefaultLocationAndEphemeralSession() -> Result<RelayCache, RelayCacheError> { - return withDefaultLocation(networkSession: URLSession(configuration: .ephemeral)) - } + self.timerSource?.cancel() + self.timerSource = nil + self.downloadRequest?.cancel() - /// Read the relay cache and update it from remote if needed. - func read() -> AnyPublisher<CachedRelayList, RelayCacheError> { - MutuallyExclusive(exclusivityQueue: exclusivityQueue, executionQueue: executionQueue) { - self.makeReaderPublisher() - }.eraseToAnyPublisher() + completionHandler?() + } } - private func makeReaderPublisher() -> AnyPublisher<CachedRelayList, RelayCacheError> { - // Create a deferred publisher that will execute once the subscriber is assigned - let downloadAndSaveRelaysPublisher = Deferred { - return self.downloadRelays() - .map(self.filterRelayList) - .flatMap(self.saveRelayListToCache) - .mapError { (error) -> RelayCacheError in - os_log(.error, "Failed to update the relay cache: %{public}s", error.localizedDescription) + func updateRelays() { + dispatchQueue.async { + self._updateRelays() + } + } - return error + /// Read the relay cache from disk + func read(completionHandler: @escaping (Result<CachedRelayList, RelayCacheError>) -> Void) { + dispatchQueue.async { + let result = Self.read(cacheFileURL: self.cacheFileURL) + .flatMapError { (error) -> Result<CachedRelayList, RelayCacheError> in + if case .readCache(let ioError as CocoaError) = error, ioError.code == .fileReadNoSuchFile { + return Self.readPrebundledRelays(fileURL: Self.preBundledRelaysFileURL) + } else { + return .failure(error) + } } + completionHandler(result) } + } + + // MARK: - Observation + + func addObserver<T: RelayCacheObserver>(_ observer: T) { + observerList.append(AnyRelayCacheObserver(observer)) + } - return Self.read(cacheFileURL: cacheFileURL).publisher - .map { (RelayListSource.cache, $0) } - .catch({ (readError) -> AnyPublisher<(RelayListSource, CachedRelayList), RelayCacheError> in - switch readError { - // Download relay list when unable to read the cache file - case .io(let error as CocoaError) where error.code == .fileReadNoSuchFile: - os_log(.error, "Relay cache file does not exist. Initiating the download.") + func removeObserver<T: RelayCacheObserver>(_ observer: T) { + observerList.remove(AnyRelayCacheObserver(observer)) + } - return downloadAndSaveRelaysPublisher.map { (RelayListSource.network, $0) } - .eraseToAnyPublisher() + // MARK: - Private instance methods - case .coding(let decodingError): - os_log(.error, "Failed to decode the relay cache: %{public}s", decodingError.localizedDescription) + private func _updateRelays() { + switch Self.read(cacheFileURL: self.cacheFileURL) { + case .success(let cachedRelays): + let nextUpdate = Self.nextUpdateDate(lastUpdatedAt: cachedRelays.updatedAt) - return downloadAndSaveRelaysPublisher.map { (RelayListSource.network, $0) } - .eraseToAnyPublisher() + if let nextUpdate = nextUpdate, nextUpdate <= Date() { + self.downloadRelays() + } - default: - os_log(.error, "Failed to read the relay cache: %{public}s", readError.localizedDescription) + case .failure(let readError): + readError.logChain(message: "Failed to read the relay cache") - return Fail(error: readError).eraseToAnyPublisher() - } - }) - .flatMap { (source, cachedRelays) -> AnyPublisher<CachedRelayList, RelayCacheError> in - let cachedRelayPublisher = Result<CachedRelayList, RelayCacheError>.Publisher(cachedRelays) + if Self.shouldDownloadRelaysOnReadFailure(readError) { + self.downloadRelays() + } + } + } + + private func downloadRelays() { + let newDownloadRequest = startDownloadTask { (result) in + let result = result.flatMap { (relayList) -> Result<CachedRelayList, RelayCacheError> in + let cachedRelayList = CachedRelayList(relayList: relayList, updatedAt: Date()) + + return Self.write(cacheFileURL: self.cacheFileURL, record: cachedRelayList) + .map { cachedRelayList } + } - if source == .cache && cachedRelays.needsUpdate() { - return downloadAndSaveRelaysPublisher - .catch { (error) -> Result<CachedRelayList, RelayCacheError>.Publisher in - // Return the on-disk cache in the event of networking error - return cachedRelayPublisher - }.eraseToAnyPublisher() - } else { - return cachedRelayPublisher - .eraseToAnyPublisher() + switch result { + case .success(let cachedRelayList): + os_log(.default, "Downloaded %d relays", cachedRelayList.relayList.numRelays) + + self.observerList.forEach { (observer) in + observer.relayCache(self, didUpdateCachedRelayList: cachedRelayList) } - }.eraseToAnyPublisher() + + case .failure(let error): + error.logChain(message: "Failed to update the relays") + } + } + + downloadRequest?.cancel() + downloadRequest = newDownloadRequest + } + + private func scheduleRepeatingTimer(startTime: DispatchWallTime) { + let timerSource = DispatchSource.makeTimerSource(queue: dispatchQueue) + timerSource.setEventHandler { [weak self] in + guard let self = self else { return } + + if self.isPeriodicUpdatesEnabled { + self._updateRelays() + } + } + + timerSource.schedule(wallDeadline: startTime, repeating: .seconds(kUpdateIntervalSeconds)) + timerSource.activate() + + self.timerSource = timerSource } + private func startDownloadTask(completionHandler: @escaping (Result<RelayList, RelayCacheError>) -> Void) -> MullvadRpc.Request<RelayList>? { + let request = rpc.getRelayList() + + request.start { (result) in + self.dispatchQueue.async { + let result = result + .map(Self.filterRelayList) + .mapError { RelayCacheError.rpc($0) } + + completionHandler(result) + } + } + + return request + } + + // MARK: - Private class methods + /// Filters the given `RelayList` removing empty leaf nodes, relays without Wireguard tunnels or /// Wireguard tunnels without any available ports. - private func filterRelayList(_ relayList: RelayList) -> RelayList { + private class func filterRelayList(_ relayList: RelayList) -> RelayList { let filteredCountries = relayList.countries .map { (country) -> RelayList.Country in var filteredCountry = country @@ -155,23 +288,6 @@ class RelayCache { return RelayList(countries: filteredCountries) } - - private func downloadRelays() -> AnyPublisher<RelayList, RelayCacheError> { - rpc.getRelayList() - .mapError { .rpc($0) } - .eraseToAnyPublisher() - } - - private func saveRelayListToCache(relayList: RelayList) -> AnyPublisher<CachedRelayList, RelayCacheError> { - Result.Publisher(relayList) - .map({ CachedRelayList(relayList: $0, updatedAt: Date()) }) - .flatMap({ (cachedRelayList) in - return Self.write(cacheFileURL: self.cacheFileURL, record: cachedRelayList) - .map { cachedRelayList } - .publisher - }).eraseToAnyPublisher() - } - /// Safely read the cache file from disk using file coordinator private class func read(cacheFileURL: URL) -> Result<CachedRelayList, RelayCacheError> { var result: Result<CachedRelayList, RelayCacheError>? @@ -180,10 +296,10 @@ class RelayCache { let accessor = { (fileURLForReading: URL) -> Void in // Decode data from disk result = Result { try Data(contentsOf: fileURLForReading) } - .mapError { RelayCacheError.io($0) } + .mapError { RelayCacheError.readCache($0) } .flatMap { (data) in Result { try JSONDecoder().decode(CachedRelayList.self, from: data) } - .mapError { RelayCacheError.coding($0) } + .mapError { RelayCacheError.decodeCache($0) } } } @@ -194,12 +310,27 @@ class RelayCache { byAccessor: accessor) if let error = error { - result = .failure(.io(error)) + result = .failure(.readCache(error)) } return result! } + private class func readPrebundledRelays(fileURL: URL) -> Result<CachedRelayList, RelayCacheError> { + return Result { try Data(contentsOf: fileURL) } + .mapError { RelayCacheError.readPrebundledRelays($0) } + .flatMap { (data) -> Result<CachedRelayList, RelayCacheError> in + return Result { try MullvadRpc.makeJSONDecoder().decode(RelayList.self, from: data) } + .mapError { RelayCacheError.decodePrebundledRelays($0) } + .map { (relayList) -> CachedRelayList in + return CachedRelayList( + relayList: Self.filterRelayList(relayList), + updatedAt: Date(timeIntervalSince1970: 0) + ) + } + } + } + /// Safely write the cache file on disk using file coordinator private class func write(cacheFileURL: URL, record: CachedRelayList) -> Result<(), RelayCacheError> { var result: Result<(), RelayCacheError>? @@ -207,10 +338,10 @@ class RelayCache { let accessor = { (fileURLForWriting: URL) -> Void in result = Result { try JSONEncoder().encode(record) } - .mapError { RelayCacheError.coding($0) } + .mapError { RelayCacheError.encodeCache($0) } .flatMap { (data) in Result { try data.write(to: fileURLForWriting) } - .mapError { RelayCacheError.io($0) } + .mapError { RelayCacheError.writeCache($0) } } } @@ -221,11 +352,41 @@ class RelayCache { byAccessor: accessor) if let error = error { - result = .failure(.io(error)) + result = .failure(.writeCache(error)) } return result! } + + private class func makeWalltime(fromDate date: Date) -> DispatchWallTime { + let (seconds, frac) = modf(date.timeIntervalSince1970) + + let nsec: Double = frac * Double(NSEC_PER_SEC) + let walltime = timespec(tv_sec: Int(seconds), tv_nsec: Int(nsec)) + + return DispatchWallTime(timespec: walltime) + } + + private class func nextUpdateDate(lastUpdatedAt: Date) -> Date? { + return Calendar.current.date( + byAdding: .second, + value: kUpdateIntervalSeconds, + to: lastUpdatedAt + ) + } + + private class func shouldDownloadRelaysOnReadFailure(_ error: RelayCacheError) -> Bool { + switch error { + case .readPrebundledRelays, .decodePrebundledRelays, .decodeCache: + return true + + case .readCache(let error as CocoaError) where error.code == .fileReadNoSuchFile: + return true + + default: + return false + } + } } /// A struct that represents the relay cache on disk @@ -236,14 +397,3 @@ struct CachedRelayList: Codable { /// The date when this cache was last updated var updatedAt: Date } - -private extension CachedRelayList { - /// Returns true if it's time to refresh the relay list cache - func needsUpdate() -> Bool { - let now = Date() - guard let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: updatedAt) else { - return false - } - return now >= nextUpdate - } -} diff --git a/ios/MullvadVPN/RelayList.swift b/ios/MullvadVPN/RelayList.swift index dbebfb6304..cf9fe7edd7 100644 --- a/ios/MullvadVPN/RelayList.swift +++ b/ios/MullvadVPN/RelayList.swift @@ -50,6 +50,15 @@ struct RelayList: Codable { extension RelayList { + /// Returns the total number of relays + var numRelays: Int { + return countries.reduce(0) { (accum, country) -> Int in + return country.cities.reduce(accum, { (accum, city) -> Int in + return accum + city.relays.count + }) + } + } + /// Returns an alphabetically sorted `RelayList` func sorted() -> Self { let lexicalComparator = { (a: String, b: String) -> Bool in diff --git a/ios/update-relays.sh b/ios/update-relays.sh new file mode 100755 index 0000000000..3935abe583 --- /dev/null +++ b/ios/update-relays.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +if [ -z "$PROJECT_DIR" ]; then + echo "This script is intended to be executed by Xcode" + exit 1 +fi + +RELAYS_FILE="$PROJECT_DIR/Assets/relays.json" + +if [ $CONFIGURATION == "Release" ]; then + echo "Remove relays file" + rm "$RELAYS_FILE" || true +fi + +if [ ! -f "$RELAYS_FILE" ]; then + echo "Download relays file" + curl https://api.mullvad.net/rpc/ \ + -d '{"jsonrpc": "2.0", "id": "0", "method": "relay_list_v3"}' \ + --header "Content-Type: application/json" | jq -c .result > "$RELAYS_FILE" +fi |
