summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--ios/.gitignore3
-rw-r--r--ios/Assets/.gitkeep0
-rw-r--r--ios/BuildInstructions.md9
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj21
-rw-r--r--ios/MullvadVPN.xcodeproj/xcshareddata/xcschemes/MullvadVPN.xcscheme20
-rw-r--r--ios/MullvadVPN/ObserverList.swift53
-rw-r--r--ios/MullvadVPN/RelayCache.swift364
-rw-r--r--ios/MullvadVPN/RelayList.swift9
-rwxr-xr-xios/update-relays.sh20
9 files changed, 390 insertions, 109 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 &gt; $PROJECT_DIR/prebuild.log 2&gt;&amp;1&#10;&#10;$PROJECT_DIR/update-relays.sh&#10;">
+ <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/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