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:
parent
cc119e18ce
commit
0d383ec792
|
@ -41,7 +41,7 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||
"state" : {
|
||||
"revision" : "79ff98a69c87cc90ef213e00ab02c9d90d63baaf"
|
||||
"revision" : "69b2ed730986d684195a39b592155b3384f3d857"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -28,6 +28,10 @@ import Foundation
|
|||
|
||||
@objc(CDProfileV3)
|
||||
final class CDProfileV3: NSManagedObject {
|
||||
@nonobjc static func fetchRequest() -> NSFetchRequest<CDProfileV3> {
|
||||
NSFetchRequest<CDProfileV3>(entityName: "CDProfileV3")
|
||||
}
|
||||
|
||||
@NSManaged var uuid: UUID?
|
||||
@NSManaged var name: String?
|
||||
@NSManaged var encoded: String?
|
|
@ -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
|
||||
}()
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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:))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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?
|
||||
}
|
|
@ -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?
|
||||
}
|
|
@ -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?
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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<Bool, Never>()
|
||||
|
||||
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<C>(
|
||||
with vpnManager: VPNProviderManager<C>,
|
||||
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<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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -63,7 +63,6 @@ struct VPNProviderServerView<Configuration>: 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<Configuration>? {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,6 +32,8 @@ public struct Constants: Decodable, Sendable {
|
|||
|
||||
public let remote: String
|
||||
|
||||
public let providers: String
|
||||
|
||||
public let legacyV2: String
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
"containers": {
|
||||
"local": "Profiles-v3",
|
||||
"remote": "Profiles-v3.remote",
|
||||
"providers": "Providers-v3",
|
||||
"legacyV2": "Profiles"
|
||||
},
|
||||
"websites": {
|
||||
|
|
|
@ -4,7 +4,7 @@ import Foundation
|
|||
@objc(CDProfile)
|
||||
final class CDProfile: NSManagedObject {
|
||||
@nonobjc static func fetchRequest() -> NSFetchRequest<CDProfile> {
|
||||
return NSFetchRequest<CDProfile>(entityName: "CDProfile")
|
||||
NSFetchRequest<CDProfile>(entityName: "CDProfile")
|
||||
}
|
||||
|
||||
@NSManaged var json: Data?
|
||||
|
|
|
@ -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: -
|
||||
|
|
Loading…
Reference in New Issue