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
This commit is contained in:
Davide 2024-10-28 16:57:23 +01:00 committed by GitHub
parent cc119e18ce
commit 0d383ec792
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 828 additions and 53 deletions

View File

@ -41,7 +41,7 @@
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source", "location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
"state" : { "state" : {
"revision" : "79ff98a69c87cc90ef213e00ab02c9d90d63baaf" "revision" : "69b2ed730986d684195a39b592155b3384f3d857"
} }
}, },
{ {

View File

@ -28,7 +28,7 @@ let package = Package(
], ],
dependencies: [ dependencies: [
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"), // .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(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", from: "0.9.1"),
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"), // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"),
@ -56,6 +56,17 @@ let package = Package(
.process("Profiles.xcdatamodeld") .process("Profiles.xcdatamodeld")
] ]
), ),
.target(
name: "AppDataProviders",
dependencies: [
"AppData",
"AppLibrary",
"UtilsLibrary"
],
resources: [
.process("Providers.xcdatamodeld")
]
),
.target( .target(
name: "AppLibrary", name: "AppLibrary",
dependencies: ["CommonLibrary"] dependencies: ["CommonLibrary"]
@ -64,6 +75,7 @@ let package = Package(
name: "AppUI", name: "AppUI",
dependencies: [ dependencies: [
"AppDataProfiles", "AppDataProfiles",
"AppDataProviders",
"AppLibrary", "AppLibrary",
"Kvitto", "Kvitto",
"LegacyV2", "LegacyV2",

View File

@ -28,6 +28,10 @@ import Foundation
@objc(CDProfileV3) @objc(CDProfileV3)
final class CDProfileV3: NSManagedObject { final class CDProfileV3: NSManagedObject {
@nonobjc static func fetchRequest() -> NSFetchRequest<CDProfileV3> {
NSFetchRequest<CDProfileV3>(entityName: "CDProfileV3")
}
@NSManaged var uuid: UUID? @NSManaged var uuid: UUID?
@NSManaged var name: String? @NSManaged var name: String?
@NSManaged var encoded: String? @NSManaged var encoded: String?

View File

@ -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 <http://www.gnu.org/licenses/>.
//
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
}()
}

View File

@ -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 <http://www.gnu.org/licenses/>.
//
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<CDProviderV3>
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<any NSFetchRequestResult>) {
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))
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
//
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<Configuration>(for configurationType: Configuration.Type) async throws -> VPNFilterOptions where Configuration: ProviderConfigurationIdentifiable {
try await context.perform {
let mapper = DomainMapper()
let serversRequest = NSFetchRequest<NSDictionary>(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:))
}
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import CoreData
import Foundation
@objc(CDProviderV3)
final class CDProviderV3: NSManagedObject {
@nonobjc static func fetchRequest() -> NSFetchRequest<CDProviderV3> {
NSFetchRequest<CDProviderV3>(entityName: "CDProviderV3")
}
@NSManaged var providerId: String?
@NSManaged var fullName: String?
@NSManaged var supportedConfigurationIds: String?
@NSManaged var lastUpdate: Date?
}

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import CoreData
import Foundation
@objc(CDVPNPresetV3)
final class CDVPNPresetV3: NSManagedObject {
@nonobjc static func fetchRequest() -> NSFetchRequest<CDVPNPresetV3> {
NSFetchRequest<CDVPNPresetV3>(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?
}

View File

@ -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 <http://www.gnu.org/licenses/>.
//
import CoreData
import Foundation
@objc(CDVPNServerV3)
final class CDVPNServerV3: NSManagedObject {
@nonobjc static func fetchRequest() -> NSFetchRequest<CDVPNServerV3> {
NSFetchRequest<CDVPNServerV3>(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?
}

View File

@ -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 <http://www.gnu.org/licenses/>.
//
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)
}
}

View File

@ -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 <http://www.gnu.org/licenses/>.
//
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)
}
}

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22758" systemVersion="23H124" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
<entity name="CDProviderV3" representedClassName="CDProviderV3" syncable="YES">
<attribute name="fullName" optional="YES" attributeType="String"/>
<attribute name="lastUpdate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="providerId" optional="YES" attributeType="String"/>
<attribute name="supportedConfigurationIds" optional="YES" attributeType="String"/>
</entity>
<entity name="CDVPNPresetV3" representedClassName="CDVPNPresetV3" syncable="YES">
<attribute name="configuration" optional="YES" attributeType="Binary"/>
<attribute name="configurationId" optional="YES" attributeType="String"/>
<attribute name="endpoints" optional="YES" attributeType="Binary"/>
<attribute name="presetDescription" optional="YES" attributeType="String"/>
<attribute name="presetId" optional="YES" attributeType="String"/>
<attribute name="providerId" optional="YES" attributeType="String"/>
</entity>
<entity name="CDVPNServerV3" representedClassName="CDVPNServerV3" syncable="YES">
<attribute name="area" optional="YES" attributeType="String"/>
<attribute name="categoryName" optional="YES" attributeType="String"/>
<attribute name="countryCode" optional="YES" attributeType="String"/>
<attribute name="hostname" optional="YES" attributeType="String"/>
<attribute name="ipAddresses" optional="YES" attributeType="Binary"/>
<attribute name="localizedCountry" optional="YES" attributeType="String"/>
<attribute name="otherCountryCodes" optional="YES" attributeType="String"/>
<attribute name="providerId" optional="YES" attributeType="String"/>
<attribute name="serverId" optional="YES" attributeType="String"/>
<attribute name="supportedConfigurationIds" optional="YES" attributeType="String"/>
<attribute name="supportedPresetIds" optional="YES" attributeType="String"/>
</entity>
</model>

View File

@ -31,10 +31,17 @@ extension VPNFiltersView {
@MainActor @MainActor
final class Model: ObservableObject { final class Model: ObservableObject {
typealias CodeWithDescription = (code: String, description: String)
private var options: VPNFilterOptions
@Published
private(set) var categories: [String] 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] private(set) var presets: [AnyVPNPreset]
@Published @Published
@ -47,48 +54,69 @@ extension VPNFiltersView {
let onlyShowsFavoritesDidChange = PassthroughSubject<Bool, Never>() let onlyShowsFavoritesDidChange = PassthroughSubject<Bool, Never>()
init( init() {
categories: [String] = [], options = VPNFilterOptions()
countries: [(code: String, description: String)] = [], categories = []
presets: [AnyVPNPreset] = [] countries = []
) { presets = []
self.categories = categories
self.countries = countries
self.presets = presets
} }
func load<C>( func load(options: VPNFilterOptions, initialFilters: VPNFilters?) {
with vpnManager: VPNProviderManager<C>, self.options = options
initialFilters: VPNFilters? setCategories(withNames: options.categoryNames)
) where C: ProviderConfigurationIdentifiable { setCountries(withCodes: options.countryCodes)
categories = vpnManager setPresets(with: options.presets)
.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
}
if let initialFilters { if let initialFilters {
filters = 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<String>) {
categories = categoryNames
.sorted()
}
func setCountries(withCodes codes: Set<String>) {
countries = codes
.map(\.asCountryCodeWithDescription)
.sorted {
$0.description < $1.description
}
}
func setPresets(with presets: Set<AnyVPNPreset>) {
self.presets = presets
.sorted {
$0.description < $1.description
}
}
}
private extension String {
var asCountryCodeWithDescription: VPNFiltersView.Model.CodeWithDescription {
(self, localizedAsRegionCode ?? self)
}
}

View File

@ -53,6 +53,9 @@ struct VPNFiltersView: View {
#endif #endif
} }
} }
.onChange(of: model.filters.categoryName) { _ in
model.filters.countryCode = nil
}
.onChange(of: model.filters) { .onChange(of: model.filters) {
model.filtersDidChange.send($0) model.filtersDidChange.send($0)
} }

View File

@ -63,7 +63,6 @@ struct VPNProviderServerView<Configuration>: View where Configuration: ProviderC
var body: some View { var body: some View {
debugChanges() debugChanges()
return contentView return contentView
.navigationTitle(Strings.Global.servers)
.themeNavigationDetail() .themeNavigationDetail()
.withErrorHandler(errorHandler) .withErrorHandler(errorHandler)
} }
@ -150,14 +149,12 @@ extension VPNProviderServerView {
.task { .task {
do { do {
favoritesManager.moduleId = moduleId favoritesManager.moduleId = moduleId
vpnManager.repository = try await providerManager.vpnRepository( let repository = try await providerManager.vpnServerRepository(
from: apis, from: apis,
for: providerId for: providerId
) )
filtersViewModel.load( try await vpnManager.setRepository(repository)
with: vpnManager, filtersViewModel.load(options: vpnManager.options, initialFilters: initialFilters)
initialFilters: initialFilters
)
await reloadServers(filters: filtersViewModel.filters) await reloadServers(filters: filtersViewModel.filters)
} catch { } catch {
pp_log(.app, .error, "Unable to load VPN repository: \(error)") pp_log(.app, .error, "Unable to load VPN repository: \(error)")
@ -175,11 +172,16 @@ extension VPNProviderServerView {
.onDisappear { .onDisappear {
favoritesManager.save() favoritesManager.save()
} }
.navigationTitle(title)
} }
} }
} }
private extension VPNProviderServerView.ServersView { private extension VPNProviderServerView.ServersView {
var title: String {
providerManager.provider(withId: providerId)?.description ?? Strings.Global.servers
}
var filteredServers: [VPNServer] { var filteredServers: [VPNServer] {
if onlyShowsFavorites { if onlyShowsFavorites {
return servers.filter { return servers.filter {
@ -191,10 +193,15 @@ private extension VPNProviderServerView.ServersView {
func reloadServers(filters: VPNFilters) async { func reloadServers(filters: VPNFilters) async {
isFiltering = true isFiltering = true
await Task { do {
servers = await vpnManager.filteredServers(with: filters) try await Task {
servers = try await vpnManager.filteredServers(with: filters)
filtersViewModel.update(with: servers)
isFiltering = false isFiltering = false
}.value }.value
} catch {
pp_log(.app, .error, "Unable to fetch filtered servers: \(error)")
}
} }
func compatiblePreset(with server: VPNServer) -> VPNPreset<Configuration>? { func compatiblePreset(with server: VPNServer) -> VPNPreset<Configuration>? {

View File

@ -74,6 +74,7 @@ extension VPNProviderServerView {
HStack { HStack {
ThemeCountryFlag(code: server.provider.countryCode) ThemeCountryFlag(code: server.provider.countryCode)
Text(server.region) Text(server.region)
.help(server.region)
} }
} }
@ -92,6 +93,8 @@ extension VPNProviderServerView {
onSelect(server) onSelect(server)
} label: { } label: {
Text(selectTitle) Text(selectTitle)
.lineLimit(1)
.truncationMode(.tail)
} }
} }
} }

View File

@ -32,6 +32,8 @@ public struct Constants: Decodable, Sendable {
public let remote: String public let remote: String
public let providers: String
public let legacyV2: String public let legacyV2: String
} }

View File

@ -3,6 +3,7 @@
"containers": { "containers": {
"local": "Profiles-v3", "local": "Profiles-v3",
"remote": "Profiles-v3.remote", "remote": "Profiles-v3.remote",
"providers": "Providers-v3",
"legacyV2": "Profiles" "legacyV2": "Profiles"
}, },
"websites": { "websites": {

View File

@ -4,7 +4,7 @@ import Foundation
@objc(CDProfile) @objc(CDProfile)
final class CDProfile: NSManagedObject { final class CDProfile: NSManagedObject {
@nonobjc static func fetchRequest() -> NSFetchRequest<CDProfile> { @nonobjc static func fetchRequest() -> NSFetchRequest<CDProfile> {
return NSFetchRequest<CDProfile>(entityName: "CDProfile") NSFetchRequest<CDProfile>(entityName: "CDProfile")
} }
@NSManaged var json: Data? @NSManaged var json: Data?

View File

@ -25,6 +25,7 @@
import AppData import AppData
import AppDataProfiles import AppDataProfiles
import AppDataProviders
import AppLibrary import AppLibrary
import AppUI import AppUI
import CommonLibrary import CommonLibrary
@ -226,11 +227,21 @@ private extension ProfileManager {
// MARK: - // MARK: -
// FIXME: #705, store providers to Core Data
extension ProviderManager { extension ProviderManager {
static let shared = ProviderManager( static let shared: ProviderManager = {
repository: InMemoryProviderRepository() 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: - // MARK: -