summaryrefslogtreecommitdiffhomepage
path: root/ios/MullvadREST/ApiHandlers/AddressCache.swift
blob: b75a515c011e7ba0e03b1830d2009a9b1b526f89 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
//
//  AddressCache.swift
//  MullvadREST
//
//  Created by pronebird on 08/12/2021.
//  Copyright © 2025 Mullvad VPN AB. All rights reserved.
//

import Foundation
import MullvadLogging
import MullvadTypes

extension REST {
    public final class AddressCache: AddressCacheProviding, @unchecked Sendable {
        /// Logger.
        private let logger = Logger(label: "AddressCache")

        /// Disk cache.
        private let fileCache: any FileCacheProtocol<StoredAddressCache>

        /// Memory cache.
        private var cache: StoredAddressCache = defaultStoredCache

        /// Lock used for synchronizing access to instance members.
        private let cacheLock = NSLock()

        /// Whether address cache can be written to.
        private let canWriteToCache: Bool

        /// The default endpoint to use as a fallback mechanism.
        private static let defaultStoredCache = StoredAddressCache(
            updatedAt: Date(timeIntervalSince1970: 0),
            endpoint: REST.defaultAPIEndpoint
        )

        // MARK: - Public API

        /// Designated initializer.
        public init(canWriteToCache: Bool, cacheDirectory: URL) {
            fileCache = FileCache(
                fileURL: cacheDirectory.appendingPathComponent("api-ip-address.json", isDirectory: false)
            )
            self.canWriteToCache = canWriteToCache
        }

        /// Initializer that accepts a file cache implementation and can be used in tests.
        init(canWriteToCache: Bool, fileCache: some FileCacheProtocol<StoredAddressCache>) {
            self.fileCache = fileCache
            self.canWriteToCache = canWriteToCache
        }

        /// Returns the latest available endpoint
        ///
        /// When running from the Network Extension, this method will read from the cache before returning.
        /// - Returns: The latest available endpoint, or a default endpoint if no endpoints are available
        public func getCurrentEndpoint() -> AnyIPEndpoint {
            cacheLock.withLock {
                // Reload from disk cache when in the Network Extension as there is no `AddressCacheTracker` running
                // there
                if canWriteToCache == false {
                    do {
                        cache = try fileCache.read()
                    } catch {
                        logger.error(error: error, message: "Failed to read address cache from disk.")
                    }
                }

                return cache.endpoint
            }
        }

        /// Updates the available endpoints to use
        ///
        /// Only the first available endpoint is kept, the rest are discarded.
        /// This method will only modify the on disk cache when running from the UI process.
        /// - Parameter endpoints: The new endpoints to use for API requests
        public func setEndpoints(_ endpoints: [AnyIPEndpoint]) {
            cacheLock.withLock {
                guard let firstEndpoint = endpoints.first else { return }

                cache = StoredAddressCache(updatedAt: Date(), endpoint: firstEndpoint)

                guard canWriteToCache else { return }
                do {
                    try fileCache.write(cache)
                } catch {
                    logger.error(
                        error: error,
                        message: "Failed to write address cache after setting new endpoints."
                    )
                }
            }
        }

        /// The `Date` when the cache was last updated at
        ///
        /// - Returns: The `Date` when the cache was last updated at
        public func getLastUpdateDate() -> Date {
            return cacheLock.withLock { cache.updatedAt }
        }

        /// Initializes cache by reading it from file on disk.
        ///
        /// If no cache file is present, a default API endpoint will be selected instead.
        public func loadFromFile() {
            cacheLock.withLock {
                // The first time the application is ran, this statement will fail as there is no cache. This is fine.
                // The cache will be filled when either `getCurrentEndpoint()` or `setEndpoints()` are called.
                do {
                    cache = try fileCache.read()
                } catch {
                    // Log all errors except when file is not on disk.
                    if (error as? CocoaError)?.code != .fileReadNoSuchFile {
                        logger.error(error: error, message: "Failed to load address cache from disk.")
                    }

                    logger.debug("Initialized cache with default API endpoint.")
                    cache = Self.defaultStoredCache
                }
            }
        }
    }

    /// Serializable address cache representation stored on disk.
    struct StoredAddressCache: Codable, Equatable {
        /// Date when the cached addresses were last updated.
        var updatedAt: Date

        /// API endpoint.
        var endpoint: AnyIPEndpoint
    }
}