//
// ProfileManager.swift
// Passepartout
//
// Created by Davide De Rosa on 2/25/22.
// Copyright (c) 2024 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 Combine
import Foundation
import PassepartoutCore
import PassepartoutProviders
@MainActor
public final class ProfileManager: ObservableObject {
public typealias ProfileEx = (profile: Profile, isReady: Bool)
public typealias KeychainEntry = (Profile) -> String
public typealias KeychainLabel = (Profile) -> String
// MARK: Initialization
private let store: KeyValueStore
private let providerManager: ProviderManager
private var profileRepository: ProfileRepository
private var sharedProfileRepository: ProfileRepository?
private let keychain: SecretRepository
private let keychainEntry: (Profile) -> String
private let keychainLabel: (Profile) -> String
public var willSaveSharedProfile: (_ newProfile: Profile, _ existingProfile: Profile?) -> Profile
// 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()
@Published private var sharedProfileIds: Set {
didSet {
refreshSharedProfiles()
}
}
private var cancellables: Set = []
public init(
store: KeyValueStore,
providerManager: ProviderManager,
profileRepository: ProfileRepository,
sharedProfileRepository: ProfileRepository? = nil,
keychain: SecretRepository,
keychainEntry: @escaping KeychainEntry,
keychainLabel: @escaping KeychainLabel
) {
self.store = store
self.providerManager = providerManager
self.profileRepository = profileRepository
self.sharedProfileRepository = sharedProfileRepository
self.keychain = keychain
self.keychainEntry = keychainEntry
self.keychainLabel = keychainLabel
willSaveSharedProfile = { newProfile, _ in
newProfile
}
currentProfile = ObservableProfile()
if let sharedProfileRepository {
sharedProfileIds = Set(sharedProfileRepository.allProfiles().keys)
} else {
sharedProfileIds = []
}
}
}
// MARK: Index
extension ProfileManager {
public var allProfiles: [UUID: Profile] {
profileRepository.allProfiles()
}
public var profiles: [Profile] {
Array(allProfiles.values)
}
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 Passepartout.ProfileError.notFound(profileId: id)
}
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 = profileRepository.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)
return profile
}
public func activateProfile(_ profile: Profile, isActive: Bool) {
guard !profile.isPlaceholder else {
assertionFailure("Placeholder")
return
}
if isActive {
pp_log.info("\tActivating profile...")
activeProfileId = profile.id
} else if activeProfileId == profile.id {
pp_log.info("\tDeactivating profile...")
activeProfileId = nil
}
}
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")
saveProfilesAndLog([profile])
if let isActive {
activateProfile(profile, isActive: isActive)
} 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)
}
pp_log.info("\tDeleting from persistent store...")
profileRepository.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
internalCurrentProfileId = copy.id
// autosaves copy if non-existing in persistent store
setCurrentProfile(copy)
} else {
saveProfilesAndLog([copy])
}
}
public func persist() {
pp_log.info("Persisting pending profiles")
if !currentProfile.value.isPlaceholder {
saveProfile(currentProfile.value, isActive: nil, updateIfCurrent: false)
}
}
}
// MARK: Keychain
extension ProfileManager {
public func savePassword(forProfile profile: Profile, newPassword: String? = nil) {
guard !profile.isPlaceholder else {
assertionFailure("Placeholder")
return
}
let entry = keychainEntry(profile)
let password = newPassword ?? profile.account.password
guard !password.isEmpty else {
keychain.removePassword(
for: entry,
userDefined: profile.id.uuidString
)
return
}
do {
try keychain.set(
password: password,
for: entry,
userDefined: profile.id.uuidString,
label: keychainLabel(profile)
)
} catch {
pp_log.error("Unable to save password to keychain: \(error)")
}
}
public func passwordReference(forProfile profile: Profile) -> Data? {
guard !profile.isPlaceholder else {
assertionFailure("Placeholder")
return nil
}
let entry = keychainEntry(profile)
do {
return try keychain.passwordReference(
for: entry,
userDefined: profile.id.uuidString
)
} catch {
pp_log.debug("Unable to load password reference from keychain: \(error)")
return nil
}
}
}
// 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 {
saveProfilesAndLog(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 { [weak self] in
self?.didUpdateActiveProfile.send($0)
}
.store(in: &cancellables)
profileRepository.willUpdateProfiles()
.dropFirst()
.sink { [weak self] in
self?.willUpdateProfiles($0)
}
.store(in: &cancellables)
if let sharedProfileRepository {
// persist changes to shared profiles immediately
currentProfile.$value
.dropFirst()
.removeDuplicates()
.filter { [unowned self] in
sharedProfileIds.contains($0.id)
}
.map { [unowned self] in
let existingProfile = sharedProfileRepository.profile(withId: $0.id)
return willSaveSharedProfile($0, existingProfile)
}
.sink { newSharedProfile in
do {
try sharedProfileRepository.saveProfiles([newSharedProfile])
pp_log.info("Current profile persisted (shared): \(newSharedProfile.logDescription)")
} catch {
pp_log.error("Unable to persist current profile (shared): \(error)")
}
}
.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
}
if let newProfile = newProfiles[currentProfile.value.id], 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 {
saveProfilesAndLog(renamedProfiles)
pp_log.debug("Duplicates successfully renamed!")
}
}
}
// MARK: Persistence
extension ProfileManager {
private func saveProfilesAndLog(_ profiles: [Profile]) {
do {
try profileRepository.saveProfiles(profiles.map {
var copy = $0
copy.connectionExpirationDate = nil
return copy
})
} catch {
pp_log.error("Unable to save profile(s): \(error)")
}
}
public func isSharing(profile: Profile) -> Bool {
sharedProfileIds.contains(profile.id)
}
public func setSharing(_ isShared: Bool, profile: Profile) {
if isShared {
pp_log.debug("Adding shared profile: \(profile.id)")
sharedProfileIds.insert(profile.id)
} else {
pp_log.debug("Removing shared profile: \(profile.id))")
sharedProfileIds.remove(profile.id)
}
}
}
extension ProfileManager {
public func refreshSharedProfiles() {
guard let sharedProfileRepository else {
return
}
var toAdd: [Profile] = []
var toRemove: [UUID] = []
profiles.forEach {
if sharedProfileIds.contains($0.id) {
toAdd.append($0)
} else {
toRemove.append($0.id)
}
}
let existingProfiles = sharedProfileRepository
.allProfiles()
.filter {
sharedProfileIds.contains($0.key)
}
pp_log.debug("Refreshing shared profiles")
pp_log.debug("\tAdding: \(toAdd.map(\.logDescription))")
pp_log.debug("\tExisting: \(existingProfiles.keys)")
pp_log.debug("\tRemoving: \(toRemove)")
sharedProfileRepository.removeProfiles(withIds: toRemove)
do {
try sharedProfileRepository.saveProfiles(toAdd.map {
willSaveSharedProfile($0, existingProfiles[$0.id])
})
} catch {
pp_log.error("Unable to save shared profiles: \(error)")
}
}
}
// 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 Passepartout.ProfileError.failedToFetchProvider(profileId: profile.id, error: error)
}
}
}
// MARK: Repository
extension ProfileManager {
public func swapProfileRepository(_ newProfileRepository: ProfileRepository) {
cancellables.removeAll()
objectWillChange.send()
profileRepository = newProfileRepository
observeUpdates()
}
}
// 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 {
enum StoreKey: String, KeyStoreDomainLocation {
case activeProfileId
var domain: String {
"Passepartout.ProfileManager"
}
}
}