From 0d383ec792b6c6722993ee13a9860a551cd7865f Mon Sep 17 00:00:00 2001 From: Davide Date: Mon, 28 Oct 2024 16:57:23 +0100 Subject: [PATCH] Store providers to Core Data (#763) Also, improve filters by constraining related fields: - Pick countries from the filtered category - Pick presets from those available in the currently filtered servers Closes #705 --- .../xcshareddata/swiftpm/Package.resolved | 2 +- Passepartout/Library/Package.swift | 14 +- .../{ => Domain}/CDProfileV3.swift | 4 + .../AppDataProviders/AppData+Providers.swift | 39 ++++ .../CDProviderRepositoryV3.swift | 179 ++++++++++++++++++ .../CDVPNProviderServerRepositoryV3.swift | 89 +++++++++ .../Domain/CDProviderV3.swift | 39 ++++ .../Domain/CDVPNPresetV3.swift | 41 ++++ .../Domain/CDVPNServerV3.swift | 46 +++++ .../AppDataProviders/Domain/Mapper.swift | 155 +++++++++++++++ .../Domain/VPNServerParameters+CoreData.swift | 86 +++++++++ .../Providers.xcdatamodel/contents | 30 +++ .../Views/Provider/VPNFiltersView+Model.swift | 100 ++++++---- .../AppUI/Views/Provider/VPNFiltersView.swift | 3 + .../Provider/VPNProviderServerView.swift | 27 ++- .../macOS/VPNProviderServerView+macOS.swift | 3 + .../CommonLibrary/Domain/Constants.swift | 2 + .../CommonLibrary/Resources/Constants.json | 1 + .../Library/Sources/LegacyV2/CDProfile.swift | 2 +- Passepartout/Shared/Shared+App.swift | 19 +- 20 files changed, 828 insertions(+), 53 deletions(-) rename Passepartout/Library/Sources/AppDataProfiles/{ => Domain}/CDProfileV3.swift (88%) create mode 100644 Passepartout/Library/Sources/AppDataProviders/AppData+Providers.swift create mode 100644 Passepartout/Library/Sources/AppDataProviders/CDProviderRepositoryV3.swift create mode 100644 Passepartout/Library/Sources/AppDataProviders/CDVPNProviderServerRepositoryV3.swift create mode 100644 Passepartout/Library/Sources/AppDataProviders/Domain/CDProviderV3.swift create mode 100644 Passepartout/Library/Sources/AppDataProviders/Domain/CDVPNPresetV3.swift create mode 100644 Passepartout/Library/Sources/AppDataProviders/Domain/CDVPNServerV3.swift create mode 100644 Passepartout/Library/Sources/AppDataProviders/Domain/Mapper.swift create mode 100644 Passepartout/Library/Sources/AppDataProviders/Domain/VPNServerParameters+CoreData.swift create mode 100644 Passepartout/Library/Sources/AppDataProviders/Providers.xcdatamodeld/Providers.xcdatamodel/contents diff --git a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f04d177e..f47a001a 100644 --- a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -41,7 +41,7 @@ "kind" : "remoteSourceControl", "location" : "git@github.com:passepartoutvpn/passepartoutkit-source", "state" : { - "revision" : "79ff98a69c87cc90ef213e00ab02c9d90d63baaf" + "revision" : "69b2ed730986d684195a39b592155b3384f3d857" } }, { diff --git a/Passepartout/Library/Package.swift b/Passepartout/Library/Package.swift index 5d7d5a36..93c0a4f1 100644 --- a/Passepartout/Library/Package.swift +++ b/Passepartout/Library/Package.swift @@ -28,7 +28,7 @@ let package = Package( ], dependencies: [ // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"), - .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "79ff98a69c87cc90ef213e00ab02c9d90d63baaf"), + .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "69b2ed730986d684195a39b592155b3384f3d857"), // .package(path: "../../../passepartoutkit-source"), .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", from: "0.9.1"), // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"), @@ -56,6 +56,17 @@ let package = Package( .process("Profiles.xcdatamodeld") ] ), + .target( + name: "AppDataProviders", + dependencies: [ + "AppData", + "AppLibrary", + "UtilsLibrary" + ], + resources: [ + .process("Providers.xcdatamodeld") + ] + ), .target( name: "AppLibrary", dependencies: ["CommonLibrary"] @@ -64,6 +75,7 @@ let package = Package( name: "AppUI", dependencies: [ "AppDataProfiles", + "AppDataProviders", "AppLibrary", "Kvitto", "LegacyV2", diff --git a/Passepartout/Library/Sources/AppDataProfiles/CDProfileV3.swift b/Passepartout/Library/Sources/AppDataProfiles/Domain/CDProfileV3.swift similarity index 88% rename from Passepartout/Library/Sources/AppDataProfiles/CDProfileV3.swift rename to Passepartout/Library/Sources/AppDataProfiles/Domain/CDProfileV3.swift index 05bb6f31..9a039224 100644 --- a/Passepartout/Library/Sources/AppDataProfiles/CDProfileV3.swift +++ b/Passepartout/Library/Sources/AppDataProfiles/Domain/CDProfileV3.swift @@ -28,6 +28,10 @@ import Foundation @objc(CDProfileV3) final class CDProfileV3: NSManagedObject { + @nonobjc static func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "CDProfileV3") + } + @NSManaged var uuid: UUID? @NSManaged var name: String? @NSManaged var encoded: String? diff --git a/Passepartout/Library/Sources/AppDataProviders/AppData+Providers.swift b/Passepartout/Library/Sources/AppDataProviders/AppData+Providers.swift new file mode 100644 index 00000000..92079692 --- /dev/null +++ b/Passepartout/Library/Sources/AppDataProviders/AppData+Providers.swift @@ -0,0 +1,39 @@ +// +// AppData+Providers.swift +// Passepartout +// +// Created by Davide De Rosa on 10/26/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 AppData +import CoreData +import Foundation + +extension AppData { + + @MainActor + public static let cdProvidersModel: NSManagedObjectModel = { + guard let model: NSManagedObjectModel = .mergedModel(from: [.module]) else { + fatalError("Unable to build Core Data model (Providers v3)") + } + return model + }() +} diff --git a/Passepartout/Library/Sources/AppDataProviders/CDProviderRepositoryV3.swift b/Passepartout/Library/Sources/AppDataProviders/CDProviderRepositoryV3.swift new file mode 100644 index 00000000..707cb709 --- /dev/null +++ b/Passepartout/Library/Sources/AppDataProviders/CDProviderRepositoryV3.swift @@ -0,0 +1,179 @@ +// +// CDProviderRepositoryV3.swift +// Passepartout +// +// Created by Davide De Rosa on 10/26/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 AppData +import Combine +import CoreData +import Foundation +import PassepartoutKit +import UtilsLibrary + +extension AppData { + public static func cdProviderRepositoryV3( + context: NSManagedObjectContext, + backgroundContext: NSManagedObjectContext + ) -> ProviderRepository { + CDProviderRepositoryV3(context: context, backgroundContext: backgroundContext) + } +} + +actor CDProviderRepositoryV3: NSObject, ProviderRepository { + private let context: NSManagedObjectContext + + private let backgroundContext: NSManagedObjectContext + + private let providersSubject: CurrentValueSubject<[ProviderMetadata], Never> + + private let lastUpdatedSubject: CurrentValueSubject<[ProviderID: Date], Never> + + private let providersController: NSFetchedResultsController + + init(context: NSManagedObjectContext, backgroundContext: NSManagedObjectContext) { + self.context = context + self.backgroundContext = backgroundContext + providersSubject = CurrentValueSubject([]) + lastUpdatedSubject = CurrentValueSubject([:]) + + let request = CDProviderV3.fetchRequest() + request.sortDescriptors = [ + NSSortDescriptor(key: "providerId", ascending: true) + ] + providersController = .init( + fetchRequest: request, + managedObjectContext: context, + sectionNameKeyPath: nil, + cacheName: nil + ) + + super.init() + + Task { + try await context.perform { [weak self] in + self?.providersController.delegate = self + try self?.providersController.performFetch() + } + } + } + + nonisolated var indexPublisher: AnyPublisher<[ProviderMetadata], Never> { + providersSubject + .removeDuplicates() + .eraseToAnyPublisher() + } + + nonisolated var lastUpdatedPublisher: AnyPublisher<[ProviderID: Date], Never> { + lastUpdatedSubject + .removeDuplicates() + .eraseToAnyPublisher() + } + + func store(_ index: [ProviderMetadata]) async throws { + try await backgroundContext.perform { [weak self] in + guard let self else { + return + } + do { + // fetch existing for last update and deletion + let request = CDProviderV3.fetchRequest() + let results = try request.execute() + let lastUpdatesByProvider = results.reduce(into: [:]) { + $0[$1.providerId] = $1.lastUpdate + } + results.forEach(backgroundContext.delete) + + // replace but retain last update + let mapper = CoreDataMapper(context: backgroundContext) + index.forEach { + let lastUpdate = lastUpdatesByProvider[$0.id.rawValue] + mapper.cdProvider(from: $0, lastUpdate: lastUpdate) + } + + try backgroundContext.save() + } catch { + backgroundContext.rollback() + throw error + } + } + } + + func store(_ infrastructure: VPNInfrastructure, for providerId: ProviderID) async throws { + try await backgroundContext.perform { [weak self] in + guard let self else { + return + } + do { + let predicate = providerId.predicate + + // signal update of related provider + let providerRequest = CDProviderV3.fetchRequest() + providerRequest.predicate = predicate + let providers = try providerRequest.execute() + if let provider = providers.first { + provider.lastUpdate = infrastructure.lastUpdated + } + + // delete all provider entities + let serverRequest = CDVPNServerV3.fetchRequest() + serverRequest.predicate = predicate + let servers = try serverRequest.execute() + servers.forEach(backgroundContext.delete) + + let presetRequest = CDVPNPresetV3.fetchRequest() + presetRequest.predicate = predicate + let presets = try presetRequest.execute() + presets.forEach(backgroundContext.delete) + + // create new entities + let mapper = CoreDataMapper(context: backgroundContext) + try infrastructure.servers.forEach { + try mapper.cdServer(from: $0) + } + try infrastructure.presets.forEach { + try mapper.cdPreset(from: $0) + } + + try backgroundContext.save() + } catch { + backgroundContext.rollback() + throw error + } + } + } + + nonisolated func vpnServerRepository(for providerId: ProviderID) -> VPNProviderServerRepository { + CDVPNProviderServerRepositoryV3(context: context, providerId: providerId) + } +} + +extension CDProviderRepositoryV3: NSFetchedResultsControllerDelegate { + nonisolated func controllerDidChangeContent(_ controller: NSFetchedResultsController) { + guard let entities = controller.fetchedObjects as? [CDProviderV3] else { + return + } + let mapper = DomainMapper() + providersSubject.send(entities.compactMap(mapper.provider(from:))) + lastUpdatedSubject.send(mapper.lastUpdated(from: entities)) + } +} diff --git a/Passepartout/Library/Sources/AppDataProviders/CDVPNProviderServerRepositoryV3.swift b/Passepartout/Library/Sources/AppDataProviders/CDVPNProviderServerRepositoryV3.swift new file mode 100644 index 00000000..6e64d88d --- /dev/null +++ b/Passepartout/Library/Sources/AppDataProviders/CDVPNProviderServerRepositoryV3.swift @@ -0,0 +1,89 @@ +// +// CDVPNProviderServerRepositoryV3.swift +// Passepartout +// +// Created by Davide De Rosa on 10/26/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 AppData +import CoreData +import Foundation +import PassepartoutKit +import UtilsLibrary + +final class CDVPNProviderServerRepositoryV3: VPNProviderServerRepository { + private let context: NSManagedObjectContext + + let providerId: ProviderID + + init(context: NSManagedObjectContext, providerId: ProviderID) { + self.context = context + self.providerId = providerId + } + + func availableOptions(for configurationType: Configuration.Type) async throws -> VPNFilterOptions where Configuration: ProviderConfigurationIdentifiable { + try await context.perform { + let mapper = DomainMapper() + + let serversRequest = NSFetchRequest(entityName: "CDVPNServerV3") + serversRequest.predicate = self.providerId.predicate + serversRequest.resultType = .dictionaryResultType + serversRequest.returnsDistinctResults = true + serversRequest.propertiesToFetch = [ + "categoryName", + "countryCode" + ] + let serversResults = try serversRequest.execute() + let categoryNames = serversResults.compactMap { + $0.object(forKey: "categoryName") as? String + } + let countryCodes = serversResults.compactMap { + $0.object(forKey: "countryCode") as? String + } + + let presetsRequest = CDVPNPresetV3.fetchRequest() + presetsRequest.predicate = NSPredicate( + format: "providerId == %@ AND configurationId == %@", self.providerId.rawValue, + Configuration.providerConfigurationIdentifier + ) + let presetsResults = try presetsRequest.execute() + + return VPNFilterOptions( + categoryNames: Set(categoryNames), + countryCodes: Set(countryCodes), + presets: Set(try presetsResults.compactMap { + try mapper.preset(from: $0) + }) + ) + } + } + + func filteredServers(with parameters: VPNServerParameters?) async throws -> [VPNServer] { + try await context.perform { + let request = CDVPNServerV3.fetchRequest() + request.sortDescriptors = parameters?.sorting.map(\.sortDescriptor) + request.predicate = parameters?.filters.predicate(for: self.providerId) + let results = try request.execute() + let mapper = DomainMapper() + return try results.compactMap(mapper.server(from:)) + } + } +} diff --git a/Passepartout/Library/Sources/AppDataProviders/Domain/CDProviderV3.swift b/Passepartout/Library/Sources/AppDataProviders/Domain/CDProviderV3.swift new file mode 100644 index 00000000..665d7142 --- /dev/null +++ b/Passepartout/Library/Sources/AppDataProviders/Domain/CDProviderV3.swift @@ -0,0 +1,39 @@ +// +// CDProviderV3.swift +// Passepartout +// +// Created by Davide De Rosa on 10/26/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(CDProviderV3) +final class CDProviderV3: NSManagedObject { + @nonobjc static func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "CDProviderV3") + } + + @NSManaged var providerId: String? + @NSManaged var fullName: String? + @NSManaged var supportedConfigurationIds: String? + @NSManaged var lastUpdate: Date? +} diff --git a/Passepartout/Library/Sources/AppDataProviders/Domain/CDVPNPresetV3.swift b/Passepartout/Library/Sources/AppDataProviders/Domain/CDVPNPresetV3.swift new file mode 100644 index 00000000..3a298b7c --- /dev/null +++ b/Passepartout/Library/Sources/AppDataProviders/Domain/CDVPNPresetV3.swift @@ -0,0 +1,41 @@ +// +// CDVPNPresetV3.swift +// Passepartout +// +// Created by Davide De Rosa on 10/26/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(CDVPNPresetV3) +final class CDVPNPresetV3: NSManagedObject { + @nonobjc static func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "CDVPNPresetV3") + } + + @NSManaged var presetId: String? + @NSManaged var presetDescription: String? + @NSManaged var providerId: String? + @NSManaged var endpoints: Data? + @NSManaged var configurationId: String? + @NSManaged var configuration: Data? +} diff --git a/Passepartout/Library/Sources/AppDataProviders/Domain/CDVPNServerV3.swift b/Passepartout/Library/Sources/AppDataProviders/Domain/CDVPNServerV3.swift new file mode 100644 index 00000000..83a9eaec --- /dev/null +++ b/Passepartout/Library/Sources/AppDataProviders/Domain/CDVPNServerV3.swift @@ -0,0 +1,46 @@ +// +// CDVPNServerV3.swift +// Passepartout +// +// Created by Davide De Rosa on 10/26/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(CDVPNServerV3) +final class CDVPNServerV3: NSManagedObject { + @nonobjc static func fetchRequest() -> NSFetchRequest { + NSFetchRequest(entityName: "CDVPNServerV3") + } + + @NSManaged var serverId: String? + @NSManaged var hostname: String? + @NSManaged var ipAddresses: Data? + @NSManaged var providerId: String? + @NSManaged var countryCode: String? + @NSManaged var supportedConfigurationIds: String? + @NSManaged var supportedPresetIds: String? + @NSManaged var categoryName: String? + @NSManaged var localizedCountry: String? + @NSManaged var otherCountryCodes: String? + @NSManaged var area: String? +} diff --git a/Passepartout/Library/Sources/AppDataProviders/Domain/Mapper.swift b/Passepartout/Library/Sources/AppDataProviders/Domain/Mapper.swift new file mode 100644 index 00000000..de5d2ffb --- /dev/null +++ b/Passepartout/Library/Sources/AppDataProviders/Domain/Mapper.swift @@ -0,0 +1,155 @@ +// +// Mapper.swift +// Passepartout +// +// Created by Davide De Rosa on 10/28/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 +import PassepartoutKit + +struct CoreDataMapper { + let context: NSManagedObjectContext + + @discardableResult + func cdProvider(from metadata: ProviderMetadata, lastUpdate: Date?) -> CDProviderV3 { + let entity = CDProviderV3(context: context) + entity.providerId = metadata.id.rawValue + entity.fullName = metadata.description + entity.supportedConfigurationIds = metadata.supportedConfigurationIdentifiers.joined(separator: ",") + entity.lastUpdate = lastUpdate + return entity + } + + @discardableResult + func cdServer(from server: VPNServer) throws -> CDVPNServerV3 { + let entity = CDVPNServerV3(context: context) + let encoder = JSONEncoder() + entity.serverId = server.serverId + entity.hostname = server.hostname + entity.ipAddresses = try server.ipAddresses.map { + try encoder.encode($0) + } + entity.providerId = server.provider.id.rawValue + entity.countryCode = server.provider.countryCode + entity.categoryName = server.provider.categoryName + entity.localizedCountry = server.provider.countryCode.localizedAsRegionCode + entity.otherCountryCodes = server.provider.otherCountryCodes?.joined(separator: ",") + entity.area = server.provider.area + entity.supportedConfigurationIds = server.provider.supportedConfigurationIdentifiers?.joined(separator: ",") + entity.supportedPresetIds = server.provider.supportedPresetIds?.joined(separator: ",") + return entity + } + + @discardableResult + func cdPreset(from preset: AnyVPNPreset) throws -> CDVPNPresetV3 { + let entity = CDVPNPresetV3(context: self.context) + let encoder = JSONEncoder() + entity.presetId = preset.presetId + entity.providerId = preset.providerId.rawValue + entity.presetDescription = preset.description + entity.endpoints = try encoder.encode(preset.endpoints) + entity.configurationId = preset.configurationIdentifier + entity.configuration = preset.configuration + return entity + } +} + +struct DomainMapper { + func provider(from entity: CDProviderV3) -> ProviderMetadata? { + guard let id = entity.providerId, + let fullName = entity.fullName, + let supportedConfigurationIds = entity.supportedConfigurationIds else { + return nil + } + return ProviderMetadata( + id, + description: fullName, + supportedConfigurationIdentifiers: Set(supportedConfigurationIds.components(separatedBy: ",")) + ) + } + + func lastUpdated(from entities: [CDProviderV3]) -> [ProviderID: Date] { + entities.reduce(into: [:]) { + guard let id = $1.providerId, + let lastUpdate = $1.lastUpdate else { + return + } + $0[.init(rawValue: id)] = lastUpdate + } + } + + func preset(from entity: CDVPNPresetV3) throws -> AnyVPNPreset? { + guard let presetId = entity.presetId, + let presetDescription = entity.presetDescription, + let providerId = entity.providerId, + let configurationId = entity.configurationId, + let configuration = entity.configuration else { + return nil + } + + let decoder = JSONDecoder() + let endpoints = try entity.endpoints.map { + try decoder.decode([EndpointProtocol].self, from: $0) + } ?? [] + + return AnyVPNPreset( + providerId: .init(rawValue: providerId), + presetId: presetId, + description: presetDescription, + endpoints: endpoints, + configurationIdentifier: configurationId, + configuration: configuration + ) + } + + func server(from entity: CDVPNServerV3) throws -> VPNServer? { + guard let serverId = entity.serverId, + let providerId = entity.providerId, + let categoryName = entity.categoryName, + let countryCode = entity.countryCode else { + return nil + } + + let decoder = JSONDecoder() + let hostname = entity.hostname + let ipAddresses = try entity.ipAddresses.map { + Set(try decoder.decode([Data].self, from: $0)) + } + let supportedConfigurationIds = entity.supportedConfigurationIds?.components(separatedBy: ",") + let supportedPresetIds = entity.supportedPresetIds?.components(separatedBy: ",") + let otherCountryCodes = entity.otherCountryCodes?.components(separatedBy: ",") + let area = entity.area + + let provider = VPNServer.Provider( + id: .init(rawValue: providerId), + serverId: serverId, + supportedConfigurationIdentifiers: supportedConfigurationIds, + supportedPresetIds: supportedPresetIds, + categoryName: categoryName, + countryCode: countryCode, + otherCountryCodes: otherCountryCodes, + area: area + ) + return VPNServer(provider: provider, hostname: hostname, ipAddresses: ipAddresses) + } +} diff --git a/Passepartout/Library/Sources/AppDataProviders/Domain/VPNServerParameters+CoreData.swift b/Passepartout/Library/Sources/AppDataProviders/Domain/VPNServerParameters+CoreData.swift new file mode 100644 index 00000000..5fbc030a --- /dev/null +++ b/Passepartout/Library/Sources/AppDataProviders/Domain/VPNServerParameters+CoreData.swift @@ -0,0 +1,86 @@ +// +// VPNServerParameters+CoreData.swift +// Passepartout +// +// Created by Davide De Rosa on 10/28/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 + +extension ProviderID { + var predicate: NSPredicate { + NSPredicate(format: predicateFormat, predicateArg) + } + + fileprivate var predicateFormat: String { + "providerId == %@" + } + + fileprivate var predicateArg: String { + rawValue + } +} + +extension VPNSortField { + var sortDescriptor: NSSortDescriptor { + switch self { + case .localizedCountry: + return NSSortDescriptor(key: "localizedCountry", ascending: true) + + case .area: + return NSSortDescriptor(key: "area", ascending: true) + + case .hostname: + return NSSortDescriptor(key: "hostname", ascending: true) + } + } +} + +extension VPNFilters { + func predicate(for providerId: ProviderID) -> NSPredicate { + var formats: [String] = [] + var args: [Any] = [] + + formats.append(providerId.predicateFormat) + args.append(providerId.rawValue) + + if let configurationIdentifier { + formats.append("supportedConfigurationIds contains %@") + args.append(configurationIdentifier) + } + if let presetId { + formats.append("supportedPresetIds contains %@") + args.append(presetId) + } + if let categoryName { + formats.append("categoryName == %@") + args.append(categoryName) + } + if let countryCode { + formats.append("countryCode == %@") + args.append(countryCode) + } + + let format = formats.joined(separator: " AND ") + return NSPredicate(format: format, argumentArray: args) + } +} diff --git a/Passepartout/Library/Sources/AppDataProviders/Providers.xcdatamodeld/Providers.xcdatamodel/contents b/Passepartout/Library/Sources/AppDataProviders/Providers.xcdatamodeld/Providers.xcdatamodel/contents new file mode 100644 index 00000000..463a14ab --- /dev/null +++ b/Passepartout/Library/Sources/AppDataProviders/Providers.xcdatamodeld/Providers.xcdatamodel/contents @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView+Model.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView+Model.swift index ae25d3a9..9265fc05 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView+Model.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView+Model.swift @@ -31,10 +31,17 @@ extension VPNFiltersView { @MainActor final class Model: ObservableObject { + typealias CodeWithDescription = (code: String, description: String) + + private var options: VPNFilterOptions + + @Published private(set) var categories: [String] - private(set) var countries: [(code: String, description: String)] + @Published + private(set) var countries: [CodeWithDescription] + @Published private(set) var presets: [AnyVPNPreset] @Published @@ -47,48 +54,69 @@ extension VPNFiltersView { let onlyShowsFavoritesDidChange = PassthroughSubject() - init( - categories: [String] = [], - countries: [(code: String, description: String)] = [], - presets: [AnyVPNPreset] = [] - ) { - self.categories = categories - self.countries = countries - self.presets = presets + init() { + options = VPNFilterOptions() + categories = [] + countries = [] + presets = [] } - func load( - with vpnManager: VPNProviderManager, - initialFilters: VPNFilters? - ) where C: ProviderConfigurationIdentifiable { - categories = vpnManager - .allCategoryNames - .sorted() - - countries = vpnManager - .allCountryCodes - .map { - (code: $0, description: $0.localizedAsRegionCode ?? $0) - } - .sorted { - $0.description < $1.description - } - - presets = vpnManager - .allPresets - .values - .filter { - $0.configurationIdentifier == C.providerConfigurationIdentifier - } - .sorted { - $0.description < $1.description - } + func load(options: VPNFilterOptions, initialFilters: VPNFilters?) { + self.options = options + setCategories(withNames: options.categoryNames) + setCountries(withCodes: options.countryCodes) + setPresets(with: options.presets) if let initialFilters { filters = initialFilters } + } - objectWillChange.send() + func update(with servers: [VPNServer]) { + + // only non-empty countries + let knownCountryCodes = Set(servers.map(\.provider.countryCode)) + + // only presets known in filtered servers + var knownPresets = options.presets + let allPresetIds = Set(servers.compactMap(\.provider.supportedPresetIds).joined()) + if !allPresetIds.isEmpty { + knownPresets = knownPresets + .filter { + allPresetIds.contains($0.presetId) + } + } + + setCountries(withCodes: knownCountryCodes) + setPresets(with: knownPresets) } } } + +private extension VPNFiltersView.Model { + func setCategories(withNames categoryNames: Set) { + categories = categoryNames + .sorted() + } + + func setCountries(withCodes codes: Set) { + countries = codes + .map(\.asCountryCodeWithDescription) + .sorted { + $0.description < $1.description + } + } + + func setPresets(with presets: Set) { + self.presets = presets + .sorted { + $0.description < $1.description + } + } +} + +private extension String { + var asCountryCodeWithDescription: VPNFiltersView.Model.CodeWithDescription { + (self, localizedAsRegionCode ?? self) + } +} diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView.swift index cad1ec07..a2933528 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNFiltersView.swift @@ -53,6 +53,9 @@ struct VPNFiltersView: View { #endif } } + .onChange(of: model.filters.categoryName) { _ in + model.filters.countryCode = nil + } .onChange(of: model.filters) { model.filtersDidChange.send($0) } diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift index e8741369..dd2d1205 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift @@ -63,7 +63,6 @@ struct VPNProviderServerView: View where Configuration: ProviderC var body: some View { debugChanges() return contentView - .navigationTitle(Strings.Global.servers) .themeNavigationDetail() .withErrorHandler(errorHandler) } @@ -150,14 +149,12 @@ extension VPNProviderServerView { .task { do { favoritesManager.moduleId = moduleId - vpnManager.repository = try await providerManager.vpnRepository( + let repository = try await providerManager.vpnServerRepository( from: apis, for: providerId ) - filtersViewModel.load( - with: vpnManager, - initialFilters: initialFilters - ) + try await vpnManager.setRepository(repository) + filtersViewModel.load(options: vpnManager.options, initialFilters: initialFilters) await reloadServers(filters: filtersViewModel.filters) } catch { pp_log(.app, .error, "Unable to load VPN repository: \(error)") @@ -175,11 +172,16 @@ extension VPNProviderServerView { .onDisappear { favoritesManager.save() } + .navigationTitle(title) } } } private extension VPNProviderServerView.ServersView { + var title: String { + providerManager.provider(withId: providerId)?.description ?? Strings.Global.servers + } + var filteredServers: [VPNServer] { if onlyShowsFavorites { return servers.filter { @@ -191,10 +193,15 @@ private extension VPNProviderServerView.ServersView { func reloadServers(filters: VPNFilters) async { isFiltering = true - await Task { - servers = await vpnManager.filteredServers(with: filters) - isFiltering = false - }.value + do { + try await Task { + servers = try await vpnManager.filteredServers(with: filters) + filtersViewModel.update(with: servers) + isFiltering = false + }.value + } catch { + pp_log(.app, .error, "Unable to fetch filtered servers: \(error)") + } } func compatiblePreset(with server: VPNServer) -> VPNPreset? { diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNProviderServerView+macOS.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNProviderServerView+macOS.swift index 39bfe9da..b6cbd106 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNProviderServerView+macOS.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/macOS/VPNProviderServerView+macOS.swift @@ -74,6 +74,7 @@ extension VPNProviderServerView { HStack { ThemeCountryFlag(code: server.provider.countryCode) Text(server.region) + .help(server.region) } } @@ -92,6 +93,8 @@ extension VPNProviderServerView { onSelect(server) } label: { Text(selectTitle) + .lineLimit(1) + .truncationMode(.tail) } } } diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift index 7e71bc42..090e8325 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift @@ -32,6 +32,8 @@ public struct Constants: Decodable, Sendable { public let remote: String + public let providers: String + public let legacyV2: String } diff --git a/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json b/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json index 6bac93a2..3def3bdc 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json +++ b/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json @@ -3,6 +3,7 @@ "containers": { "local": "Profiles-v3", "remote": "Profiles-v3.remote", + "providers": "Providers-v3", "legacyV2": "Profiles" }, "websites": { diff --git a/Passepartout/Library/Sources/LegacyV2/CDProfile.swift b/Passepartout/Library/Sources/LegacyV2/CDProfile.swift index 781f3bdd..66fb1a1f 100644 --- a/Passepartout/Library/Sources/LegacyV2/CDProfile.swift +++ b/Passepartout/Library/Sources/LegacyV2/CDProfile.swift @@ -4,7 +4,7 @@ import Foundation @objc(CDProfile) final class CDProfile: NSManagedObject { @nonobjc static func fetchRequest() -> NSFetchRequest { - return NSFetchRequest(entityName: "CDProfile") + NSFetchRequest(entityName: "CDProfile") } @NSManaged var json: Data? diff --git a/Passepartout/Shared/Shared+App.swift b/Passepartout/Shared/Shared+App.swift index 09a278c3..0a20a6d1 100644 --- a/Passepartout/Shared/Shared+App.swift +++ b/Passepartout/Shared/Shared+App.swift @@ -25,6 +25,7 @@ import AppData import AppDataProfiles +import AppDataProviders import AppLibrary import AppUI import CommonLibrary @@ -226,11 +227,21 @@ private extension ProfileManager { // MARK: - -// FIXME: #705, store providers to Core Data extension ProviderManager { - static let shared = ProviderManager( - repository: InMemoryProviderRepository() - ) + static let shared: ProviderManager = { + let store = CoreDataPersistentStore( + logger: .default, + containerName: Constants.shared.containers.providers, + model: AppData.cdProvidersModel, + cloudKitIdentifier: nil, + author: nil + ) + let repository = AppData.cdProviderRepositoryV3( + context: store.context, + backgroundContext: store.backgroundContext + ) + return ProviderManager(repository: repository) + }() } // MARK: -