Migrate provider favorites to Core Data entities (#997)
Rather than a Codable array.
This commit is contained in:
parent
c7bba033fe
commit
2b3c28832b
|
@ -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?
|
||||||
|
}
|
|
@ -35,4 +35,5 @@ final class CDProviderPreferencesV3: NSManagedObject {
|
||||||
@NSManaged var providerId: String?
|
@NSManaged var providerId: String?
|
||||||
@NSManaged var lastUpdate: Date?
|
@NSManaged var lastUpdate: Date?
|
||||||
@NSManaged var favoriteServerIds: Data?
|
@NSManaged var favoriteServerIds: Data?
|
||||||
|
@NSManaged var favoriteServers: Set<CDFavoriteServer>?
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,15 +29,6 @@ import Foundation
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
|
|
||||||
struct DomainMapper {
|
struct DomainMapper {
|
||||||
func excludedEndpoints(from entities: Set<CDExcludedEndpoint>?) -> Set<ExtendedEndpoint> {
|
|
||||||
entities.map {
|
|
||||||
Set($0.compactMap {
|
|
||||||
$0.endpoint.map {
|
|
||||||
ExtendedEndpoint(rawValue: $0)
|
|
||||||
} ?? nil
|
|
||||||
})
|
|
||||||
} ?? []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CoreDataMapper {
|
struct CoreDataMapper {
|
||||||
|
@ -49,7 +40,9 @@ struct CoreDataMapper {
|
||||||
return cdEndpoint
|
return cdEndpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
func cdExcludedEndpoints(from endpoints: Set<ExtendedEndpoint>) -> Set<CDExcludedEndpoint> {
|
func cdFavoriteServer(from serverId: String) -> CDFavoriteServer {
|
||||||
Set(endpoints.map(cdExcludedEndpoint(from:)))
|
let cdFavorite = CDFavoriteServer(context: context)
|
||||||
|
cdFavorite.serverId = serverId
|
||||||
|
return cdFavorite
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,10 @@
|
||||||
<attribute name="endpoint" optional="YES" attributeType="String"/>
|
<attribute name="endpoint" optional="YES" attributeType="String"/>
|
||||||
<relationship name="modulePreferences" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CDModulePreferencesV3" inverseName="excludedEndpoints" inverseEntity="CDModulePreferencesV3"/>
|
<relationship name="modulePreferences" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CDModulePreferencesV3" inverseName="excludedEndpoints" inverseEntity="CDModulePreferencesV3"/>
|
||||||
</entity>
|
</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">
|
<entity name="CDModulePreferencesV3" representedClassName="CDModulePreferencesV3" syncable="YES">
|
||||||
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="moduleId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
<attribute name="moduleId" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
|
||||||
|
@ -13,5 +17,6 @@
|
||||||
<attribute name="favoriteServerIds" optional="YES" attributeType="Binary"/>
|
<attribute name="favoriteServerIds" optional="YES" attributeType="Binary"/>
|
||||||
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||||
<attribute name="providerId" optional="YES" attributeType="String"/>
|
<attribute name="providerId" optional="YES" attributeType="String"/>
|
||||||
|
<relationship name="favoriteServers" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="CDFavoriteServer" inverseName="providerPreferences" inverseEntity="CDFavoriteServer"/>
|
||||||
</entity>
|
</entity>
|
||||||
</model>
|
</model>
|
|
@ -30,6 +30,8 @@ import Foundation
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
|
|
||||||
extension AppData {
|
extension AppData {
|
||||||
|
|
||||||
|
@MainActor
|
||||||
public static func cdModulePreferencesRepositoryV3(context: NSManagedObjectContext, moduleId: UUID) throws -> ModulePreferencesRepository {
|
public static func cdModulePreferencesRepositoryV3(context: NSManagedObjectContext, moduleId: UUID) throws -> ModulePreferencesRepository {
|
||||||
try CDModulePreferencesRepositoryV3(context: context, moduleId: moduleId)
|
try CDModulePreferencesRepositoryV3(context: context, moduleId: moduleId)
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,8 @@ import Foundation
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
|
|
||||||
extension AppData {
|
extension AppData {
|
||||||
|
|
||||||
|
@MainActor
|
||||||
public static func cdProviderPreferencesRepositoryV3(context: NSManagedObjectContext, providerId: ProviderID) throws -> ProviderPreferencesRepository {
|
public static func cdProviderPreferencesRepositoryV3(context: NSManagedObjectContext, providerId: ProviderID) throws -> ProviderPreferencesRepository {
|
||||||
try CDProviderPreferencesRepositoryV3(context: context, providerId: providerId)
|
try CDProviderPreferencesRepositoryV3(context: context, providerId: providerId)
|
||||||
}
|
}
|
||||||
|
@ -55,12 +57,26 @@ private final class CDProviderPreferencesRepositoryV3: ProviderPreferencesReposi
|
||||||
guard $0.offset > 0 else {
|
guard $0.offset > 0 else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
$0.element.favoriteServers?.forEach(context.delete(_:))
|
||||||
context.delete($0.element)
|
context.delete($0.element)
|
||||||
}
|
}
|
||||||
|
|
||||||
let entity = entities.first ?? CDProviderPreferencesV3(context: context)
|
let entity = entities.first ?? CDProviderPreferencesV3(context: context)
|
||||||
entity.providerId = providerId.rawValue
|
entity.providerId = providerId.rawValue
|
||||||
entity.lastUpdate = Date()
|
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
|
return entity
|
||||||
} catch {
|
} catch {
|
||||||
pp_log(.app, .error, "Unable to load preferences for provider \(providerId): \(error)")
|
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> {
|
func isFavoriteServer(_ serverId: String) -> Bool {
|
||||||
get {
|
context.performAndWait {
|
||||||
do {
|
entity.favoriteServers?.contains {
|
||||||
return try context.performAndWait {
|
$0.serverId == serverId
|
||||||
guard let data = entity.favoriteServerIds else {
|
} ?? false
|
||||||
return []
|
|
||||||
}
|
|
||||||
return try JSONDecoder().decode(Set<String>.self, from: data)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
pp_log(.app, .error, "Unable to get favoriteServers: \(error)")
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
set {
|
}
|
||||||
do {
|
|
||||||
try context.performAndWait {
|
func addFavoriteServer(_ serverId: String) {
|
||||||
entity.favoriteServerIds = try JSONEncoder().encode(newValue)
|
context.performAndWait {
|
||||||
}
|
guard entity.favoriteServers?.contains(where: {
|
||||||
} catch {
|
$0.serverId == serverId
|
||||||
pp_log(.app, .error, "Unable to set favoriteServers: \(error)")
|
}) != 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -126,7 +126,7 @@ private extension VPNProviderServerView {
|
||||||
var filteredServers: [VPNServer] {
|
var filteredServers: [VPNServer] {
|
||||||
if onlyShowsFavorites {
|
if onlyShowsFavorites {
|
||||||
return servers.filter {
|
return servers.filter {
|
||||||
providerPreferences.favoriteServers.contains($0.serverId)
|
providerPreferences.isFavoriteServer($0.serverId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return servers
|
return servers
|
||||||
|
|
|
@ -151,7 +151,7 @@ private extension VPNProviderServerView.ContentView {
|
||||||
Spacer()
|
Spacer()
|
||||||
FavoriteToggle(
|
FavoriteToggle(
|
||||||
value: server.serverId,
|
value: server.serverId,
|
||||||
selection: $providerPreferences.favoriteServers
|
selection: providerPreferences.favoriteServers()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -87,7 +87,7 @@ private extension VPNProviderServerView.ContentView {
|
||||||
TableColumn("") { server in
|
TableColumn("") { server in
|
||||||
FavoriteToggle(
|
FavoriteToggle(
|
||||||
value: server.serverId,
|
value: server.serverId,
|
||||||
selection: $providerPreferences.favoriteServers
|
selection: providerPreferences.favoriteServers()
|
||||||
)
|
)
|
||||||
.environmentObject(theme) // TODO: #873, Table loses environment
|
.environmentObject(theme) // TODO: #873, Table loses environment
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,8 +27,7 @@ import CommonUtils
|
||||||
import Foundation
|
import Foundation
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
|
|
||||||
@MainActor
|
public final class ModulePreferences: ObservableObject, ModulePreferencesRepository {
|
||||||
public final class ModulePreferences: ObservableObject {
|
|
||||||
private var repository: ModulePreferencesRepository?
|
private var repository: ModulePreferencesRepository?
|
||||||
|
|
||||||
public init() {
|
public init() {
|
||||||
|
@ -43,10 +42,12 @@ public final class ModulePreferences: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
public func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
|
public func addExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
|
||||||
|
objectWillChange.send()
|
||||||
repository?.addExcludedEndpoint(endpoint)
|
repository?.addExcludedEndpoint(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
|
public func removeExcludedEndpoint(_ endpoint: ExtendedEndpoint) {
|
||||||
|
objectWillChange.send()
|
||||||
repository?.removeExcludedEndpoint(endpoint)
|
repository?.removeExcludedEndpoint(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,14 +27,15 @@ import CommonUtils
|
||||||
import Foundation
|
import Foundation
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
|
|
||||||
public final class PreferencesManager: ObservableObject, Sendable {
|
@MainActor
|
||||||
private let modulesFactory: @Sendable (UUID) throws -> ModulePreferencesRepository
|
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(
|
public init(
|
||||||
modulesFactory: (@Sendable (UUID) throws -> ModulePreferencesRepository)? = nil,
|
modulesFactory: ((UUID) throws -> ModulePreferencesRepository)? = nil,
|
||||||
providersFactory: (@Sendable (ProviderID) throws -> ProviderPreferencesRepository)? = nil
|
providersFactory: ((ProviderID) throws -> ProviderPreferencesRepository)? = nil
|
||||||
) {
|
) {
|
||||||
self.modulesFactory = modulesFactory ?? { _ in
|
self.modulesFactory = modulesFactory ?? { _ in
|
||||||
DummyModulePreferencesRepository()
|
DummyModulePreferencesRepository()
|
||||||
|
@ -57,6 +58,7 @@ extension PreferencesManager {
|
||||||
|
|
||||||
// MARK: - Dummy
|
// MARK: - Dummy
|
||||||
|
|
||||||
|
@MainActor
|
||||||
private final class DummyModulePreferencesRepository: ModulePreferencesRepository {
|
private final class DummyModulePreferencesRepository: ModulePreferencesRepository {
|
||||||
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool {
|
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool {
|
||||||
false
|
false
|
||||||
|
@ -72,8 +74,17 @@ private final class DummyModulePreferencesRepository: ModulePreferencesRepositor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
private final class DummyProviderPreferencesRepository: ProviderPreferencesRepository {
|
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 {
|
func save() throws {
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,8 +27,7 @@ import CommonUtils
|
||||||
import Foundation
|
import Foundation
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
|
|
||||||
@MainActor
|
public final class ProviderPreferences: ObservableObject, ProviderPreferencesRepository {
|
||||||
public final class ProviderPreferences: ObservableObject {
|
|
||||||
private var repository: ProviderPreferencesRepository?
|
private var repository: ProviderPreferencesRepository?
|
||||||
|
|
||||||
public init() {
|
public init() {
|
||||||
|
@ -38,16 +37,30 @@ public final class ProviderPreferences: ObservableObject {
|
||||||
self.repository = repository
|
self.repository = repository
|
||||||
}
|
}
|
||||||
|
|
||||||
public var favoriteServers: Set<String> {
|
public func favoriteServers() -> ObservableList<String> {
|
||||||
get {
|
ObservableList { [weak self] in
|
||||||
repository?.favoriteServers ?? []
|
self?.isFavoriteServer($0) ?? false
|
||||||
}
|
} add: { [weak self] in
|
||||||
set {
|
self?.addFavoriteServer($0)
|
||||||
objectWillChange.send()
|
} remove: { [weak self] in
|
||||||
repository?.favoriteServers = newValue
|
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 {
|
public func save() throws {
|
||||||
try repository?.save()
|
try repository?.save()
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
public protocol ModulePreferencesRepository {
|
public protocol ModulePreferencesRepository {
|
||||||
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool
|
func isExcludedEndpoint(_ endpoint: ExtendedEndpoint) -> Bool
|
||||||
|
|
||||||
|
|
|
@ -26,8 +26,13 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
|
|
||||||
|
@MainActor
|
||||||
public protocol ProviderPreferencesRepository {
|
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
|
func save() throws
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,20 +23,21 @@
|
||||||
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import CommonUtils
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
public struct FavoriteToggle<ID>: View where ID: Hashable {
|
public struct FavoriteToggle<ID>: View where ID: Hashable {
|
||||||
private let value: ID
|
private let value: ID
|
||||||
|
|
||||||
@Binding
|
@ObservedObject
|
||||||
private var selection: Set<ID>
|
private var selection: ObservableList<ID>
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var hover: ID?
|
private var hover: ID?
|
||||||
|
|
||||||
public init(value: ID, selection: Binding<Set<ID>>) {
|
public init(value: ID, selection: ObservableList<ID>) {
|
||||||
self.value = value
|
self.value = value
|
||||||
_selection = selection
|
self.selection = selection
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some View {
|
public var body: some View {
|
||||||
|
@ -44,7 +45,7 @@ public struct FavoriteToggle<ID>: View where ID: Hashable {
|
||||||
if selection.contains(value) {
|
if selection.contains(value) {
|
||||||
selection.remove(value)
|
selection.remove(value)
|
||||||
} else {
|
} else {
|
||||||
selection.insert(value)
|
selection.add(value)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
ThemeImage(selection.contains(value) ? .favoriteOn : .favoriteOff)
|
ThemeImage(selection.contains(value) ? .favoriteOn : .favoriteOff)
|
||||||
|
|
Loading…
Reference in New Issue