diff --git a/Library/Sources/AppDataPreferences/Domain/CDFavoriteServer.swift b/Library/Sources/AppDataPreferences/Domain/CDFavoriteServer.swift new file mode 100644 index 00000000..cbe11d40 --- /dev/null +++ b/Library/Sources/AppDataPreferences/Domain/CDFavoriteServer.swift @@ -0,0 +1,37 @@ +// +// CDFavoriteServer.swift +// Passepartout +// +// Created by Davide De Rosa on 12/10/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 CoreData +import Foundation + +@objc(CDFavoriteServer) +final class CDFavoriteServer: NSManagedObject { + @nonobjc static func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "CDFavoriteServer") + } + + @NSManaged var serverId: String? + @NSManaged var providerPreferences: CDProviderPreferencesV3? +} diff --git a/Library/Sources/AppDataPreferences/Domain/CDProviderPreferencesV3.swift b/Library/Sources/AppDataPreferences/Domain/CDProviderPreferencesV3.swift index 3d3fabc1..a7ba2ea6 100644 --- a/Library/Sources/AppDataPreferences/Domain/CDProviderPreferencesV3.swift +++ b/Library/Sources/AppDataPreferences/Domain/CDProviderPreferencesV3.swift @@ -35,4 +35,5 @@ final class CDProviderPreferencesV3: NSManagedObject { @NSManaged var providerId: String? @NSManaged var lastUpdate: Date? @NSManaged var favoriteServerIds: Data? + @NSManaged var favoriteServers: Set? } diff --git a/Library/Sources/AppDataPreferences/Domain/Mapper.swift b/Library/Sources/AppDataPreferences/Domain/Mapper.swift index 4f37d23e..5dd7fff6 100644 --- a/Library/Sources/AppDataPreferences/Domain/Mapper.swift +++ b/Library/Sources/AppDataPreferences/Domain/Mapper.swift @@ -29,15 +29,6 @@ import Foundation import PassepartoutKit struct DomainMapper { - func excludedEndpoints(from entities: Set?) -> Set { - entities.map { - Set($0.compactMap { - $0.endpoint.map { - ExtendedEndpoint(rawValue: $0) - } ?? nil - }) - } ?? [] - } } struct CoreDataMapper { @@ -49,7 +40,9 @@ struct CoreDataMapper { return cdEndpoint } - func cdExcludedEndpoints(from endpoints: Set) -> Set { - Set(endpoints.map(cdExcludedEndpoint(from:))) + func cdFavoriteServer(from serverId: String) -> CDFavoriteServer { + let cdFavorite = CDFavoriteServer(context: context) + cdFavorite.serverId = serverId + return cdFavorite } } diff --git a/Library/Sources/AppDataPreferences/Preferences.xcdatamodeld/Preferences.xcdatamodel/contents b/Library/Sources/AppDataPreferences/Preferences.xcdatamodeld/Preferences.xcdatamodel/contents index abd570d6..16aa7607 100644 --- a/Library/Sources/AppDataPreferences/Preferences.xcdatamodeld/Preferences.xcdatamodel/contents +++ b/Library/Sources/AppDataPreferences/Preferences.xcdatamodeld/Preferences.xcdatamodel/contents @@ -4,6 +4,10 @@ + + + + @@ -13,5 +17,6 @@ + \ No newline at end of file diff --git a/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift b/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift index 6ca884e6..b07bbfa7 100644 --- a/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift +++ b/Library/Sources/AppDataPreferences/Strategy/CDModulePreferencesRepositoryV3.swift @@ -30,6 +30,8 @@ import Foundation import PassepartoutKit extension AppData { + + @MainActor public static func cdModulePreferencesRepositoryV3(context: NSManagedObjectContext, moduleId: UUID) throws -> ModulePreferencesRepository { try CDModulePreferencesRepositoryV3(context: context, moduleId: moduleId) } diff --git a/Library/Sources/AppDataPreferences/Strategy/CDProviderPreferencesRepositoryV3.swift b/Library/Sources/AppDataPreferences/Strategy/CDProviderPreferencesRepositoryV3.swift index 5fd0a9e8..63447789 100644 --- a/Library/Sources/AppDataPreferences/Strategy/CDProviderPreferencesRepositoryV3.swift +++ b/Library/Sources/AppDataPreferences/Strategy/CDProviderPreferencesRepositoryV3.swift @@ -30,6 +30,8 @@ import Foundation import PassepartoutKit extension AppData { + + @MainActor public static func cdProviderPreferencesRepositoryV3(context: NSManagedObjectContext, providerId: ProviderID) throws -> ProviderPreferencesRepository { try CDProviderPreferencesRepositoryV3(context: context, providerId: providerId) } @@ -55,12 +57,26 @@ private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesReposi guard $0.offset > 0 else { return } + $0.element.favoriteServers?.forEach(context.delete(_:)) context.delete($0.element) } let entity = entities.first ?? CDProviderPreferencesV3(context: context) entity.providerId = providerId.rawValue entity.lastUpdate = Date() + + // migrate favorite server ids + if let favoriteServerIds = entity.favoriteServerIds { + let mapper = CoreDataMapper(context: context) + let ids = try? JSONDecoder().decode(Set.self, from: favoriteServerIds) + var favoriteServers: Set = [] + ids?.forEach { + favoriteServers.insert(mapper.cdFavoriteServer(from: $0)) + } + entity.favoriteServers = favoriteServers + entity.favoriteServerIds = nil + } + return entity } catch { pp_log(.app, .error, "Unable to load preferences for provider \(providerId): \(error)") @@ -69,28 +85,37 @@ private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesReposi } } - var favoriteServers: Set { - get { - do { - return try context.performAndWait { - guard let data = entity.favoriteServerIds else { - return [] - } - return try JSONDecoder().decode(Set.self, from: data) - } - } catch { - pp_log(.app, .error, "Unable to get favoriteServers: \(error)") - return [] - } + func isFavoriteServer(_ serverId: String) -> Bool { + context.performAndWait { + entity.favoriteServers?.contains { + $0.serverId == serverId + } ?? false } - set { - do { - try context.performAndWait { - entity.favoriteServerIds = try JSONEncoder().encode(newValue) - } - } catch { - pp_log(.app, .error, "Unable to set favoriteServers: \(error)") + } + + func addFavoriteServer(_ serverId: String) { + context.performAndWait { + guard entity.favoriteServers?.contains(where: { + $0.serverId == serverId + }) != true else { + return } + let mapper = CoreDataMapper(context: context) + let cdFavorite = mapper.cdFavoriteServer(from: serverId) + cdFavorite.providerPreferences = entity + entity.favoriteServers?.insert(cdFavorite) + } + } + + func removeFavoriteServer(_ serverId: String) { + context.performAndWait { + guard let found = entity.favoriteServers?.first(where: { + $0.serverId == serverId + }) else { + return + } + entity.favoriteServers?.remove(found) + context.delete(found) } } diff --git a/Library/Sources/AppUIMain/Views/VPN/VPNProviderServerView.swift b/Library/Sources/AppUIMain/Views/VPN/VPNProviderServerView.swift index ac834623..0508add1 100644 --- a/Library/Sources/AppUIMain/Views/VPN/VPNProviderServerView.swift +++ b/Library/Sources/AppUIMain/Views/VPN/VPNProviderServerView.swift @@ -126,7 +126,7 @@ private extension VPNProviderServerView { var filteredServers: [VPNServer] { if onlyShowsFavorites { return servers.filter { - providerPreferences.favoriteServers.contains($0.serverId) + providerPreferences.isFavoriteServer($0.serverId) } } return servers diff --git a/Library/Sources/AppUIMain/Views/VPN/iOS/VPNProviderServer+Content+iOS.swift b/Library/Sources/AppUIMain/Views/VPN/iOS/VPNProviderServer+Content+iOS.swift index a36a394d..3d4bfc57 100644 --- a/Library/Sources/AppUIMain/Views/VPN/iOS/VPNProviderServer+Content+iOS.swift +++ b/Library/Sources/AppUIMain/Views/VPN/iOS/VPNProviderServer+Content+iOS.swift @@ -151,7 +151,7 @@ private extension VPNProviderServerView.ContentView { Spacer() FavoriteToggle( value: server.serverId, - selection: $providerPreferences.favoriteServers + selection: providerPreferences.favoriteServers() ) } } diff --git a/Library/Sources/AppUIMain/Views/VPN/macOS/VPNProviderServer+Content+macOS.swift b/Library/Sources/AppUIMain/Views/VPN/macOS/VPNProviderServer+Content+macOS.swift index 5be6bf19..5d38cc21 100644 --- a/Library/Sources/AppUIMain/Views/VPN/macOS/VPNProviderServer+Content+macOS.swift +++ b/Library/Sources/AppUIMain/Views/VPN/macOS/VPNProviderServer+Content+macOS.swift @@ -87,7 +87,7 @@ private extension VPNProviderServerView.ContentView { TableColumn("􀋂") { server in FavoriteToggle( value: server.serverId, - selection: $providerPreferences.favoriteServers + selection: providerPreferences.favoriteServers() ) .environmentObject(theme) // TODO: #873, Table loses environment } diff --git a/Library/Sources/CommonLibrary/Business/ModulePreferences.swift b/Library/Sources/CommonLibrary/Business/ModulePreferences.swift index 25642aff..553897ec 100644 --- a/Library/Sources/CommonLibrary/Business/ModulePreferences.swift +++ b/Library/Sources/CommonLibrary/Business/ModulePreferences.swift @@ -27,8 +27,7 @@ import CommonUtils import Foundation import PassepartoutKit -@MainActor -public final class ModulePreferences: ObservableObject { +public final class ModulePreferences: ObservableObject, ModulePreferencesRepository { private var repository: ModulePreferencesRepository? public init() { @@ -43,10 +42,12 @@ public final class ModulePreferences: ObservableObject { } public func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) { + objectWillChange.send() repository?.addExcludedEndpoint(endpoint) } public func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) { + objectWillChange.send() repository?.removeExcludedEndpoint(endpoint) } diff --git a/Library/Sources/CommonLibrary/Business/PreferencesManager.swift b/Library/Sources/CommonLibrary/Business/PreferencesManager.swift index ac8f662a..a31a0e56 100644 --- a/Library/Sources/CommonLibrary/Business/PreferencesManager.swift +++ b/Library/Sources/CommonLibrary/Business/PreferencesManager.swift @@ -27,14 +27,15 @@ import CommonUtils import Foundation import PassepartoutKit -public final class PreferencesManager: ObservableObject, Sendable { - private let modulesFactory: @Sendable (UUID) throws -> ModulePreferencesRepository +@MainActor +public final class PreferencesManager: ObservableObject { + private let modulesFactory: (UUID) throws -> ModulePreferencesRepository - private let providersFactory: @Sendable (ProviderID) throws -> ProviderPreferencesRepository + private let providersFactory: (ProviderID) throws -> ProviderPreferencesRepository public init( - modulesFactory: (@Sendable (UUID) throws -> ModulePreferencesRepository)? = nil, - providersFactory: (@Sendable (ProviderID) throws -> ProviderPreferencesRepository)? = nil + modulesFactory: ((UUID) throws -> ModulePreferencesRepository)? = nil, + providersFactory: ((ProviderID) throws -> ProviderPreferencesRepository)? = nil ) { self.modulesFactory = modulesFactory ?? { _ in DummyModulePreferencesRepository() @@ -57,6 +58,7 @@ extension PreferencesManager { // MARK: - Dummy +@MainActor private final class DummyModulePreferencesRepository: ModulePreferencesRepository { func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool { false @@ -72,8 +74,17 @@ private final class DummyModulePreferencesRepository: ModulePreferencesRepositor } } +@MainActor private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository { - var favoriteServers: Set = [] + func isFavoriteServer(_ serverId: String) -> Bool { + false + } + + func addFavoriteServer(_ serverId: String) { + } + + func removeFavoriteServer(_ serverId: String) { + } func save() throws { } diff --git a/Library/Sources/CommonLibrary/Business/ProviderPreferences.swift b/Library/Sources/CommonLibrary/Business/ProviderPreferences.swift index 707e2c6a..9171da9c 100644 --- a/Library/Sources/CommonLibrary/Business/ProviderPreferences.swift +++ b/Library/Sources/CommonLibrary/Business/ProviderPreferences.swift @@ -27,8 +27,7 @@ import CommonUtils import Foundation import PassepartoutKit -@MainActor -public final class ProviderPreferences: ObservableObject { +public final class ProviderPreferences: ObservableObject, ProviderPreferencesRepository { private var repository: ProviderPreferencesRepository? public init() { @@ -38,16 +37,30 @@ public final class ProviderPreferences: ObservableObject { self.repository = repository } - public var favoriteServers: Set { - get { - repository?.favoriteServers ?? [] - } - set { - objectWillChange.send() - repository?.favoriteServers = newValue + public func favoriteServers() -> ObservableList { + ObservableList { [weak self] in + self?.isFavoriteServer($0) ?? false + } add: { [weak self] in + self?.addFavoriteServer($0) + } remove: { [weak self] in + self?.removeFavoriteServer($0) } } + public func isFavoriteServer(_ serverId: String) -> Bool { + repository?.isFavoriteServer(serverId) ?? false + } + + public func addFavoriteServer(_ serverId: String) { + objectWillChange.send() + repository?.addFavoriteServer(serverId) + } + + public func removeFavoriteServer(_ serverId: String) { + objectWillChange.send() + repository?.removeFavoriteServer(serverId) + } + public func save() throws { try repository?.save() } diff --git a/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift b/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift index a0481589..bfa12049 100644 --- a/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift +++ b/Library/Sources/CommonLibrary/Strategy/ModulePreferencesRepository.swift @@ -26,6 +26,7 @@ import Foundation import PassepartoutKit +@MainActor public protocol ModulePreferencesRepository { func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool diff --git a/Library/Sources/CommonLibrary/Strategy/ProviderPreferencesRepository.swift b/Library/Sources/CommonLibrary/Strategy/ProviderPreferencesRepository.swift index 477fd7f7..01064770 100644 --- a/Library/Sources/CommonLibrary/Strategy/ProviderPreferencesRepository.swift +++ b/Library/Sources/CommonLibrary/Strategy/ProviderPreferencesRepository.swift @@ -26,8 +26,13 @@ import Foundation import PassepartoutKit +@MainActor public protocol ProviderPreferencesRepository { - var favoriteServers: Set { get set } + func isFavoriteServer(_ serverId: String) -> Bool + + func addFavoriteServer(_ serverId: String) + + func removeFavoriteServer(_ serverId: String) func save() throws } diff --git a/Library/Sources/UILibrary/Views/UI/FavoriteToggle.swift b/Library/Sources/UILibrary/Views/UI/FavoriteToggle.swift index f9391eb5..37d20a50 100644 --- a/Library/Sources/UILibrary/Views/UI/FavoriteToggle.swift +++ b/Library/Sources/UILibrary/Views/UI/FavoriteToggle.swift @@ -23,20 +23,21 @@ // along with Passepartout. If not, see . // +import CommonUtils import SwiftUI public struct FavoriteToggle: View where ID: Hashable { private let value: ID - @Binding - private var selection: Set + @ObservedObject + private var selection: ObservableList @State private var hover: ID? - public init(value: ID, selection: Binding>) { + public init(value: ID, selection: ObservableList) { self.value = value - _selection = selection + self.selection = selection } public var body: some View { @@ -44,7 +45,7 @@ public struct FavoriteToggle: View where ID: Hashable { if selection.contains(value) { selection.remove(value) } else { - selection.insert(value) + selection.add(value) } } label: { ThemeImage(selection.contains(value) ? .favoriteOn : .favoriteOff)