// // ProfileEditor.swift // Passepartout // // Created by Davide De Rosa on 2/17/24. // 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 CommonLibrary import Foundation import PassepartoutKit @MainActor final class ProfileEditor: ObservableObject { private(set) var id: Profile.ID @Published var name: String @Published private(set) var modules: [any EditableModule] @Published private(set) var activeModulesIds: Set @Published private var moduleNames: [UUID: String] private(set) var removedModules: [UUID: any EditableModule] convenience init() { self.init(modules: []) } init(modules: [any EditableModule]) { id = UUID() name = "" self.modules = modules activeModulesIds = Set(modules.map(\.id)) moduleNames = [:] removedModules = [:] } init(profile: Profile) { id = profile.id name = profile.name modules = profile.modulesBuilders activeModulesIds = profile.activeModulesIds moduleNames = profile.moduleNames removedModules = [:] } func editProfile(_ profile: Profile) { id = profile.id name = profile.name modules = profile.modulesBuilders activeModulesIds = profile.activeModulesIds moduleNames = profile.moduleNames removedModules = [:] } } // MARK: - CRUD extension ProfileEditor { var moduleTypes: [ModuleType] { modules .compactMap { $0 as? ModuleTypeProviding } .map(\.moduleType) } var availableModuleTypes: [ModuleType] { ModuleType .allCases .filter { // TODO: hide manual OpenVPN/WireGuard until editable $0 != .openVPN && $0 != .wireGuard } .filter { !moduleTypes.contains($0) } .sorted { $0.localizedDescription < $1.localizedDescription } } func displayName(forModuleWithId moduleId: UUID) -> String? { guard let name = moduleNames[moduleId] else { return nil } let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) return !trimmedName.isEmpty ? trimmedName : nil } func name(forModuleWithId moduleId: UUID) -> String? { moduleNames[moduleId] } func setName(_ name: String, forModuleWithId moduleId: UUID) { moduleNames[moduleId] = name } func module(withId moduleId: UUID) -> (any EditableModule)? { modules.first { $0.id == moduleId } ?? removedModules[moduleId] } func moveModules(from offsets: IndexSet, to newOffset: Int) { modules.move(fromOffsets: offsets, toOffset: newOffset) } func removeModules(at offsets: IndexSet) { offsets.forEach { let module = modules[$0] removedModules[module.id] = module modules.remove(at: $0) } } func removeModule(withId moduleId: UUID) { guard let index = modules.firstIndex(where: { $0.id == moduleId }) else { return } let module = modules[index] removedModules[module.id] = module modules.remove(at: index) } func saveModule(_ module: any EditableModule, activating: Bool) { if let index = modules.firstIndex(where: { $0.id == module.id }) { modules[index] = module } else { modules.append(module) } if activating { activateModule(module) } } } // MARK: - Active modules extension ProfileEditor { func isActiveModule(withId moduleId: UUID) -> Bool { activeModulesIds.contains(moduleId) && !removedModules.keys.contains(moduleId) } var activeConnectionModule: (any EditableModule)? { modules.first { isActiveModule(withId: $0.id) && $0.buildsConnectionModule } } var activeModules: [any EditableModule] { modules.filter { activeModulesIds.contains($0.id) } } func activateModule(_ module: any EditableModule) { activeModulesIds.insert(module.id) } func toggleModule(withId moduleId: UUID) { guard let existingModule = module(withId: moduleId) else { return } if isActiveModule(withId: moduleId) { activeModulesIds.remove(moduleId) } else { activateModule(existingModule) } } } private extension ProfileEditor { func checkConstraints() throws { if activeConnectionModule == nil, let ipModule = modules.first(where: { activeModulesIds.contains($0.id) && $0 is IPModule.Builder }) { throw AppError.ipModuleRequiresConnection(ipModule) } let connectionModules = modules.filter { activeModulesIds.contains($0.id) && $0.buildsConnectionModule } guard connectionModules.count <= 1 else { throw AppError.multipleConnectionModules(connectionModules) } } } // MARK: - Building extension ProfileEditor { func build() throws -> Profile { try checkConstraints() var builder = Profile.Builder(id: id) let trimmedName = name.trimmingCharacters(in: .whitespaces) guard !trimmedName.isEmpty else { throw AppError.emptyProfileName } builder.name = trimmedName builder.modules = try modules.compactMap { do { return try $0.tryBuild() } catch { throw AppError.malformedModule($0, error: error) } } builder.activeModulesIds = activeModulesIds builder.moduleNames = moduleNames.reduce(into: [:]) { let trimmedName = $1.value.trimmingCharacters(in: .whitespaces) guard !trimmedName.isEmpty else { return } $0[$1.key] = trimmedName } let profile = try builder.tryBuild() // update local view modules = profile.modulesBuilders removedModules.removeAll() return profile } } private extension Profile { var modulesBuilders: [any EditableModule] { modules.compactMap { guard let buildableModule = $0 as? any BuildableType else { return nil } let builder = buildableModule.builder() as any BuilderType return builder as? any EditableModule } } } // MARK: - Saving extension ProfileEditor { func save(to profileManager: ProfileManager) async throws { do { let newProfile = try build() try await profileManager.save(newProfile) } catch { pp_log(.app, .fault, "Unable to save edited profile: \(error)") throw error } } }