// // AccessMethodRepository.swift // MullvadVPN // // Created by Jon Petersson on 12/12/2023. // Copyright © 2025 Mullvad VPN AB. All rights reserved. // import Combine import Foundation import MullvadLogging import MullvadTypes public class AccessMethodRepository: AccessMethodRepositoryProtocol, @unchecked Sendable { public static let directId = UUID(uuidString: "C9DB7457-2A55-42C3-A926-C07F82131994")! public static let bridgeId = UUID(uuidString: "8586E75A-CA7B-4432-B70D-EE65F3F95084")! public static let encryptedDNSId = UUID(uuidString: "831CB1F8-1829-42DD-B9DC-82902F298EC0")! private let logger = Logger(label: "AccessMethodRepository") // The access method names will be localised on creation time. As they are persisted // to on-device storage, they will not be relocalised if the user changes language. private let direct = PersistentAccessMethod( id: AccessMethodRepository.directId, name: "Direct", isEnabled: true, proxyConfiguration: .direct ) private let bridge = PersistentAccessMethod( id: AccessMethodRepository.bridgeId, name: "Mullvad bridges", isEnabled: true, proxyConfiguration: .bridges ) private let encryptedDNS = PersistentAccessMethod( id: AccessMethodRepository.encryptedDNSId, name: "Encrypted DNS proxy", isEnabled: true, proxyConfiguration: .encryptedDNS ) private let accessMethodsSubject: CurrentValueSubject<[PersistentAccessMethod], Never> public var accessMethodsPublisher: AnyPublisher<[PersistentAccessMethod], Never> { accessMethodsSubject.eraseToAnyPublisher() } private let requestAccessMethodSubject: PassthroughSubject public var requestAccessMethodPublisher: AnyPublisher { requestAccessMethodSubject.eraseToAnyPublisher() } private let currentAccessMethodSubject: CurrentValueSubject public var currentAccessMethodPublisher: AnyPublisher { currentAccessMethodSubject.eraseToAnyPublisher() } public var directAccess: PersistentAccessMethod { direct } private var cancellables: Set = [] public init() { accessMethodsSubject = CurrentValueSubject([]) requestAccessMethodSubject = PassthroughSubject() currentAccessMethodSubject = CurrentValueSubject(direct) addDefaultsMethods() let lastReachable = fetchLastReachable() accessMethodsSubject.send(fetchAll()) requestAccessMethodSubject.send(lastReachable) // Set the correct access method, as opposed to the default value "direct" above. currentAccessMethodSubject.value = lastReachable currentAccessMethodPublisher .removeDuplicates() .sink { [weak self] currentAccessMethod in self?.saveCurrentAccessMethod(currentAccessMethod) }.store(in: &cancellables) } public func save(_ method: PersistentAccessMethod, notifyingAPI: Bool = false) { var methodStore = readApiAccessMethodStore() var method = method method.name = method.name.trimmingCharacters(in: .whitespaces) if let index = methodStore.accessMethods.firstIndex(where: { $0.id == method.id }) { methodStore.accessMethods[index] = method } else { methodStore.accessMethods.append(method) } do { try writeApiAccessMethodStore(methodStore) if notifyingAPI { accessMethodsSubject.send(methodStore.accessMethods) } } catch { logger.error("Could not save access method: \(method) \nError: \(error)") } } public func requestAccessMethod(_ method: PersistentAccessMethod) { requestAccessMethodSubject.send(method) } private func saveCurrentAccessMethod(_ method: PersistentAccessMethod) { var methodStore = readApiAccessMethodStore() methodStore.lastReachableAccessMethod = method do { try writeApiAccessMethodStore(methodStore) } catch { logger.error("Could not save last reachable access method: \(method) \nError: \(error)") } } public func delete(id: UUID) { var methodStore = readApiAccessMethodStore() guard let index = methodStore.accessMethods.firstIndex(where: { $0.id == id }) else { return } // Prevent removing methods that have static UUIDs and are always present. let method = methodStore.accessMethods[index] if !method.kind.isPermanent { methodStore.accessMethods.remove(at: index) } do { try writeApiAccessMethodStore(methodStore) accessMethodsSubject.send(methodStore.accessMethods) } catch { logger.error("Could not delete access method with id: \(id) \nError: \(error)") } } public func fetch(by id: UUID) -> PersistentAccessMethod? { fetchAll().first { $0.id == id } } public func fetchAll() -> [PersistentAccessMethod] { readApiAccessMethodStore().accessMethods } public func fetchLastReachable() -> PersistentAccessMethod { readApiAccessMethodStore().lastReachableAccessMethod } public func addDefaultsMethods() { add([ direct, bridge, encryptedDNS, ]) } private func add(_ methods: [PersistentAccessMethod]) { var methodStore = readApiAccessMethodStore() methods.forEach { method in if !methodStore.accessMethods.contains(where: { $0.id == method.id }) { methodStore.accessMethods.append(method) } } do { try writeApiAccessMethodStore(methodStore) accessMethodsSubject.send(methods) } catch { logger.error("Could not update access methods: \(methods) \nError: \(error)") } } private func readApiAccessMethodStore() -> PersistentAccessMethodStore { let parser = makeParser() do { let data = try SettingsManager.store.read(key: .apiAccessMethods) return try parser.parseUnversionedPayload(as: PersistentAccessMethodStore.self, from: data) } catch { logger.error("Could not load access method store: \(error)") return PersistentAccessMethodStore(lastReachableAccessMethod: direct, accessMethods: []) } } private func writeApiAccessMethodStore(_ store: PersistentAccessMethodStore) throws { let parser = makeParser() let data = try parser.produceUnversionedPayload(store) try SettingsManager.store.write(data, for: .apiAccessMethods) } private func makeParser() -> SettingsParser { SettingsParser(decoder: JSONDecoder(), encoder: JSONEncoder()) } } extension AccessMethodRepository: MullvadAccessMethodChangeListening { public func accessMethodChangedTo(_ uuid: UUID) { guard let method = accessMethodsSubject.value.first(where: { $0.id == uuid }) else { logger.warning("Change reported to method with unknown ID: \(uuid)") return } Task { logger.debug("Mullvad API changed access method to \(method.name)") currentAccessMethodSubject.send(method) } } }