Migrate provider favorites to Core Data entities (#997)

Rather than a Codable array.
This commit is contained in:
Davide 2024-12-10 15:19:07 +01:00 committed by GitHub
parent c7bba033fe
commit 2b3c28832b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 152 additions and 57 deletions

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import CoreData
import Foundation
@objc(CDFavoriteServer)
final class CDFavoriteServer: NSManagedObject {
@nonobjc static func fetchRequest() -> NSFetchRequest<CDFavoriteServer> {
NSFetchRequest<CDFavoriteServer>(entityName: "CDFavoriteServer")
}
@NSManaged var serverId: String?
@NSManaged var providerPreferences: CDProviderPreferencesV3?
}

View File

@ -35,4 +35,5 @@ final class CDProviderPreferencesV3: NSManagedObject {
@NSManaged var providerId: String?
@NSManaged var lastUpdate: Date?
@NSManaged var favoriteServerIds: Data?
@NSManaged var favoriteServers: Set<CDFavoriteServer>?
}

View File

@ -29,15 +29,6 @@ import Foundation
import PassepartoutKit
struct DomainMapper {
func excludedEndpoints(from entities: Set<CDExcludedEndpoint>?) -> Set<ExtendedEndpoint> {
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<ExtendedEndpoint>) -> Set<CDExcludedEndpoint> {
Set(endpoints.map(cdExcludedEndpoint(from:)))
func cdFavoriteServer(from serverId: String) -> CDFavoriteServer {
let cdFavorite = CDFavoriteServer(context: context)
cdFavorite.serverId = serverId
return cdFavorite
}
}

View File

@ -4,6 +4,10 @@
<attribute name="endpoint" optional="YES" attributeType="String"/>
<relationship name="modulePreferences" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CDModulePreferencesV3" inverseName="excludedEndpoints" inverseEntity="CDModulePreferencesV3"/>
</entity>
<entity name="CDFavoriteServer" representedClassName="CDFavoriteServer" syncable="YES">
<attribute name="serverId" optional="YES" attributeType="String"/>
<relationship name="providerPreferences" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CDProviderPreferencesV3" inverseName="favoriteServers" inverseEntity="CDProviderPreferencesV3"/>
</entity>
<entity name="CDModulePreferencesV3" representedClassName="CDModulePreferencesV3" syncable="YES">
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="moduleId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
@ -13,5 +17,6 @@
<attribute name="favoriteServerIds" optional="YES" attributeType="Binary"/>
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="providerId" optional="YES" attributeType="String"/>
<relationship name="favoriteServers" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CDFavoriteServer" inverseName="providerPreferences" inverseEntity="CDFavoriteServer"/>
</entity>
</model>

View File

@ -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)
}

View File

@ -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<String>.self, from: favoriteServerIds)
var favoriteServers: Set<CDFavoriteServer> = []
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<String> {
get {
do {
return try context.performAndWait {
guard let data = entity.favoriteServerIds else {
return []
}
return try JSONDecoder().decode(Set<String>.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)
}
}

View File

@ -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

View File

@ -151,7 +151,7 @@ private extension VPNProviderServerView.ContentView {
Spacer()
FavoriteToggle(
value: server.serverId,
selection: $providerPreferences.favoriteServers
selection: providerPreferences.favoriteServers()
)
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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<String> = []
func isFavoriteServer(_ serverId: String) -> Bool {
false
}
func addFavoriteServer(_ serverId: String) {
}
func removeFavoriteServer(_ serverId: String) {
}
func save() throws {
}

View File

@ -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<String> {
get {
repository?.favoriteServers ?? []
}
set {
objectWillChange.send()
repository?.favoriteServers = newValue
public func favoriteServers() -> ObservableList<String> {
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()
}

View File

@ -26,6 +26,7 @@
import Foundation
import PassepartoutKit
@MainActor
public protocol ModulePreferencesRepository {
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool

View File

@ -26,8 +26,13 @@
import Foundation
import PassepartoutKit
@MainActor
public protocol ProviderPreferencesRepository {
var favoriteServers: Set<String> { get set }
func isFavoriteServer(_ serverId: String) -> Bool
func addFavoriteServer(_ serverId: String)
func removeFavoriteServer(_ serverId: String)
func save() throws
}

View File

@ -23,20 +23,21 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonUtils
import SwiftUI
public struct FavoriteToggle<ID>: View where ID: Hashable {
private let value: ID
@Binding
private var selection: Set<ID>
@ObservedObject
private var selection: ObservableList<ID>
@State
private var hover: ID?
public init(value: ID, selection: Binding<Set<ID>>) {
public init(value: ID, selection: ObservableList<ID>) {
self.value = value
_selection = selection
self.selection = selection
}
public var body: some View {
@ -44,7 +45,7 @@ public struct FavoriteToggle<ID>: 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)