Refactor ProfileEditor to leverage ProfileType (#689)

Closes #688
This commit is contained in:
Davide 2024-10-06 13:41:02 +02:00 committed by GitHub
parent 17f1331de0
commit f4505d0efd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 224 additions and 160 deletions

View File

@ -32,7 +32,7 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source", "location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
"state" : { "state" : {
"revision" : "f681f968f39ca514e29ac6c0abcf658c224e4c04" "revision" : "7efa18eb75b7a102781be3c62cd31a08607f03c8"
} }
}, },
{ {

View File

@ -31,7 +31,7 @@ let package = Package(
], ],
dependencies: [ dependencies: [
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.8.0"), // .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(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", from: "0.8.0"),
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"), // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"),

View File

@ -31,65 +31,46 @@ import PassepartoutKit
@MainActor @MainActor
final class ProfileEditor: ObservableObject { final class ProfileEditor: ObservableObject {
private(set) var id: Profile.ID
@Published @Published
var name: String private var editableProfile: EditableProfile
@Published @Published
var isShared: Bool var isShared: Bool
@Published private(set) var removedModules: [UUID: any ModuleBuilder]
private(set) var modules: [any EditableModule]
@Published
private(set) var activeModulesIds: Set<UUID>
@Published
private var moduleNames: [UUID: String]
private(set) var removedModules: [UUID: any EditableModule]
convenience init() { convenience init() {
self.init(modules: []) self.init(modules: [])
} }
init(modules: [any EditableModule]) { init(modules: [any ModuleBuilder]) {
id = UUID() editableProfile = EditableProfile(
name = "" modules: modules,
self.modules = modules activeModulesIds: Set(modules.map(\.id))
activeModulesIds = Set(modules.map(\.id)) )
moduleNames = [:]
removedModules = [:]
isShared = false isShared = false
removedModules = [:]
} }
init(profile: Profile) { init(profile: Profile) {
id = profile.id editableProfile = profile.editable()
name = profile.name
modules = profile.modulesBuilders
activeModulesIds = profile.activeModulesIds
moduleNames = profile.moduleNames
removedModules = [:]
isShared = false isShared = false
removedModules = [:]
} }
func editProfile(_ profile: Profile, isShared: Bool) { func editProfile(_ profile: Profile, isShared: Bool) {
id = profile.id editableProfile = profile.editable()
name = profile.name
modules = profile.modulesBuilders
activeModulesIds = profile.activeModulesIds
moduleNames = profile.moduleNames
removedModules = [:]
self.isShared = isShared self.isShared = isShared
removedModules = [:]
} }
} }
// MARK: - CRUD // MARK: - Types
extension ProfileEditor { extension ProfileEditor {
var moduleTypes: [ModuleType] { var moduleTypes: [ModuleType] {
modules editableProfile.modules
.compactMap { .compactMap {
$0 as? ModuleTypeProviding $0 as? ModuleTypeProviding
} }
@ -110,83 +91,52 @@ extension ProfileEditor {
$0.localizedDescription < $1.localizedDescription $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? { func displayName(forModuleWithId moduleId: UUID) -> String? {
guard let name = moduleNames[moduleId] else { editableProfile.displayName(forModuleWithId: moduleId)
return nil
}
let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
return !trimmedName.isEmpty ? trimmedName : nil
} }
func name(forModuleWithId moduleId: UUID) -> String? { func name(forModuleWithId moduleId: UUID) -> String? {
moduleNames[moduleId] editableProfile.name(forModuleWithId: moduleId)
} }
func setName(_ name: String, forModuleWithId moduleId: UUID) { func setName(_ name: String, forModuleWithId moduleId: UUID) {
moduleNames[moduleId] = name editableProfile.setName(name, forModuleWithId: moduleId)
}
} }
func module(withId moduleId: UUID) -> (any EditableModule)? { // MARK: - Modules
modules.first {
extension ProfileEditor {
var modules: [any ModuleBuilder] {
editableProfile.modules
}
func module(withId moduleId: UUID) -> (any ModuleBuilder)? {
editableProfile.modules.first {
$0.id == moduleId $0.id == moduleId
} ?? removedModules[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 { func isActiveModule(withId moduleId: UUID) -> Bool {
activeModulesIds.contains(moduleId) && !removedModules.keys.contains(moduleId) editableProfile.isActiveModule(withId: 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) { func toggleModule(withId moduleId: UUID) {
@ -194,26 +144,48 @@ extension ProfileEditor {
return return
} }
if isActiveModule(withId: moduleId) { if isActiveModule(withId: moduleId) {
activeModulesIds.remove(moduleId) editableProfile.activeModulesIds.remove(moduleId)
} else { } else {
activateModule(existingModule) 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 { private extension ProfileEditor {
func checkConstraints() throws { func activateModule(_ module: any ModuleBuilder) {
if activeConnectionModule == nil, editableProfile.activeModulesIds.insert(module.id)
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)
}
} }
} }
@ -221,51 +193,17 @@ private extension ProfileEditor {
extension ProfileEditor { extension ProfileEditor {
func build() throws -> Profile { func build() throws -> Profile {
try checkConstraints() let builder = try editableProfile.builder()
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() let profile = try builder.tryBuild()
// update local view // update local view
modules = profile.modulesBuilders editableProfile.modules = profile.modulesBuilders
removedModules.removeAll() removedModules.removeAll()
return profile 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 // MARK: - Saving
extension ProfileEditor { extension ProfileEditor {
@ -279,3 +217,11 @@ extension ProfileEditor {
} }
} }
} }
// MARK: - Testing
extension ProfileEditor {
var activeModulesIds: Set<UUID> {
editableProfile.activeModulesIds
}
}

View File

@ -29,11 +29,11 @@ import PassepartoutKit
enum AppError { enum AppError {
case emptyProfileName 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 case permissionDenied

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import Foundation
import PassepartoutKit
struct EditableProfile: MutableProfileType {
var id = UUID()
var name: String = ""
var modules: [any ModuleBuilder] = []
var activeModulesIds: Set<UUID> = []
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)
}
}
}

View File

@ -27,7 +27,7 @@ import Foundation
import PassepartoutKit import PassepartoutKit
extension ModuleType { extension ModuleType {
func newModule() -> any EditableModule { func newModule() -> any ModuleBuilder {
switch self { switch self {
case .openVPN: case .openVPN:
return OpenVPNModule.Builder() return OpenVPNModule.Builder()

View File

@ -1,5 +1,5 @@
// //
// EditableModule+Description.swift // ModuleBuilder+Description.swift
// Passepartout // Passepartout
// //
// Created by Davide De Rosa on 9/6/24. // Created by Davide De Rosa on 9/6/24.
@ -26,7 +26,7 @@
import Foundation import Foundation
import PassepartoutKit import PassepartoutKit
extension EditableModule { extension ModuleBuilder {
@MainActor @MainActor
func description(inEditor editor: ProfileEditor) -> String { func description(inEditor editor: ProfileEditor) -> String {
@ -34,7 +34,7 @@ extension EditableModule {
} }
} }
extension EditableModule { extension ModuleBuilder {
var typeDescription: String { var typeDescription: String {
guard let providing = self as? ModuleTypeProviding else { guard let providing = self as? ModuleTypeProviding else {
return String(describing: self) return String(describing: self)

View File

@ -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) { EditorModuleToggle(profileEditor: profileEditor, module: module) {
Button { Button {
push(.moduleDetail(moduleId: module.id)) push(.moduleDetail(moduleId: module.id))

View File

@ -69,7 +69,7 @@ struct ModuleListView: View, Routable {
} }
private extension ModuleListView { private extension ModuleListView {
func moduleRow(for module: any EditableModule) -> some View { func moduleRow(for module: any ModuleBuilder) -> some View {
HStack { HStack {
Text(module.description(inEditor: profileEditor)) Text(module.description(inEditor: profileEditor))
.themeError(malformedModuleIds.contains(module.id)) .themeError(malformedModuleIds.contains(module.id))

View File

@ -56,7 +56,7 @@ private extension ProfileEditor {
extension View { extension View {
@MainActor @MainActor
func asModuleView<T>(with editor: ProfileEditor, draft: T, withName: Bool = true) -> some View where T: EditableModule, T: Equatable { func asModuleView<T>(with editor: ProfileEditor, draft: T, withName: Bool = true) -> some View where T: ModuleBuilder, T: Equatable {
Form { Form {
if withName { if withName {
NameSection( NameSection(

View File

@ -1,5 +1,5 @@
// //
// EditableModule+Previews.swift // ModuleBuilder+Previews.swift
// Passepartout // Passepartout
// //
// Created by Davide De Rosa on 8/19/24. // Created by Davide De Rosa on 8/19/24.
@ -26,7 +26,7 @@
import PassepartoutKit import PassepartoutKit
import SwiftUI import SwiftUI
extension EditableModule where Self: ModuleViewProviding { extension ModuleBuilder where Self: ModuleViewProviding {
@MainActor @MainActor
func preview(title: String = "") -> some View { func preview(title: String = "") -> some View {

View File

@ -35,7 +35,7 @@ extension ProfileEditor {
} }
} }
func binding<T>(forModule module: T) -> Binding<T> where T: EditableModule { func binding<T>(forModule module: T) -> Binding<T> where T: ModuleBuilder {
Binding { [weak self] in Binding { [weak self] in
guard let foundModule = self?.module(withId: module.id) else { guard let foundModule = self?.module(withId: module.id) else {
fatalError("Module not found in editor: \(module.id)") fatalError("Module not found in editor: \(module.id)")

View File

@ -31,7 +31,7 @@ struct EditorModuleToggle<Label>: View where Label: View {
@ObservedObject @ObservedObject
var profileEditor: ProfileEditor var profileEditor: ProfileEditor
let module: any EditableModule let module: any ModuleBuilder
let label: () -> Label let label: () -> Label