From f4505d0efdfdeabe4742a63ee4e2b0b058dae674 Mon Sep 17 00:00:00 2001 From: Davide Date: Sun, 6 Oct 2024 13:41:02 +0200 Subject: [PATCH] Refactor ProfileEditor to leverage ProfileType (#689) Closes #688 --- .../xcshareddata/swiftpm/Package.resolved | 2 +- Passepartout/Library/Package.swift | 2 +- .../AppUI/Business/ProfileEditor.swift | 234 +++++++----------- .../Sources/AppUI/Domain/AppError.swift | 6 +- .../AppUI/Domain/EditableProfile.swift | 118 +++++++++ .../Sources/AppUI/Domain/ModuleType+New.swift | 2 +- .../L10n/EditableModule+Description.swift | 6 +- .../Profile/iOS/ProfileEditView+iOS.swift | 2 +- .../Profile/macOS/ModuleListView+macOS.swift | 2 +- .../DefaultModuleViewFactory.swift | 2 +- .../EditableModule+Previews.swift | 4 +- .../ProfileEditor/ProfileEditor+UI.swift | 2 +- .../AppUI/Views/UI/EditorModuleToggle.swift | 2 +- 13 files changed, 224 insertions(+), 160 deletions(-) create mode 100644 Passepartout/Library/Sources/AppUI/Domain/EditableProfile.swift diff --git a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index a1b1332b..41f32a76 100644 --- a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -32,7 +32,7 @@ "kind" : "remoteSourceControl", "location" : "git@github.com:passepartoutvpn/passepartoutkit-source", "state" : { - "revision" : "f681f968f39ca514e29ac6c0abcf658c224e4c04" + "revision" : "7efa18eb75b7a102781be3c62cd31a08607f03c8" } }, { diff --git a/Passepartout/Library/Package.swift b/Passepartout/Library/Package.swift index eab5aed9..854d8de7 100644 --- a/Passepartout/Library/Package.swift +++ b/Passepartout/Library/Package.swift @@ -31,7 +31,7 @@ let package = Package( ], dependencies: [ // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.8.0"), - .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "f681f968f39ca514e29ac6c0abcf658c224e4c04"), + .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "7efa18eb75b7a102781be3c62cd31a08607f03c8"), // .package(path: "../../../passepartoutkit-source"), .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", from: "0.8.0"), // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"), diff --git a/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift b/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift index 5d669fbc..039fb896 100644 --- a/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift +++ b/Passepartout/Library/Sources/AppUI/Business/ProfileEditor.swift @@ -31,65 +31,46 @@ import PassepartoutKit @MainActor final class ProfileEditor: ObservableObject { - private(set) var id: Profile.ID @Published - var name: String + private var editableProfile: EditableProfile @Published var isShared: Bool - @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] + private(set) var removedModules: [UUID: any ModuleBuilder] convenience init() { self.init(modules: []) } - init(modules: [any EditableModule]) { - id = UUID() - name = "" - self.modules = modules - activeModulesIds = Set(modules.map(\.id)) - moduleNames = [:] - removedModules = [:] + init(modules: [any ModuleBuilder]) { + editableProfile = EditableProfile( + modules: modules, + activeModulesIds: Set(modules.map(\.id)) + ) isShared = false + removedModules = [:] } init(profile: Profile) { - id = profile.id - name = profile.name - modules = profile.modulesBuilders - activeModulesIds = profile.activeModulesIds - moduleNames = profile.moduleNames - removedModules = [:] + editableProfile = profile.editable() isShared = false + removedModules = [:] } func editProfile(_ profile: Profile, isShared: Bool) { - id = profile.id - name = profile.name - modules = profile.modulesBuilders - activeModulesIds = profile.activeModulesIds - moduleNames = profile.moduleNames - removedModules = [:] + editableProfile = profile.editable() self.isShared = isShared + removedModules = [:] } } -// MARK: - CRUD +// MARK: - Types extension ProfileEditor { var moduleTypes: [ModuleType] { - modules + editableProfile.modules .compactMap { $0 as? ModuleTypeProviding } @@ -110,83 +91,52 @@ extension ProfileEditor { $0.localizedDescription < $1.localizedDescription } } +} + +// MARK: - Metadata + +extension ProfileEditor { + var id: Profile.ID { + editableProfile.id + } + + var name: String { + get { + editableProfile.name + } + set { + editableProfile.name = newValue + } + } 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 + editableProfile.displayName(forModuleWithId: moduleId) } func name(forModuleWithId moduleId: UUID) -> String? { - moduleNames[moduleId] + editableProfile.name(forModuleWithId: moduleId) } func setName(_ name: String, forModuleWithId moduleId: UUID) { - moduleNames[moduleId] = name + editableProfile.setName(name, forModuleWithId: moduleId) + } +} + +// MARK: - Modules + +extension ProfileEditor { + var modules: [any ModuleBuilder] { + editableProfile.modules } - func module(withId moduleId: UUID) -> (any EditableModule)? { - modules.first { + func module(withId moduleId: UUID) -> (any ModuleBuilder)? { + editableProfile.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) + editableProfile.isActiveModule(withId: moduleId) } func toggleModule(withId moduleId: UUID) { @@ -194,26 +144,48 @@ extension ProfileEditor { return } if isActiveModule(withId: moduleId) { - activeModulesIds.remove(moduleId) + editableProfile.activeModulesIds.remove(moduleId) } else { activateModule(existingModule) } } + + func moveModules(from offsets: IndexSet, to newOffset: Int) { + editableProfile.modules.move(fromOffsets: offsets, toOffset: newOffset) + } + + func removeModules(at offsets: IndexSet) { + offsets.forEach { + let module = editableProfile.modules[$0] + removedModules[module.id] = module + editableProfile.modules.remove(at: $0) + } + } + + func removeModule(withId moduleId: UUID) { + guard let index = editableProfile.modules.firstIndex(where: { $0.id == moduleId }) else { + return + } + let module = editableProfile.modules[index] + removedModules[module.id] = module + editableProfile.modules.remove(at: index) + } + + func saveModule(_ module: any ModuleBuilder, activating: Bool) { + if let index = editableProfile.modules.firstIndex(where: { $0.id == module.id }) { + editableProfile.modules[index] = module + } else { + editableProfile.modules.append(module) + } + if activating { + activateModule(module) + } + } } 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) - } + func activateModule(_ module: any ModuleBuilder) { + editableProfile.activeModulesIds.insert(module.id) } } @@ -221,51 +193,17 @@ private extension ProfileEditor { 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 builder = try editableProfile.builder() let profile = try builder.tryBuild() // update local view - modules = profile.modulesBuilders + editableProfile.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 { @@ -279,3 +217,11 @@ extension ProfileEditor { } } } + +// MARK: - Testing + +extension ProfileEditor { + var activeModulesIds: Set { + editableProfile.activeModulesIds + } +} diff --git a/Passepartout/Library/Sources/AppUI/Domain/AppError.swift b/Passepartout/Library/Sources/AppUI/Domain/AppError.swift index 7e1cc7fe..8c2d6628 100644 --- a/Passepartout/Library/Sources/AppUI/Domain/AppError.swift +++ b/Passepartout/Library/Sources/AppUI/Domain/AppError.swift @@ -29,11 +29,11 @@ import PassepartoutKit enum AppError { case emptyProfileName - case malformedModule(any EditableModule, error: Error) + case malformedModule(any ModuleBuilder, error: Error) - case multipleConnectionModules([any EditableModule]) + case multipleConnectionModules([any ModuleBuilder]) - case ipModuleRequiresConnection(any EditableModule) + case ipModuleRequiresConnection(any ModuleBuilder) case permissionDenied diff --git a/Passepartout/Library/Sources/AppUI/Domain/EditableProfile.swift b/Passepartout/Library/Sources/AppUI/Domain/EditableProfile.swift new file mode 100644 index 00000000..ab3db0be --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Domain/EditableProfile.swift @@ -0,0 +1,118 @@ +// +// EditableProfile.swift +// Passepartout +// +// Created by Davide De Rosa on 10/6/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 Foundation +import PassepartoutKit + +struct EditableProfile: MutableProfileType { + var id = UUID() + + var name: String = "" + + var modules: [any ModuleBuilder] = [] + + var activeModulesIds: Set = [] + + var modulesMetadata: [UUID: ModuleMetadata]? + + func builder() throws -> Profile.Builder { + try checkConstraints() + + var builder = Profile.Builder(id: id) + builder.modules = try modules.compactMap { + do { + return try $0.tryBuild() + } catch { + throw AppError.malformedModule($0, error: error) + } + } + builder.activeModulesIds = activeModulesIds + + let trimmedName = name.trimmingCharacters(in: .whitespaces) + guard !trimmedName.isEmpty else { + throw AppError.emptyProfileName + } + builder.name = trimmedName + + builder.modulesMetadata = modulesMetadata?.reduce(into: [:]) { + var metadata = $1.value + guard let name = metadata.name else { + return + } + let trimmedName = name.trimmingCharacters(in: .whitespaces) + guard !trimmedName.isEmpty else { + return + } + metadata.name = trimmedName + $0[$1.key] = metadata + } + + return builder + } +} + +extension Profile { + func editable() -> EditableProfile { + EditableProfile( + id: id, + name: name, + modules: modulesBuilders, + activeModulesIds: activeModulesIds, + modulesMetadata: modulesMetadata + ) + } + + var modulesBuilders: [any ModuleBuilder] { + modules.compactMap { + guard let buildableModule = $0 as? any BuildableType else { + return nil + } + let builder = buildableModule.builder() as any BuilderType + return builder as? any ModuleBuilder + } + } +} + +private extension EditableProfile { + var activeConnectionModule: (any ModuleBuilder)? { + modules.first { + isActiveModule(withId: $0.id) && $0.buildsConnectionModule + } + } + + 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) + } + } +} diff --git a/Passepartout/Library/Sources/AppUI/Domain/ModuleType+New.swift b/Passepartout/Library/Sources/AppUI/Domain/ModuleType+New.swift index 31fa41cd..79da3c7a 100644 --- a/Passepartout/Library/Sources/AppUI/Domain/ModuleType+New.swift +++ b/Passepartout/Library/Sources/AppUI/Domain/ModuleType+New.swift @@ -27,7 +27,7 @@ import Foundation import PassepartoutKit extension ModuleType { - func newModule() -> any EditableModule { + func newModule() -> any ModuleBuilder { switch self { case .openVPN: return OpenVPNModule.Builder() diff --git a/Passepartout/Library/Sources/AppUI/L10n/EditableModule+Description.swift b/Passepartout/Library/Sources/AppUI/L10n/EditableModule+Description.swift index 735c282e..32bd8cea 100644 --- a/Passepartout/Library/Sources/AppUI/L10n/EditableModule+Description.swift +++ b/Passepartout/Library/Sources/AppUI/L10n/EditableModule+Description.swift @@ -1,5 +1,5 @@ // -// EditableModule+Description.swift +// ModuleBuilder+Description.swift // Passepartout // // Created by Davide De Rosa on 9/6/24. @@ -26,7 +26,7 @@ import Foundation import PassepartoutKit -extension EditableModule { +extension ModuleBuilder { @MainActor func description(inEditor editor: ProfileEditor) -> String { @@ -34,7 +34,7 @@ extension EditableModule { } } -extension EditableModule { +extension ModuleBuilder { var typeDescription: String { guard let providing = self as? ModuleTypeProviding else { return String(describing: self) diff --git a/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift b/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift index a03f94dd..5159102e 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift @@ -95,7 +95,7 @@ private extension ProfileEditView { } } - func moduleRow(for module: any EditableModule) -> some View { + func moduleRow(for module: any ModuleBuilder) -> some View { EditorModuleToggle(profileEditor: profileEditor, module: module) { Button { push(.moduleDetail(moduleId: module.id)) diff --git a/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ModuleListView+macOS.swift b/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ModuleListView+macOS.swift index 2ceeb0e9..b406a9ea 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ModuleListView+macOS.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ModuleListView+macOS.swift @@ -69,7 +69,7 @@ struct ModuleListView: View, Routable { } private extension ModuleListView { - func moduleRow(for module: any EditableModule) -> some View { + func moduleRow(for module: any ModuleBuilder) -> some View { HStack { Text(module.description(inEditor: profileEditor)) .themeError(malformedModuleIds.contains(module.id)) diff --git a/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/DefaultModuleViewFactory.swift b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/DefaultModuleViewFactory.swift index b861f3a1..3659afb6 100644 --- a/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/DefaultModuleViewFactory.swift +++ b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/DefaultModuleViewFactory.swift @@ -56,7 +56,7 @@ private extension ProfileEditor { extension View { @MainActor - func asModuleView(with editor: ProfileEditor, draft: T, withName: Bool = true) -> some View where T: EditableModule, T: Equatable { + func asModuleView(with editor: ProfileEditor, draft: T, withName: Bool = true) -> some View where T: ModuleBuilder, T: Equatable { Form { if withName { NameSection( diff --git a/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/EditableModule+Previews.swift b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/EditableModule+Previews.swift index 3533fd51..f3819236 100644 --- a/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/EditableModule+Previews.swift +++ b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/EditableModule+Previews.swift @@ -1,5 +1,5 @@ // -// EditableModule+Previews.swift +// ModuleBuilder+Previews.swift // Passepartout // // Created by Davide De Rosa on 8/19/24. @@ -26,7 +26,7 @@ import PassepartoutKit import SwiftUI -extension EditableModule where Self: ModuleViewProviding { +extension ModuleBuilder where Self: ModuleViewProviding { @MainActor func preview(title: String = "") -> some View { diff --git a/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ProfileEditor+UI.swift b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ProfileEditor+UI.swift index e0497b75..bb2d2f45 100644 --- a/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ProfileEditor+UI.swift +++ b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ProfileEditor+UI.swift @@ -35,7 +35,7 @@ extension ProfileEditor { } } - func binding(forModule module: T) -> Binding where T: EditableModule { + func binding(forModule module: T) -> Binding where T: ModuleBuilder { Binding { [weak self] in guard let foundModule = self?.module(withId: module.id) else { fatalError("Module not found in editor: \(module.id)") diff --git a/Passepartout/Library/Sources/AppUI/Views/UI/EditorModuleToggle.swift b/Passepartout/Library/Sources/AppUI/Views/UI/EditorModuleToggle.swift index 1bacb89b..e9c25f2e 100644 --- a/Passepartout/Library/Sources/AppUI/Views/UI/EditorModuleToggle.swift +++ b/Passepartout/Library/Sources/AppUI/Views/UI/EditorModuleToggle.swift @@ -31,7 +31,7 @@ struct EditorModuleToggle