// // ProfileManager.swift // Passepartout // // Created by Davide De Rosa on 2/25/22. // Copyright (c) 2022 Davide De Rosa. All rights reserved. // // https://github.com/passepartoutvpn // // This file is part of Passepartout. // // Passepartout is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Passepartout is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Passepartout. If not, see . // import Foundation import Combine import TunnelKitManager import PassepartoutCore import PassepartoutUtils import PassepartoutProviders @MainActor public final class ProfileManager: ObservableObject { public typealias ProfileEx = (profile: Profile, isReady: Bool) // MARK: Initialization private let store: KeyValueStore private let providerManager: ProviderManager let appGroup: String let keychainLabel: (String, VPNProtocolType) -> String let keychain: Keychain private let strategy: ProfileManagerStrategy // MARK: State @Published private var internalActiveProfileId: UUID? { willSet { pp_log.debug("Setting active profile: \(newValue?.uuidString ?? "nil")") } } @Published private var internalCurrentProfileId: UUID? { willSet { pp_log.debug("Setting current profile: \(newValue?.uuidString ?? "nil")") } } public var currentProfileId: UUID? { get { internalCurrentProfileId } set { guard let id = newValue else { internalCurrentProfileId = nil return } guard let profile = liveProfile(withId: id) else { return } internalCurrentProfileId = id setCurrentProfile(profile) } } public let currentProfile: ObservableProfile public let didUpdateProfiles = PassthroughSubject() public let didUpdateActiveProfile = PassthroughSubject() public let didCreateProfile = PassthroughSubject() private var cancellables: Set = [] public init( store: KeyValueStore, providerManager: ProviderManager, appGroup: String, keychainLabel: @escaping (String, VPNProtocolType) -> String, strategy: ProfileManagerStrategy ) { guard let _ = UserDefaults(suiteName: appGroup) else { fatalError("No entitlements for group '\(appGroup)'") } self.store = store self.providerManager = providerManager self.appGroup = appGroup self.keychainLabel = keychainLabel keychain = Keychain(group: appGroup) self.strategy = strategy currentProfile = ObservableProfile() } } // MARK: Index extension ProfileManager { private var allProfiles: [UUID: Profile] { strategy.allProfiles } public var profiles: [Profile] { strategy.profiles() } public var headers: [Profile.Header] { Array(allProfiles.values.map(\.header)) } public func isExistingProfile(withId id: UUID) -> Bool { allProfiles[id] != nil } public func isExistingProfile(withName name: String) -> Bool { allProfiles.contains { $0.value.header.name == name } } } // MARK: Profiles extension ProfileManager { public func liveProfileEx(withId id: UUID) throws -> ProfileEx { guard let profile = liveProfile(withId: id) else { pp_log.error("Profile not found: \(id)") throw PassepartoutError.missingProfile } pp_log.info("Found profile: \(profile.logDescription)") return (profile, isProfileReady(profile)) } private func liveProfile(withId id: UUID) -> Profile? { pp_log.debug("Searching profile \(id)") // IMPORTANT: fetch live copy first (see intents) if isCurrentProfile(id) { pp_log.debug("Profile \(currentProfile.value.logDescription) found in memory (current profile)") return currentProfile.value } guard let profile = strategy.profile(withId: id) else { assertionFailure("Profile in headers yet not found in persistent store") return nil } guard !profile.vpnProtocols.isEmpty else { assertionFailure("Ditching profile, no OpenVPN/WireGuard settings found") return nil } pp_log.debug("Profile \(profile.logDescription) found") keychain.debugAllPasswords(matching: id, context: appGroup) return profile } public func saveProfile(_ profile: Profile, isActive: Bool?, updateIfCurrent: Bool = true) { guard !profile.isPlaceholder else { assertionFailure("Placeholder") return } pp_log.info("Writing profile \(profile.logDescription) to persistent store") strategy.saveProfile(profile) if let isActive = isActive { if isActive { pp_log.info("\tActivating profile...") activeProfileId = profile.id } else if activeProfileId == profile.id { pp_log.info("\tDeactivating profile...") activeProfileId = nil } } else if allProfiles.isEmpty { pp_log.info("\tActivating first profile...") activeProfileId = profile.id } // IMPORTANT: refresh live copy if just saved (e.g. via intents) if updateIfCurrent && isCurrentProfile(profile.id) { pp_log.info("Saved profile is also current profile, updating...") currentProfile.value = profile } } public func removeProfiles(withIds ids: [UUID]) { pp_log.info("Deleting profiles with ids \(ids)") pp_log.info("\tDeleting passwords from keychain...") for id in ids { keychain.removeAllPasswords(matching: id, context: appGroup) } pp_log.info("\tDeleting from persistent store...") strategy.removeProfiles(withIds: ids) } @available(*, deprecated, message: "only use for testing") public func removeAllProfiles() { let ids = Array(allProfiles.keys) removeProfiles(withIds: ids) } public func duplicateProfile(withId id: UUID, setAsCurrent: Bool) { guard let source = liveProfile(withId: id) else { return } let copy = source .withNewId() .renamedUniquely(withLastUpdate: false) if setAsCurrent { // iOS 14 goes crazy when changing binding of a presented NavigationLink if #available(iOS 15, *) { internalCurrentProfileId = copy.id } // autosaves copy if non-existing in persistent store setCurrentProfile(copy) } else { strategy.saveProfile(copy) } } public func persist() { pp_log.info("Persisting pending profiles") if !currentProfile.value.isPlaceholder { saveProfile(currentProfile.value, isActive: nil, updateIfCurrent: false) } } } // MARK: Observation extension ProfileManager { private func setCurrentProfile(_ profile: Profile) { guard !currentProfile.isLoading else { pp_log.warning("Already loading another profile") return } guard profile.id != currentProfile.value.id else { pp_log.debug("Profile \(profile.logDescription) is already current profile") return } pp_log.info("Set current profile: \(profile.logDescription)") // // IMPORTANT: this method is called on app launch if there is an active profile, which // means that carelessly calling .saveProfiles() may trigger an unnecessary // willUpdateProfiles() and a potential animation in subscribers (e.g. OrganizerView) // // current profile, when set on launch, is always existing, so we take care // checking that to avoid an undesired save // var profilesToSave: [Profile] = [] if isExistingProfile(withId: currentProfile.value.id) { pp_log.info("Defer saving of former current profile \(currentProfile.value.logDescription)") profilesToSave.append(currentProfile.value) } if !isExistingProfile(withId: profile.id) { pp_log.info("Defer saving of transient current profile \(profile.logDescription)") profilesToSave.append(profile) } defer { if !profilesToSave.isEmpty { strategy.saveProfiles(profilesToSave) } } if isProfileReady(profile) { currentProfile.value = profile } else { currentProfile.isLoading = true Task { try await makeProfileReady(profile) currentProfile.value = profile currentProfile.isLoading = false } } } } extension ProfileManager { public func observeUpdates() { $internalActiveProfileId .sink { self.didUpdateActiveProfile.send($0) }.store(in: &cancellables) strategy.willUpdateProfiles() .dropFirst() .sink { self.willUpdateProfiles($0) }.store(in: &cancellables) } private func willUpdateProfiles(_ newProfiles: [UUID: Profile]) { pp_log.debug("Profiles updated: \(newProfiles.values.map(\.header))") defer { objectWillChange.send() } // IMPORTANT: invalidate current profile if deleted if !currentProfile.value.isPlaceholder && !newProfiles.keys.contains(currentProfile.value.id) { pp_log.info("\tCurrent profile deleted, invalidating...") currentProfile.value = .placeholder } let newProfile = strategy.profile(withId: currentProfile.value.id) if let newProfile = newProfile, newProfile != currentProfile.value { pp_log.info("Current profile remotely updated") currentProfile.value = newProfile } if let activeProfileId = activeProfileId, !newProfiles.keys.contains(activeProfileId) { pp_log.info("\tActive profile was deleted") self.activeProfileId = nil } didUpdateProfiles.send() // IMPORTANT: defer task to avoid recursive saves (is non-main thread an issue?) // FIXME: Core Data, not sure about this workaround Task { fixDuplicateNames(in: newProfiles) } } private func fixDuplicateNames(in newProfiles: [UUID: Profile]) { var allNames = newProfiles.values.map(\.header.name) let distinctNames = Set(allNames) distinctNames.forEach { guard let i = allNames.firstIndex(of: $0) else { return } allNames.remove(at: i) } let duplicates = Set(allNames) guard !duplicates.isEmpty else { pp_log.debug("No duplicated profiles") return } pp_log.debug("Duplicated profile names: \(duplicates)") var renamedProfiles: [Profile] = [] duplicates.forEach { name in let headers = newProfiles.values .map(\.header) .filter { $0.name == name } guard headers.count > 1 else { assertionFailure("Name '\(name)' marked as duplicate but headers.count not > 1") return } // headers.removeFirst() headers.forEach { dupHeader in let uniqueHeader = dupHeader.renamedUniquely(withLastUpdate: true) pp_log.debug("Renaming duplicate profile \(dupHeader.logDescription) to \(uniqueHeader.logDescription)") guard var uniqueProfile = liveProfile(withId: uniqueHeader.id) else { pp_log.warning("Skipping profile \(dupHeader.logDescription) renaming, not found") return } uniqueProfile.header = uniqueHeader renamedProfiles.append(uniqueProfile) } } if !renamedProfiles.isEmpty { strategy.saveProfiles(renamedProfiles) pp_log.debug("Duplicates successfully renamed!") } } } // MARK: Readiness extension ProfileManager { private func isProfileReady(_ profile: Profile) -> Bool { isProfileProviderAvailable(profile) } public func makeProfileReady(_ profile: Profile) async throws { try await fetchProfileProviderIfMissing(profile) } private func isProfileProviderAvailable(_ profile: Profile) -> Bool { guard let providerName = profile.header.providerName else { return true // host } return providerManager.isAvailable(providerName, vpnProtocol: profile.currentVPNProtocol) } private func fetchProfileProviderIfMissing(_ profile: Profile) async throws { guard let providerName = profile.header.providerName else { return // host } if providerManager.isAvailable(providerName, vpnProtocol: profile.currentVPNProtocol) { return } do { pp_log.info("Importing missing provider \(providerName)...") try await providerManager.fetchProviderPublisher( withName: providerName, vpnProtocol: profile.currentVPNProtocol, priority: .remoteThenBundle ).async() pp_log.info("Finished!") } catch { pp_log.error("Unable to import missing provider: \(error)") throw PassepartoutError.missingProfile } } } // MARK: KeyValueStore extension ProfileManager { public private(set) var activeProfileId: UUID? { get { guard let idString: String = store.value(forLocation: StoreKey.activeProfileId) else { return nil } guard let id = UUID(uuidString: idString) else { pp_log.warning("Active profile id is malformed, ignoring") return nil } guard isExistingProfile(withId: id) else { pp_log.warning("Active profile \(id) does not exist, ignoring") return nil } return id } set { // trigger publisher internalActiveProfileId = newValue store.setValue(newValue?.uuidString, forLocation: StoreKey.activeProfileId) } } } private extension ProfileManager { private enum StoreKey: String, KeyStoreDomainLocation { case activeProfileId var domain: String { "Passepartout.ProfileManager" } } }