Reuse provider entities for more than VPN (#1107)

Rename entities/views, and decouple provider templates from built
configurations. Reuse ProviderServerCoordinator.

This is in preparation for #507
This commit is contained in:
Davide 2025-01-29 12:53:23 +01:00 committed by GitHub
parent cb93ee83ea
commit 8d269e7113
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 764 additions and 793 deletions

View File

@ -32,9 +32,9 @@ final class CDVPNPresetV3: NSManagedObject {
NSFetchRequest<CDVPNPresetV3>(entityName: "CDVPNPresetV3")
}
@NSManaged var providerId: String?
@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

@ -42,7 +42,7 @@ struct CoreDataMapper {
}
@discardableResult
func cdServer(from server: VPNServer) throws -> CDVPNServerV3 {
func cdServer(from server: ProviderServer) throws -> CDVPNServerV3 {
let entity = CDVPNServerV3(context: context)
let encoder = JSONEncoder()
entity.serverId = server.serverId
@ -50,27 +50,27 @@ struct CoreDataMapper {
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: ",")
entity.providerId = server.metadata.providerId.rawValue
entity.countryCode = server.metadata.countryCode
entity.categoryName = server.metadata.categoryName
entity.localizedCountry = server.metadata.countryCode.localizedAsRegionCode
entity.otherCountryCodes = server.metadata.otherCountryCodes?.joined(separator: ",")
entity.area = server.metadata.area
entity.supportedConfigurationIds = server.metadata.supportedConfigurationIdentifiers?.joined(separator: ",")
entity.supportedPresetIds = server.metadata.supportedPresetIds?.joined(separator: ",")
return entity
}
@discardableResult
func cdPreset(from preset: AnyVPNPreset) throws -> CDVPNPresetV3 {
func cdPreset(from preset: AnyProviderPreset) throws -> CDVPNPresetV3 {
let entity = CDVPNPresetV3(context: self.context)
let encoder = JSONEncoder()
entity.presetId = preset.presetId
entity.providerId = preset.providerId.rawValue
entity.presetId = preset.presetId
entity.presetDescription = preset.description
entity.endpoints = try encoder.encode(preset.endpoints)
entity.configurationId = preset.configurationIdentifier
entity.configuration = preset.configuration
entity.configuration = preset.template
return entity
}
}

View File

@ -63,12 +63,12 @@ struct DomainMapper {
}
}
func preset(from entity: CDVPNPresetV3) throws -> AnyVPNPreset? {
func preset(from entity: CDVPNPresetV3) throws -> AnyProviderPreset? {
guard let presetId = entity.presetId,
let presetDescription = entity.presetDescription,
let providerId = entity.providerId,
let configurationId = entity.configurationId,
let configuration = entity.configuration else {
let template = entity.configuration else {
return nil
}
@ -77,17 +77,17 @@ struct DomainMapper {
try decoder.decode([EndpointProtocol].self, from: $0)
} ?? []
return AnyVPNPreset(
return AnyProviderPreset(
providerId: .init(rawValue: providerId),
presetId: presetId,
description: presetDescription,
endpoints: endpoints,
configurationIdentifier: configurationId,
configuration: configuration
template: template
)
}
func server(from entity: CDVPNServerV3) throws -> VPNServer? {
func server(from entity: CDVPNServerV3) throws -> ProviderServer? {
guard let serverId = entity.serverId,
let providerId = entity.providerId,
let categoryName = entity.categoryName,
@ -105,8 +105,8 @@ struct DomainMapper {
let otherCountryCodes = entity.otherCountryCodes?.components(separatedBy: ",")
let area = entity.area
let provider = VPNServer.Provider(
id: .init(rawValue: providerId),
let metadata = ProviderServer.Metadata(
providerId: .init(rawValue: providerId),
serverId: serverId,
supportedConfigurationIdentifiers: supportedConfigurationIds,
supportedPresetIds: supportedPresetIds,
@ -115,6 +115,6 @@ struct DomainMapper {
otherCountryCodes: otherCountryCodes,
area: area
)
return VPNServer(provider: provider, hostname: hostname, ipAddresses: ipAddresses)
return ProviderServer(metadata: metadata, hostname: hostname, ipAddresses: ipAddresses)
}
}

View File

@ -1,5 +1,5 @@
//
// VPNServerParameters+CoreData.swift
// ProviderServerParameters+CoreData.swift
// Passepartout
//
// Created by Davide De Rosa on 10/28/24.
@ -40,7 +40,7 @@ extension ProviderID {
}
}
extension VPNSortField {
extension ProviderSortField {
var sortDescriptor: NSSortDescriptor {
switch self {
case .localizedCountry:
@ -58,7 +58,7 @@ extension VPNSortField {
}
}
extension VPNFilters {
extension ProviderFilters {
func predicate(for providerId: ProviderID) -> NSPredicate {
var formats: [String] = []
var args: [Any] = []

View File

@ -0,0 +1,173 @@
//
// CDAPIRepositoryV3.swift
// Passepartout
//
// Created by Davide De Rosa on 10/26/24.
// Copyright (c) 2025 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 CommonUtils
import CoreData
import Foundation
import PassepartoutKit
extension AppData {
public static func cdAPIRepositoryV3(context: NSManagedObjectContext) -> APIRepository {
CDAPIRepositoryV3(context: context)
}
}
private final class CDAPIRepositoryV3: NSObject, APIRepository {
private nonisolated let context: NSManagedObjectContext
private nonisolated let providersSubject: CurrentValueSubject<[Provider], Never>
private nonisolated let lastUpdateSubject: CurrentValueSubject<[ProviderID: Date], Never>
private nonisolated let providersController: NSFetchedResultsController<CDProviderV3>
init(context: NSManagedObjectContext) {
self.context = context
providersSubject = CurrentValueSubject([])
lastUpdateSubject = 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<[Provider], Never> {
providersSubject
.removeDuplicates()
.eraseToAnyPublisher()
}
nonisolated var lastUpdatePublisher: AnyPublisher<[ProviderID: Date], Never> {
lastUpdateSubject
.removeDuplicates()
.eraseToAnyPublisher()
}
func store(_ index: [Provider]) async throws {
try await context.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(context.delete)
// replace but retain last update
let mapper = CoreDataMapper(context: context)
try index.forEach {
let lastUpdate = lastUpdatesByProvider[$0.id.rawValue]
try mapper.cdProvider(from: $0, lastUpdate: lastUpdate)
}
try context.save()
} catch {
context.rollback()
throw error
}
}
}
func store(_ infrastructure: ProviderInfrastructure, for providerId: ProviderID) async throws {
try await context.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.lastUpdate
}
// delete all provider entities
let serverRequest = CDVPNServerV3.fetchRequest()
serverRequest.predicate = predicate
let servers = try serverRequest.execute()
servers.forEach(context.delete)
let presetRequest = CDVPNPresetV3.fetchRequest()
presetRequest.predicate = predicate
let presets = try presetRequest.execute()
presets.forEach(context.delete)
// create new entities
let mapper = CoreDataMapper(context: context)
try infrastructure.servers.forEach {
try mapper.cdServer(from: $0)
}
try infrastructure.presets.forEach {
try mapper.cdPreset(from: $0)
}
try context.save()
} catch {
context.rollback()
throw error
}
}
}
nonisolated func providerRepository(for providerId: ProviderID) -> ProviderRepository {
CDProviderRepositoryV3(context: context, providerId: providerId)
}
}
extension CDAPIRepositoryV3: 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:)))
lastUpdateSubject.send(mapper.lastUpdate(from: entities))
}
}

View File

@ -24,150 +24,73 @@
//
import AppData
import Combine
import CommonUtils
import CoreData
import Foundation
import PassepartoutKit
extension AppData {
public static func cdProviderRepositoryV3(context: NSManagedObjectContext) -> ProviderRepository {
CDProviderRepositoryV3(context: context)
}
}
final class CDProviderRepositoryV3: ProviderRepository {
private let context: NSManagedObjectContext
private final class CDProviderRepositoryV3: NSObject, ProviderRepository {
private nonisolated let context: NSManagedObjectContext
let providerId: ProviderID
private nonisolated let providersSubject: CurrentValueSubject<[Provider], Never>
private nonisolated let lastUpdateSubject: CurrentValueSubject<[ProviderID: Date], Never>
private nonisolated let providersController: NSFetchedResultsController<CDProviderV3>
init(context: NSManagedObjectContext) {
init(context: NSManagedObjectContext, providerId: ProviderID) {
self.context = context
providersSubject = CurrentValueSubject([])
lastUpdateSubject = CurrentValueSubject([:])
self.providerId = providerId
}
let request = CDProviderV3.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(key: "providerId", ascending: true)
]
providersController = .init(
fetchRequest: request,
managedObjectContext: context,
sectionNameKeyPath: nil,
cacheName: nil
)
func availableOptions<Template>(for templateType: Template.Type) async throws -> ProviderFilterOptions where Template: IdentifiableConfiguration {
try await context.perform {
let mapper = DomainMapper()
super.init()
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()
Task {
try await context.perform { [weak self] in
self?.providersController.delegate = self
try self?.providersController.performFetch()
var countriesByCategoryName: [String: Set<String>] = [:]
var countryCodes: Set<String> = []
serversResults.forEach {
guard let categoryName = $0.object(forKey: "categoryName") as? String,
let countryCode = $0.object(forKey: "countryCode") as? String else {
return
}
var codes: Set<String> = countriesByCategoryName[categoryName] ?? []
codes.insert(countryCode)
countriesByCategoryName[categoryName] = codes
countryCodes.insert(countryCode)
}
let presetsRequest = CDVPNPresetV3.fetchRequest()
presetsRequest.predicate = NSPredicate(
format: "providerId == %@ AND configurationId == %@", self.providerId.rawValue,
Template.configurationIdentifier
)
let presetsResults = try presetsRequest.execute()
return ProviderFilterOptions(
countriesByCategoryName: countriesByCategoryName,
countryCodes: Set(countryCodes),
presets: Set(try presetsResults.compactMap {
try mapper.preset(from: $0)
})
)
}
}
nonisolated var indexPublisher: AnyPublisher<[Provider], Never> {
providersSubject
.removeDuplicates()
.eraseToAnyPublisher()
}
nonisolated var lastUpdatePublisher: AnyPublisher<[ProviderID: Date], Never> {
lastUpdateSubject
.removeDuplicates()
.eraseToAnyPublisher()
}
func store(_ index: [Provider]) async throws {
try await context.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(context.delete)
// replace but retain last update
let mapper = CoreDataMapper(context: context)
try index.forEach {
let lastUpdate = lastUpdatesByProvider[$0.id.rawValue]
try mapper.cdProvider(from: $0, lastUpdate: lastUpdate)
}
try context.save()
} catch {
context.rollback()
throw error
}
func filteredServers(with parameters: ProviderServerParameters?) async throws -> [ProviderServer] {
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:))
}
}
func store(_ infrastructure: VPNInfrastructure, for providerId: ProviderID) async throws {
try await context.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.lastUpdate
}
// delete all provider entities
let serverRequest = CDVPNServerV3.fetchRequest()
serverRequest.predicate = predicate
let servers = try serverRequest.execute()
servers.forEach(context.delete)
let presetRequest = CDVPNPresetV3.fetchRequest()
presetRequest.predicate = predicate
let presets = try presetRequest.execute()
presets.forEach(context.delete)
// create new entities
let mapper = CoreDataMapper(context: context)
try infrastructure.servers.forEach {
try mapper.cdServer(from: $0)
}
try infrastructure.presets.forEach {
try mapper.cdPreset(from: $0)
}
try context.save()
} catch {
context.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:)))
lastUpdateSubject.send(mapper.lastUpdate(from: entities))
}
}

View File

@ -1,96 +0,0 @@
//
// CDVPNProviderServerRepositoryV3.swift
// Passepartout
//
// Created by Davide De Rosa on 10/26/24.
// Copyright (c) 2025 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 CommonUtils
import CoreData
import Foundation
import PassepartoutKit
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: IdentifiableConfiguration {
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()
var countriesByCategoryName: [String: Set<String>] = [:]
var countryCodes: Set<String> = []
serversResults.forEach {
guard let categoryName = $0.object(forKey: "categoryName") as? String,
let countryCode = $0.object(forKey: "countryCode") as? String else {
return
}
var codes: Set<String> = countriesByCategoryName[categoryName] ?? []
codes.insert(countryCode)
countriesByCategoryName[categoryName] = codes
countryCodes.insert(countryCode)
}
let presetsRequest = CDVPNPresetV3.fetchRequest()
presetsRequest.predicate = NSPredicate(
format: "providerId == %@ AND configurationId == %@", self.providerId.rawValue,
Configuration.configurationIdentifier
)
let presetsResults = try presetsRequest.execute()
return VPNFilterOptions(
countriesByCategoryName: countriesByCategoryName,
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

@ -53,10 +53,10 @@ private extension AppUIMain {
fatalError("\(moduleType): is not ModuleViewProviding")
}
// ProviderEntityViewProviding
// ProviderServerCoordinatorSupporting
if providerModuleTypes.contains(moduleType) {
guard module is any ProviderEntityViewProviding else {
fatalError("\(moduleType): is not ProviderEntityViewProviding")
guard module is any ProviderServerCoordinatorSupporting else {
fatalError("\(moduleType): is not ProviderServerCoordinatorSupporting")
}
}
} catch {

View File

@ -114,7 +114,7 @@ private extension AddProfileMenu {
private struct ProvidersSubmenu: View {
@EnvironmentObject
private var providerManager: ProviderManager
private var apiManager: APIManager
let moduleType: ModuleType
@ -124,7 +124,7 @@ private struct ProvidersSubmenu: View {
var body: some View {
Menu {
ForEach(providerManager.providers, content: profileButton(for:))
ForEach(apiManager.providers, content: profileButton(for:))
} label: {
Text(moduleType.localizedDescription)
}

View File

@ -191,7 +191,7 @@ extension AppCoordinator {
)
case .editProviderEntity(let profile, let force, let module):
ProviderEntitySelector(
ProviderServerCoordinatorIfSupported(
module: module,
errorHandler: errorHandler,
selectTitle: profile.providerServerSelectionTitle,
@ -226,6 +226,30 @@ extension AppCoordinator {
}
}
// MARK: - Providers
private struct ProviderServerCoordinatorIfSupported: View {
let module: Module
let errorHandler: ErrorHandler
let selectTitle: String
let onSelect: (Module) async throws -> Void
var body: some View {
if let supporting = module as? any ProviderServerCoordinatorSupporting {
supporting.providerServerCoordinator(
selectTitle: selectTitle,
onSelect: onSelect,
errorHandler: errorHandler
)
} else {
fatalError("Module got too far without being ProviderServerCoordinatorSupporting: \(module)")
}
}
}
// MARK: - Handlers
extension AppCoordinator {

View File

@ -133,7 +133,7 @@ private extension InstalledProfileView {
Button {
flow?.connectionFlow?.onProviderEntityRequired(profile!) // never nil due to .map
} label: {
providerSelectorLabel(with: selection.entity?.header)
providerSelectorLabel(with: selection.entityHeader)
}
.buttonStyle(.plain)
}

View File

@ -30,7 +30,7 @@ import SwiftUI
struct ReportIssueButton {
@EnvironmentObject
private var providerManager: ProviderManager
private var apiManager: APIManager
@ObservedObject
var profileManager: ProfileManager
@ -61,7 +61,7 @@ struct ReportIssueButton {
guard let id = installedProfile?.selectedProvider?.selection.id else {
return nil
}
let lastUpdate = providerManager.lastUpdate(for: id)
let lastUpdate = apiManager.lastUpdate(for: id)
return (id, lastUpdate)
}
}

View File

@ -34,26 +34,5 @@ extension OpenVPNModule.Builder: ModuleViewProviding {
}
}
extension OpenVPNModule: ProviderEntityViewProviding {
public func providerEntityView(
errorHandler: ErrorHandler,
selectTitle: String,
onSelect: @escaping (Module) async throws -> Void
) -> some View {
providerSelection.map {
VPNProviderServerCoordinator(
moduleId: id,
providerId: $0.id,
selectedEntity: $0.entity,
selectTitle: selectTitle,
onSelect: {
var newBuilder = builder()
newBuilder.providerEntity = $0
let newModule = try newBuilder.tryBuild()
try await onSelect(newModule)
},
errorHandler: errorHandler
)
}
}
extension OpenVPNModule: ProviderServerCoordinatorSupporting {
}

View File

@ -34,26 +34,5 @@ extension WireGuardModule.Builder: ModuleViewProviding {
}
}
extension WireGuardModule: ProviderEntityViewProviding {
public func providerEntityView(
errorHandler: ErrorHandler,
selectTitle: String,
onSelect: @escaping (Module) async throws -> Void
) -> some View {
providerSelection.map {
VPNProviderServerCoordinator(
moduleId: id,
providerId: $0.id,
selectedEntity: $0.entity,
selectTitle: selectTitle,
onSelect: {
var newBuilder = builder()
newBuilder.providerEntity = $0
let newModule = try newBuilder.tryBuild()
try await onSelect(newModule)
},
errorHandler: errorHandler
)
}
}
extension WireGuardModule: ProviderServerCoordinatorSupporting {
}

View File

@ -123,7 +123,7 @@ private extension OpenVPNView {
}
var providerModifier: some ViewModifier {
VPNProviderContentModifier(
ProviderContentModifier(
providerId: providerId,
providerPreferences: nil,
selectedEntity: providerEntity,
@ -157,7 +157,7 @@ private extension OpenVPNView {
switch route {
case .providerServer:
draft.wrappedValue.providerSelection.map {
VPNProviderServerView(
ProviderServerView(
moduleId: module.id,
providerId: $0.id,
selectedEntity: $0.entity,
@ -215,8 +215,8 @@ private extension OpenVPNView {
}
}
func onSelectServer(server: VPNServer, preset: VPNPreset<OpenVPN.Configuration>) {
draft.wrappedValue.providerEntity = VPNEntity(server: server, preset: preset)
func onSelectServer(server: ProviderServer, preset: ProviderPreset<OpenVPNProviderTemplate>) {
draft.wrappedValue.providerEntity = ProviderEntity(server: server, preset: preset)
resetExcludedEndpointsWithCurrentProviderEntity()
path.wrappedValue.removeLast()
}

View File

@ -73,7 +73,7 @@ private extension WireGuardView {
}
var providerModifier: some ViewModifier {
VPNProviderContentModifier(
ProviderContentModifier(
providerId: providerId,
providerPreferences: nil,
selectedEntity: providerEntity,
@ -90,8 +90,8 @@ private extension WireGuardView {
}
private extension WireGuardView {
func onSelectServer(server: VPNServer, preset: VPNPreset<WireGuard.Configuration>) {
draft.wrappedValue.providerEntity = VPNEntity(server: server, preset: preset)
func onSelectServer(server: ProviderServer, preset: ProviderPreset<WireGuardProviderTemplate>) {
draft.wrappedValue.providerEntity = ProviderEntity(server: server, preset: preset)
path.wrappedValue.removeLast()
}
@ -114,7 +114,7 @@ private extension WireGuardView {
switch route {
case .providerServer:
draft.wrappedValue.providerSelection.map {
VPNProviderServerView(
ProviderServerView(
moduleId: module.id,
providerId: $0.id,
selectedEntity: $0.entity,

View File

@ -0,0 +1,226 @@
//
// APIContentModifier.swift
// Passepartout
//
// Created by Davide De Rosa on 10/14/24.
// Copyright (c) 2025 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 CommonAPI
import CommonLibrary
import PassepartoutKit
import SwiftUI
import UILibrary
struct APIContentModifier<Template, ProviderRows>: ViewModifier where Template: IdentifiableConfiguration, ProviderRows: View {
@EnvironmentObject
private var apiManager: APIManager
@EnvironmentObject
private var preferencesManager: PreferencesManager
let apis: [APIMapper]
@Binding
var providerId: ProviderID?
let providerPreferences: ProviderPreferences?
let templateType: Template.Type
@ViewBuilder
let providerRows: ProviderRows
let onSelectProvider: (APIManager, ProviderID?, _ isInitial: Bool) -> Void
func body(content: Content) -> some View {
providerView
.onLoad(perform: loadCurrentProvider)
.onChange(of: providerId) { newId in
Task {
if let newId {
await refreshInfrastructure(for: newId)
}
loadPreferences(for: newId)
onSelectProvider(apiManager, newId, false)
}
}
.onDisappear(perform: savePreferences)
.disabled(apiManager.isLoading)
content
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.providerId == rhs.providerId
}
}
private extension APIContentModifier {
#if os(iOS)
@ViewBuilder
var providerView: some View {
providerPicker
.themeSection()
if let providerId {
Group {
providerRows
RefreshInfrastructureButton(apis: apis, providerId: providerId)
}
.themeSection(footer: lastUpdatedString)
}
}
#else
@ViewBuilder
var providerView: some View {
Section {
providerPicker
}
if let providerId {
Section {
providerRows
HStack {
lastUpdatedString.map {
Text($0)
.themeSubtitle()
}
Spacer()
RefreshInfrastructureButton(apis: apis, providerId: providerId)
}
}
}
}
#endif
var providerPicker: some View {
ProviderPicker(
providers: supportedProviders,
providerId: $providerId,
isRequired: true,
isLoading: apiManager.isLoading
)
}
}
private extension APIContentModifier {
var supportedProviders: [Provider] {
apiManager
.providers
.filter {
$0.supports(Template.self)
}
}
var lastUpdate: Date? {
guard let providerId else {
return nil
}
return apiManager.lastUpdate(for: providerId)
}
var lastUpdatedString: String? {
guard let lastUpdate else {
return apiManager.isLoading ? Strings.Views.Providers.LastUpdated.loading : nil
}
return Strings.Views.Providers.lastUpdated(lastUpdate.localizedDescription(style: .timestamp))
}
func loadCurrentProvider() {
Task {
await refreshIndex()
if let providerId {
onSelectProvider(apiManager, providerId, true)
loadPreferences(for: providerId)
}
}
}
@discardableResult
func refreshIndex() async -> Bool {
do {
try await apiManager.fetchIndex(from: apis)
return true
} catch {
pp_log(.app, .error, "Unable to fetch index: \(error)")
return false
}
}
@discardableResult
func refreshInfrastructure(for providerId: ProviderID) async -> Bool {
do {
try await apiManager.fetchInfrastructure(from: apis, for: providerId)
return true
} catch {
pp_log(.app, .error, "Unable to refresh infrastructure: \(error)")
return false
}
}
func loadPreferences(for providerId: ProviderID?) {
guard let providerPreferences else {
return
}
if let providerId {
do {
pp_log(.app, .debug, "Load preferences for provider \(providerId)")
let repository = try preferencesManager.preferencesRepository(forProviderWithId: providerId)
providerPreferences.setRepository(repository)
} catch {
pp_log(.app, .error, "Unable to load preferences for provider \(providerId): \(error)")
providerPreferences.setRepository(nil)
}
} else {
providerPreferences.setRepository(nil)
}
}
func savePreferences() {
guard let providerPreferences else {
return
}
do {
pp_log(.app, .debug, "Save preferences for provider \(providerId.debugDescription)")
try providerPreferences.save()
} catch {
pp_log(.app, .error, "Unable to save preferences for provider \(providerId.debugDescription): \(error)")
}
}
}
// MARK: - Preview
#Preview {
List {
EmptyView()
.modifier(APIContentModifier(
apis: [API.bundled],
providerId: .constant(.hideme),
providerPreferences: nil,
templateType: OpenVPNProviderTemplate.self,
providerRows: {},
onSelectProvider: { _, _, _ in }
))
}
.withMockEnvironment()
}

View File

@ -2,7 +2,7 @@
// ProviderContentModifier.swift
// Passepartout
//
// Created by Davide De Rosa on 10/14/24.
// Created by Davide De Rosa on 10/7/24.
// Copyright (c) 2025 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
@ -27,183 +27,59 @@ import CommonAPI
import CommonLibrary
import PassepartoutKit
import SwiftUI
import UILibrary
struct ProviderContentModifier<Entity, ProviderRows>: ViewModifier where Entity: ProviderEntity, ProviderRows: View {
@EnvironmentObject
private var providerManager: ProviderManager
@EnvironmentObject
private var preferencesManager: PreferencesManager
let apis: [APIMapper]
struct ProviderContentModifier<Template, ProviderRows>: ViewModifier where Template: IdentifiableConfiguration, ProviderRows: View {
var apis: [APIMapper] = API.shared
@Binding
var providerId: ProviderID?
let providerPreferences: ProviderPreferences?
let entityType: Entity.Type
@Binding
var selectedEntity: ProviderEntity<Template>?
let entityDestination: any Hashable
@ViewBuilder
let providerRows: ProviderRows
let onSelectProvider: (ProviderManager, ProviderID?, _ isInitial: Bool) -> Void
func body(content: Content) -> some View {
providerView
.onLoad(perform: loadCurrentProvider)
.onChange(of: providerId) { newId in
Task {
if let newId {
await refreshInfrastructure(for: newId)
}
loadPreferences(for: newId)
onSelectProvider(providerManager, newId, false)
}
}
.onDisappear(perform: savePreferences)
.disabled(providerManager.isLoading)
content
}
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.providerId == rhs.providerId
debugChanges()
return content
.modifier(APIContentModifier(
apis: apis,
providerId: $providerId,
providerPreferences: providerPreferences,
templateType: Template.self,
providerRows: {
providerEntityRow
providerRows
},
onSelectProvider: onSelectProvider
))
}
}
private extension ProviderContentModifier {
#if os(iOS)
@ViewBuilder
var providerView: some View {
providerPicker
.themeSection()
if let providerId {
Group {
providerRows
RefreshInfrastructureButton(apis: apis, providerId: providerId)
}
.themeSection(footer: lastUpdatedString)
}
}
#else
@ViewBuilder
var providerView: some View {
Section {
providerPicker
}
if let providerId {
Section {
providerRows
HStack {
lastUpdatedString.map {
Text($0)
.themeSubtitle()
}
var providerEntityRow: some View {
NavigationLink(value: entityDestination) {
HStack {
Text(Strings.Global.Nouns.server)
if let selectedEntity {
Spacer()
RefreshInfrastructureButton(apis: apis, providerId: providerId)
Text(selectedEntity.server.hostname ?? selectedEntity.server.serverId)
.foregroundStyle(.secondary)
}
}
}
}
#endif
var providerPicker: some View {
ProviderPicker(
providers: supportedProviders,
providerId: $providerId,
isRequired: true,
isLoading: providerManager.isLoading
)
}
}
private extension ProviderContentModifier {
var supportedProviders: [Provider] {
providerManager
.providers
.filter {
$0.supports(Entity.Template.self)
}
}
var lastUpdate: Date? {
guard let providerId else {
return nil
}
return providerManager.lastUpdate(for: providerId)
}
var lastUpdatedString: String? {
guard let lastUpdate else {
return providerManager.isLoading ? Strings.Views.Providers.LastUpdated.loading : nil
}
return Strings.Views.Providers.lastUpdated(lastUpdate.localizedDescription(style: .timestamp))
}
func loadCurrentProvider() {
Task {
await refreshIndex()
if let providerId {
onSelectProvider(providerManager, providerId, true)
loadPreferences(for: providerId)
}
}
}
@discardableResult
func refreshIndex() async -> Bool {
do {
try await providerManager.fetchIndex(from: apis)
return true
} catch {
pp_log(.app, .error, "Unable to fetch index: \(error)")
return false
}
}
@discardableResult
func refreshInfrastructure(for providerId: ProviderID) async -> Bool {
do {
try await providerManager.fetchVPNInfrastructure(from: apis, for: providerId)
return true
} catch {
pp_log(.app, .error, "Unable to refresh infrastructure: \(error)")
return false
}
}
func loadPreferences(for providerId: ProviderID?) {
guard let providerPreferences else {
return
}
if let providerId {
do {
pp_log(.app, .debug, "Load preferences for provider \(providerId)")
let repository = try preferencesManager.preferencesRepository(forProviderWithId: providerId)
providerPreferences.setRepository(repository)
} catch {
pp_log(.app, .error, "Unable to load preferences for provider \(providerId): \(error)")
providerPreferences.setRepository(nil)
}
} else {
providerPreferences.setRepository(nil)
}
}
func savePreferences() {
guard let providerPreferences else {
return
}
do {
pp_log(.app, .debug, "Save preferences for provider \(providerId.debugDescription)")
try providerPreferences.save()
} catch {
pp_log(.app, .error, "Unable to save preferences for provider \(providerId.debugDescription): \(error)")
func onSelectProvider(manager: APIManager, providerId: ProviderID?, isInitial: Bool) {
if !isInitial {
selectedEntity = nil
}
}
}
@ -211,16 +87,24 @@ private extension ProviderContentModifier {
// MARK: - Preview
#Preview {
List {
EmptyView()
.modifier(ProviderContentModifier(
apis: [API.bundled],
providerId: .constant(.hideme),
providerPreferences: nil,
entityType: VPNEntity<OpenVPN.Configuration>.self,
providerRows: {},
onSelectProvider: { _, _, _ in }
))
NavigationStack {
List {
EmptyView()
.modifier(ProviderContentModifier(
apis: [API.bundled],
providerId: .constant(.hideme),
providerPreferences: nil,
selectedEntity: .constant(nil as ProviderEntity<OpenVPNProviderTemplate>?),
entityDestination: "Destination",
providerRows: {
Text("Other")
}
))
}
.navigationTitle("Preview")
.navigationDestination(for: String.self) {
Text($0)
}
}
.withMockEnvironment()
}

View File

@ -1,51 +0,0 @@
//
// ProviderEntitySelector.swift
// Passepartout
//
// Created by Davide De Rosa on 10/22/24.
// Copyright (c) 2025 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 CommonLibrary
import CommonUtils
import PassepartoutKit
import SwiftUI
struct ProviderEntitySelector: View {
let module: Module
let errorHandler: ErrorHandler
let selectTitle: String
let onSelect: (Module) async throws -> Void
var body: some View {
if let viewProvider = module as? any ProviderEntityViewProviding {
AnyView(viewProvider.providerEntityView(
errorHandler: errorHandler,
selectTitle: selectTitle,
onSelect: onSelect
))
} else {
fatalError("Module got too far without being ProviderEntityViewProviding: \(module)")
}
}
}

View File

@ -1,5 +1,5 @@
//
// VPNFiltersView+Model.swift
// ProviderFiltersView+Model.swift
// Passepartout
//
// Created by Davide De Rosa on 10/26/24.
@ -29,7 +29,7 @@ import Foundation
import PassepartoutKit
import UIAccessibility
extension VPNFiltersView {
extension ProviderFiltersView {
@MainActor
final class Model: ObservableObject {
@ -37,7 +37,7 @@ extension VPNFiltersView {
private let defaults: UserDefaults
private var options: VPNFilterOptions
private var options: ProviderFilterOptions
@Published
private(set) var categories: [String]
@ -46,10 +46,10 @@ extension VPNFiltersView {
private(set) var countries: [CodeWithDescription]
@Published
private(set) var presets: [AnyVPNPreset]
private(set) var presets: [AnyProviderPreset]
@Published
var filters: VPNFilters
var filters: ProviderFilters
@Published
var onlyShowsFavorites: Bool
@ -58,11 +58,11 @@ extension VPNFiltersView {
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
options = VPNFilterOptions()
options = ProviderFilterOptions()
categories = []
countries = []
presets = []
filters = VPNFilters()
filters = ProviderFilters()
onlyShowsFavorites = false
subscriptions = []
@ -71,7 +71,7 @@ extension VPNFiltersView {
}
}
func load(options: VPNFilterOptions, initialFilters: VPNFilters?) {
func load(options: ProviderFilterOptions, initialFilters: ProviderFilters?) {
self.options = options
setCategories(withNames: Set(options.countriesByCategoryName.keys))
setCountries(withCodes: options.countryCodes)
@ -82,7 +82,7 @@ extension VPNFiltersView {
}
}
func update(with servers: [VPNServer]) {
func update(with servers: [ProviderServer]) {
// only countries that have servers in this category
let knownCountryCodes: Set<String>
@ -94,7 +94,7 @@ extension VPNFiltersView {
// only presets known in filtered servers
var knownPresets = options.presets
let allPresetIds = Set(servers.compactMap(\.provider.supportedPresetIds).joined())
let allPresetIds = Set(servers.compactMap(\.metadata.supportedPresetIds).joined())
if !allPresetIds.isEmpty {
knownPresets = knownPresets
.filter {
@ -108,7 +108,7 @@ extension VPNFiltersView {
}
}
private extension VPNFiltersView.Model {
private extension ProviderFiltersView.Model {
func setCategories(withNames categoryNames: Set<String>) {
categories = categoryNames
.sorted()
@ -122,7 +122,7 @@ private extension VPNFiltersView.Model {
}
}
func setPresets(with presets: Set<AnyVPNPreset>) {
func setPresets(with presets: Set<AnyProviderPreset>) {
self.presets = presets
.sorted {
$0.description < $1.description
@ -132,7 +132,7 @@ private extension VPNFiltersView.Model {
// MARK: - Observation
private extension VPNFiltersView.Model {
private extension ProviderFiltersView.Model {
func observeObjects() {
$onlyShowsFavorites
.dropFirst()
@ -160,7 +160,7 @@ private extension UserDefaults {
}
private extension String {
var asCountryCodeWithDescription: VPNFiltersView.Model.CodeWithDescription {
var asCountryCodeWithDescription: ProviderFiltersView.Model.CodeWithDescription {
(self, localizedAsRegionCode ?? self)
}
}

View File

@ -1,5 +1,5 @@
//
// VPNFiltersView.swift
// ProviderFiltersView.swift
// Passepartout
//
// Created by Davide De Rosa on 10/9/24.
@ -28,7 +28,7 @@ import CommonLibrary
import PassepartoutKit
import SwiftUI
struct VPNFiltersView: View {
struct ProviderFiltersView: View {
let apis: [APIMapper]
let providerId: ProviderID
@ -59,7 +59,7 @@ struct VPNFiltersView: View {
}
}
private extension VPNFiltersView {
private extension ProviderFiltersView {
var categoryNameBinding: Binding<String?> {
Binding {
model.filters.categoryName
@ -108,14 +108,14 @@ private extension VPNFiltersView {
var clearFiltersButton: some View {
Button(Strings.Views.Providers.clearFilters, role: .destructive) {
model.filters = VPNFilters()
model.filters = ProviderFilters()
}
}
}
#Preview {
NavigationStack {
VPNFiltersView(
ProviderFiltersView(
apis: [API.bundled],
providerId: .mullvad,
model: .init()

View File

@ -0,0 +1,57 @@
//
// ProviderServerCoordinatorSupporting+Module.swift
// Passepartout
//
// Created by Davide De Rosa on 1/16/25.
// Copyright (c) 2025 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 CommonUtils
import PassepartoutKit
import SwiftUI
import UILibrary
extension ProviderServerCoordinatorSupporting where Self: Module,
Self: BuildableType,
Self: ProviderSelecting,
B: MutableProviderSelecting,
CustomProviderSelection == B.CustomProviderSelection {
public func providerServerCoordinator(
selectTitle: String,
onSelect: @escaping (Module) async throws -> Void,
errorHandler: ErrorHandler
) -> AnyView {
AnyView(providerSelection.map {
ProviderServerCoordinator(
moduleId: id,
providerId: $0.id,
selectedEntity: $0.entity,
selectTitle: selectTitle,
onSelect: {
var newBuilder = builder()
newBuilder.providerEntity = $0
let newModule = try newBuilder.tryBuild()
try await onSelect(newModule)
},
errorHandler: errorHandler
)
})
}
}

View File

@ -1,5 +1,5 @@
//
// VPNProviderServerCoordinator.swift
// ProviderServerCoordinator.swift
// Passepartout
//
// Created by Davide De Rosa on 10/16/24.
@ -27,7 +27,7 @@ import CommonUtils
import PassepartoutKit
import SwiftUI
struct VPNProviderServerCoordinator<Configuration>: View where Configuration: IdentifiableConfiguration {
struct ProviderServerCoordinator<Template>: View where Template: IdentifiableConfiguration {
@Environment(\.dismiss)
private var dismiss
@ -36,17 +36,17 @@ struct VPNProviderServerCoordinator<Configuration>: View where Configuration: Id
let providerId: ProviderID
let selectedEntity: VPNEntity<Configuration>?
let selectedEntity: ProviderEntity<Template>?
let selectTitle: String
let onSelect: (VPNEntity<Configuration>) async throws -> Void
let onSelect: (ProviderEntity<Template>) async throws -> Void
@ObservedObject
var errorHandler: ErrorHandler
var body: some View {
VPNProviderServerView(
ProviderServerView(
moduleId: moduleId,
providerId: providerId,
selectedEntity: selectedEntity,
@ -58,15 +58,15 @@ struct VPNProviderServerCoordinator<Configuration>: View where Configuration: Id
}
}
private extension VPNProviderServerCoordinator {
func onSelect(server: VPNServer, preset: VPNPreset<Configuration>) {
private extension ProviderServerCoordinator {
func onSelect(server: ProviderServer, preset: ProviderPreset<Template>) {
Task {
do {
let entity = VPNEntity(server: server, preset: preset)
let entity = ProviderEntity(server: server, preset: preset)
dismiss()
try await onSelect(entity)
} catch {
pp_log(.app, .fault, "Unable to select server \(server.serverId) for provider \(server.provider.id): \(error)")
pp_log(.app, .fault, "Unable to select server \(server.serverId) for provider \(server.metadata.providerId): \(error)")
errorHandler.handle(error, title: Strings.Views.Providers.selectEntity)
}
}

View File

@ -1,5 +1,5 @@
//
// VPNProviderServerView.swift
// ProviderServerView.swift
// Passepartout
//
// Created by Davide De Rosa on 10/7/24.
@ -29,10 +29,10 @@ import CommonUtils
import PassepartoutKit
import SwiftUI
struct VPNProviderServerView<Configuration>: View where Configuration: IdentifiableConfiguration {
struct ProviderServerView<Template>: View where Template: IdentifiableConfiguration {
@EnvironmentObject
private var providerManager: ProviderManager
private var apiManager: APIManager
@EnvironmentObject
private var preferencesManager: PreferencesManager
@ -43,23 +43,23 @@ struct VPNProviderServerView<Configuration>: View where Configuration: Identifia
let providerId: ProviderID
let selectedEntity: VPNEntity<Configuration>?
let selectedEntity: ProviderEntity<Template>?
let filtersWithSelection: Bool
var selectTitle = Strings.Views.Providers.selectEntity
let onSelect: (VPNServer, VPNPreset<Configuration>) -> Void
let onSelect: (ProviderServer, ProviderPreset<Template>) -> Void
@StateObject
private var vpnManager = VPNProviderManager<Configuration>(sorting: [
private var providerManager = ProviderManager<Template>(sorting: [
.localizedCountry,
.area,
.serverId
])
@State
private var servers: [VPNServer] = []
private var servers: [ProviderServer] = []
@State
private var isFiltering = false
@ -71,7 +71,7 @@ struct VPNProviderServerView<Configuration>: View where Configuration: Identifia
private var providerPreferences = ProviderPreferences()
@StateObject
private var filtersViewModel = VPNFiltersView.Model()
private var filtersViewModel = ProviderFiltersView.Model()
@StateObject
private var errorHandler: ErrorHandler = .default()
@ -88,7 +88,7 @@ struct VPNProviderServerView<Configuration>: View where Configuration: Identifia
}
}
extension VPNProviderServerView {
extension ProviderServerView {
func contentView() -> some View {
ContentView(
apis: apis,
@ -110,7 +110,7 @@ extension VPNProviderServerView {
}
func filtersView() -> some View {
VPNFiltersView(
ProviderFiltersView(
apis: apis,
providerId: providerId,
model: filtersViewModel
@ -118,12 +118,12 @@ extension VPNProviderServerView {
}
}
private extension VPNProviderServerView {
private extension ProviderServerView {
var title: String {
providerManager.provider(withId: providerId)?.description ?? Strings.Global.Nouns.servers
apiManager.provider(withId: providerId)?.description ?? Strings.Global.Nouns.servers
}
var filteredServers: [VPNServer] {
var filteredServers: [ProviderServer] {
if onlyShowsFavorites {
return servers.filter {
providerPreferences.isFavoriteServer($0.serverId)
@ -132,16 +132,16 @@ private extension VPNProviderServerView {
return servers
}
var initialFilters: VPNFilters? {
var initialFilters: ProviderFilters? {
guard let selectedEntity else {
return nil
}
var filters = VPNFilters()
var filters = ProviderFilters()
filters.presetId = selectedEntity.preset.presetId
if filtersWithSelection {
filters.categoryName = selectedEntity.server.provider.categoryName
filters.categoryName = selectedEntity.server.metadata.categoryName
#if os(macOS)
filters.countryCode = selectedEntity.server.provider.countryCode
filters.countryCode = selectedEntity.server.metadata.countryCode
#endif
}
return filters
@ -155,12 +155,12 @@ private extension VPNProviderServerView {
pp_log(.app, .error, "Unable to load preferences for provider \(providerId): \(error)")
}
do {
let repository = try await providerManager.vpnServerRepository(
let repository = try await apiManager.providerRepository(
from: apis,
for: providerId
)
try await vpnManager.setRepository(repository)
filtersViewModel.load(options: vpnManager.options, initialFilters: initialFilters)
try await providerManager.setRepository(repository)
filtersViewModel.load(options: providerManager.options, initialFilters: initialFilters)
await reloadServers(filters: filtersViewModel.filters)
} catch {
pp_log(.app, .error, "Unable to load VPN servers for provider \(providerId): \(error)")
@ -168,11 +168,11 @@ private extension VPNProviderServerView {
}
}
func reloadServers(filters: VPNFilters) async {
func reloadServers(filters: ProviderFilters) async {
isFiltering = true
do {
try await Task {
servers = try await vpnManager.filteredServers(with: filters)
servers = try await providerManager.filteredServers(with: filters)
filtersViewModel.update(with: servers)
isFiltering = false
}.value
@ -181,8 +181,8 @@ private extension VPNProviderServerView {
}
}
func compatiblePresets(with server: VPNServer) -> [VPNPreset<Configuration>] {
vpnManager
func compatiblePresets(with server: ProviderServer) -> [ProviderPreset<Template>] {
providerManager
.presets
.filter {
if let selectedId = filtersViewModel.filters.presetId {
@ -191,14 +191,14 @@ private extension VPNProviderServerView {
return true
}
.filter {
if let supportedIds = server.provider.supportedPresetIds {
if let supportedIds = server.metadata.supportedPresetIds {
return supportedIds.contains($0.presetId)
}
return true
}
}
func onNewFilters(_ filters: VPNFilters) {
func onNewFilters(_ filters: ProviderFilters) {
Task {
await reloadServers(filters: filters)
}
@ -216,11 +216,11 @@ private extension VPNProviderServerView {
}
}
func onSelectServer(_ server: VPNServer) {
func onSelectServer(_ server: ProviderServer) {
let presets = compatiblePresets(with: server)
guard let preset = presets.first else {
pp_log(.app, .error, "Unable to find a compatible preset. Supported IDs: \(server.provider.supportedPresetIds ?? [])")
assertionFailure("No compatible presets for server \(server.serverId) (provider=\(vpnManager.providerId), configuration=\(Configuration.configurationIdentifier), supported=\(server.provider.supportedPresetIds ?? []))")
pp_log(.app, .error, "Unable to find a compatible preset. Supported IDs: \(server.metadata.supportedPresetIds ?? [])")
assertionFailure("No compatible presets for server \(server.serverId) (provider=\(providerManager.providerId), template=\(Template.configurationIdentifier), supported=\(server.metadata.supportedPresetIds ?? []))")
return
}
onSelect(server, preset)
@ -231,11 +231,11 @@ private extension VPNProviderServerView {
#Preview {
NavigationStack {
VPNProviderServerView(
ProviderServerView(
apis: [API.bundled],
moduleId: UUID(),
providerId: .protonvpn,
selectedEntity: nil as VPNEntity<OpenVPN.Configuration>?,
selectedEntity: nil as ProviderEntity<OpenVPNProviderTemplate>?,
filtersWithSelection: false,
selectTitle: "Select",
onSelect: { _, _ in }

View File

@ -1,5 +1,5 @@
//
// VPNProviderServer+Container+macOS.swift
// ProviderServer+Container+macOS.swift
// Passepartout
//
// Created by Davide De Rosa on 11/25/24.
@ -27,7 +27,7 @@
import SwiftUI
extension VPNProviderServerView {
extension ProviderServerView {
struct ContainerView<Content, Filters>: View where Content: View, Filters: View {
@ViewBuilder

View File

@ -1,5 +1,5 @@
//
// VPNProviderServer+Content+iOS.swift
// ProviderServer+Content+iOS.swift
// Passepartout
//
// Created by Davide De Rosa on 10/9/24.
@ -31,30 +31,30 @@ import PassepartoutKit
import SwiftUI
import UIAccessibility
extension VPNProviderServerView {
extension ProviderServerView {
struct ContentView: View {
let apis: [APIMapper]
let providerId: ProviderID
let servers: [VPNServer]
let servers: [ProviderServer]
let selectedServer: VPNServer?
let selectedServer: ProviderServer?
let isFiltering: Bool
@ObservedObject
var filtersViewModel: VPNFiltersView.Model
var filtersViewModel: ProviderFiltersView.Model
@ObservedObject
var providerPreferences: ProviderPreferences
let selectTitle: String
let onSelect: (VPNServer) -> Void
let onSelect: (ProviderServer) -> Void
@State
private var serversByCountryCode: [String: [VPNServer]] = [:]
private var serversByCountryCode: [String: [ProviderServer]] = [:]
@State
private var expandedCodes: Set<String> = []
@ -68,7 +68,7 @@ extension VPNProviderServerView {
}
}
private extension VPNProviderServerView.ContentView {
private extension ProviderServerView.ContentView {
var listView: some View {
List {
Section {
@ -92,7 +92,7 @@ private extension VPNProviderServerView.ContentView {
)
.onLoad {
if let selectedServer {
expandedCodes.insert(selectedServer.provider.countryCode)
expandedCodes.insert(selectedServer.metadata.countryCode)
}
}
}
@ -110,7 +110,7 @@ private extension VPNProviderServerView.ContentView {
}
}
func serverView(for server: VPNServer) -> some View {
func serverView(for server: ProviderServer) -> some View {
Button {
onSelect(server)
} label: {
@ -118,7 +118,7 @@ private extension VPNProviderServerView.ContentView {
ThemeImage(.marked)
.opaque(server.id == selectedServer?.id)
VStack(alignment: .leading) {
if let area = server.provider.area {
if let area = server.metadata.area {
Text(area)
.font(.headline)
}
@ -140,7 +140,7 @@ private extension VPNProviderServerView.ContentView {
}
}
private extension VPNProviderServerView.ContentView {
private extension ProviderServerView.ContentView {
var countryCodes: [String] {
filtersViewModel
.countries
@ -159,10 +159,10 @@ private extension VPNProviderServerView.ContentView {
}
}
func computeServersByCountry(_ servers: [VPNServer]) {
var map: [String: [VPNServer]] = [:]
func computeServersByCountry(_ servers: [ProviderServer]) {
var map: [String: [ProviderServer]] = [:]
servers.forEach {
let code = $0.provider.countryCode
let code = $0.metadata.countryCode
var list = map[code] ?? []
list.append($0)
map[code] = list

View File

@ -1,5 +1,5 @@
//
// VPNProviderServer+Container+macOS.swift
// ProviderServer+Container+macOS.swift
// Passepartout
//
// Created by Davide De Rosa on 11/25/24.
@ -27,7 +27,7 @@
import SwiftUI
extension VPNProviderServerView {
extension ProviderServerView {
struct ContainerView<Content, Filters>: View where Content: View, Filters: View {
@ViewBuilder

View File

@ -1,5 +1,5 @@
//
// VPNProviderServer+Content+macOS.swift
// ProviderServer+Content+macOS.swift
// Passepartout
//
// Created by Davide De Rosa on 10/9/24.
@ -30,7 +30,7 @@ import CommonLibrary
import PassepartoutKit
import SwiftUI
extension VPNProviderServerView {
extension ProviderServerView {
struct ContentView: View {
@EnvironmentObject
@ -40,21 +40,21 @@ extension VPNProviderServerView {
let providerId: ProviderID
let servers: [VPNServer]
let servers: [ProviderServer]
let selectedServer: VPNServer?
let selectedServer: ProviderServer?
let isFiltering: Bool
@ObservedObject
var filtersViewModel: VPNFiltersView.Model
var filtersViewModel: ProviderFiltersView.Model
@ObservedObject
var providerPreferences: ProviderPreferences
let selectTitle: String
let onSelect: (VPNServer) -> Void
let onSelect: (ProviderServer) -> Void
@State
private var hoveringServerId: String?
@ -66,7 +66,7 @@ extension VPNProviderServerView {
}
}
private extension VPNProviderServerView.ContentView {
private extension ProviderServerView.ContentView {
var tableView: some View {
Table(servers) {
TableColumn("") { server in
@ -77,7 +77,7 @@ private extension VPNProviderServerView.ContentView {
.width(10.0)
TableColumn(Strings.Global.Nouns.region) { server in
ThemeCountryText(server.provider.countryCode, title: server.region)
ThemeCountryText(server.metadata.countryCode, title: server.region)
.help(server.region)
.environmentObject(theme) // TODO: #873, Table loses environment
}

View File

@ -1,110 +0,0 @@
//
// VPNProviderContentModifier.swift
// Passepartout
//
// Created by Davide De Rosa on 10/7/24.
// Copyright (c) 2025 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 CommonAPI
import CommonLibrary
import PassepartoutKit
import SwiftUI
struct VPNProviderContentModifier<Configuration, ProviderRows>: ViewModifier where Configuration: IdentifiableConfiguration, ProviderRows: View {
var apis: [APIMapper] = API.shared
@Binding
var providerId: ProviderID?
let providerPreferences: ProviderPreferences?
@Binding
var selectedEntity: VPNEntity<Configuration>?
let entityDestination: any Hashable
@ViewBuilder
let providerRows: ProviderRows
func body(content: Content) -> some View {
debugChanges()
return content
.modifier(ProviderContentModifier(
apis: apis,
providerId: $providerId,
providerPreferences: providerPreferences,
entityType: VPNEntity<Configuration>.self,
providerRows: {
providerEntityRow
providerRows
},
onSelectProvider: onSelectProvider
))
}
}
private extension VPNProviderContentModifier {
var providerEntityRow: some View {
NavigationLink(value: entityDestination) {
HStack {
Text(Strings.Global.Nouns.server)
if let selectedEntity {
Spacer()
Text(selectedEntity.server.hostname ?? selectedEntity.server.serverId)
.foregroundStyle(.secondary)
}
}
}
}
}
private extension VPNProviderContentModifier {
func onSelectProvider(manager: ProviderManager, providerId: ProviderID?, isInitial: Bool) {
if !isInitial {
selectedEntity = nil
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
List {
EmptyView()
.modifier(VPNProviderContentModifier(
apis: [API.bundled],
providerId: .constant(.hideme),
providerPreferences: nil,
selectedEntity: .constant(nil as VPNEntity<OpenVPN.Configuration>?),
entityDestination: "Destination",
providerRows: {
Text("Other")
}
))
}
.navigationTitle("Preview")
.navigationDestination(for: String.self) {
Text($0)
}
}
.withMockEnvironment()
}

View File

@ -35,7 +35,7 @@ struct ActiveProfileView: View {
private var theme: Theme
@EnvironmentObject
private var providerManager: ProviderManager
private var apiManager: APIManager
let profile: Profile?
@ -106,14 +106,14 @@ private extension ActiveProfileView {
}
}
if let pair = profile.selectedProvider {
if let provider = providerManager.provider(withId: pair.selection.id) {
if let provider = apiManager.provider(withId: pair.selection.id) {
ListRowView(title: Strings.Global.Nouns.provider) {
Text(provider.description)
}
}
if let entity = pair.selection.entity {
if let entityHeader = pair.selection.entityHeader {
ListRowView(title: Strings.Global.Nouns.country) {
ThemeCountryText(entity.header.countryCode)
ThemeCountryText(entityHeader.countryCode)
}
}
}
@ -217,7 +217,7 @@ private extension ActiveProfileView {
.frame(maxWidth: .infinity)
}
.task {
try? await ProviderManager.forPreviews.fetchIndex(from: [API.bundled])
try? await APIManager.forPreviews.fetchIndex(from: [API.bundled])
}
}

View File

@ -32,16 +32,16 @@ import UIAccessibility
@MainActor
public final class AppContext: ObservableObject, Sendable {
public let apiManager: APIManager
public let iapManager: IAPManager
public let migrationManager: MigrationManager
public let profileManager: ProfileManager
public let providerManager: ProviderManager
public let preferencesManager: PreferencesManager
public let profileManager: ProfileManager
public let registry: Registry
public let tunnel: ExtendedTunnel
@ -57,21 +57,21 @@ public final class AppContext: ObservableObject, Sendable {
private var subscriptions: Set<AnyCancellable>
public init(
apiManager: APIManager,
iapManager: IAPManager,
migrationManager: MigrationManager,
profileManager: ProfileManager,
providerManager: ProviderManager,
preferencesManager: PreferencesManager,
profileManager: ProfileManager,
registry: Registry,
tunnel: ExtendedTunnel,
tunnelReceiptURL: URL?,
onEligibleFeaturesBlock: ((Set<AppFeature>) async -> Void)? = nil
) {
self.apiManager = apiManager
self.iapManager = iapManager
self.migrationManager = migrationManager
self.profileManager = profileManager
self.providerManager = providerManager
self.preferencesManager = preferencesManager
self.profileManager = profileManager
self.registry = registry
self.tunnel = tunnel
self.tunnelReceiptURL = tunnelReceiptURL
@ -150,7 +150,7 @@ private extension AppContext {
do {
pp_log(.app, .info, "\tFetch providers index...")
try await providerManager.fetchIndex(from: API.shared)
try await apiManager.fetchIndex(from: API.shared)
} catch {
pp_log(.app, .error, "\tUnable to fetch providers index: \(error)")
}

View File

@ -43,7 +43,7 @@ extension ModuleDraftEditing where Draft: MutableProviderSelecting {
}
}
public var providerEntity: Binding<Draft.CustomProviderSelection.Entity?> {
public var providerEntity: Binding<ProviderEntity<Draft.CustomProviderSelection.Template>?> {
Binding {
draft.providerEntity.wrappedValue
} set: {

View File

@ -29,10 +29,10 @@ import SwiftUI
extension View {
public func withEnvironment(from context: AppContext, theme: Theme) -> some View {
environmentObject(theme)
.environmentObject(context.apiManager)
.environmentObject(context.iapManager)
.environmentObject(context.migrationManager)
.environmentObject(context.preferencesManager)
.environmentObject(context.providerManager)
}
public func withMockEnvironment() -> some View {

View File

@ -181,9 +181,9 @@ extension ProviderID: @retroactive CustomDebugStringConvertible {
}
}
extension VPNServer {
extension ProviderServer {
public var region: String {
[provider.countryCode.localizedAsRegionCode, provider.area]
[metadata.countryCode.localizedAsRegionCode, metadata.area]
.compactMap { $0 }
.joined(separator: " - ")
}

View File

@ -54,16 +54,16 @@ extension AppContext {
processor: processor,
interval: Constants.shared.tunnel.refreshInterval
)
let providerManager = ProviderManager(
repository: InMemoryProviderRepository()
let apiManager = APIManager(
repository: InMemoryAPIRepository()
)
let migrationManager = MigrationManager()
return AppContext(
apiManager: apiManager,
iapManager: iapManager,
migrationManager: migrationManager,
profileManager: profileManager,
providerManager: providerManager,
preferencesManager: PreferencesManager(),
profileManager: profileManager,
registry: Registry(),
tunnel: tunnel,
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt
@ -91,8 +91,8 @@ extension ExtendedTunnel {
}
}
extension ProviderManager {
public static var forPreviews: ProviderManager {
AppContext.forPreviews.providerManager
extension APIManager {
public static var forPreviews: APIManager {
AppContext.forPreviews.apiManager
}
}

View File

@ -1,5 +1,5 @@
//
// ProviderEntityViewProviding.swift
// ProviderServerCoordinatorSupporting.swift
// Passepartout
//
// Created by Davide De Rosa on 10/16/24.
@ -27,13 +27,12 @@ import CommonUtils
import PassepartoutKit
import SwiftUI
public protocol ProviderEntityViewProviding {
associatedtype EntityContent: View
public protocol ProviderServerCoordinatorSupporting {
@MainActor
func providerEntityView(
errorHandler: ErrorHandler,
func providerServerCoordinator(
selectTitle: String,
onSelect: @escaping (Module) async throws -> Void
) -> EntityContent
onSelect: @escaping (Module) async throws -> Void,
errorHandler: ErrorHandler
) -> AnyView
}

View File

@ -41,7 +41,7 @@ public struct OpenVPNCredentialsView: View {
private var iapManager: IAPManager
@EnvironmentObject
private var providerManager: ProviderManager
private var apiManager: APIManager
@ObservedObject
private var profileEditor: ProfileEditor
@ -214,8 +214,8 @@ private extension OpenVPNCredentialsView {
}
func onLoad() {
if let providerId, let provider = providerManager.provider(withId: providerId) {
providerCustomization = provider.customization(for: OpenVPN.Configuration.self)
if let providerId, let provider = apiManager.provider(withId: providerId) {
providerCustomization = provider.customization(for: OpenVPNProviderTemplate.self)
}
builder = credentials?.builder() ?? OpenVPN.Credentials.Builder()
if ignoresPassword {

View File

@ -29,7 +29,7 @@ import SwiftUI
public struct RefreshInfrastructureButton<Label>: View where Label: View {
@EnvironmentObject
private var providerManager: ProviderManager
private var apiManager: APIManager
private let apis: [APIMapper]
@ -46,7 +46,7 @@ public struct RefreshInfrastructureButton<Label>: View where Label: View {
public var body: some View {
Button {
Task {
try await providerManager.fetchVPNInfrastructure(from: apis, for: providerId)
try await apiManager.fetchInfrastructure(from: apis, for: providerId)
}
} label: {
label()
@ -67,13 +67,13 @@ extension RefreshInfrastructureButton where Label == RefreshInfrastructureButton
public struct RefreshInfrastructureButtonProgressView: View {
@EnvironmentObject
private var providerManager: ProviderManager
private var apiManager: APIManager
public var body: some View {
#if os(iOS)
HStack {
Text(Strings.Views.Providers.refreshInfrastructure)
if providerManager.isLoading {
if apiManager.isLoading {
Spacer()
ProgressView()
}

View File

@ -64,12 +64,6 @@ enum Environment {
]
))
}
targets.append(.testTarget(
name: "TargetTests",
dependencies: [
.target(name: targetName)
]
))
return targets
}
}

View File

@ -1,10 +0,0 @@
import PassepartoutKit
import XCTest
final class Tests: XCTestCase {
func test_dummy() {
var profile = Profile.Builder(activatingModules: true)
profile.name = "foobar"
XCTAssertEqual(profile.name, "foobar")
}
}

@ -1 +1 @@
Subproject commit c45a52251b7f5a59f6183bc8a4aae994fdf085e5
Subproject commit 95a6074b3b1eca732a794098622d73c34ba08e0a

View File

@ -122,9 +122,9 @@ extension AppContext {
interval: Constants.shared.tunnel.refreshInterval
)
let providerManager: ProviderManager = {
let repository = AppData.cdProviderRepositoryV3(context: localStore.backgroundContext())
return ProviderManager(repository: repository)
let apiManager: APIManager = {
let repository = AppData.cdAPIRepositoryV3(context: localStore.backgroundContext())
return APIManager(repository: repository)
}()
let migrationManager: MigrationManager = {
@ -209,11 +209,11 @@ extension AppContext {
// MARK: Build
return AppContext(
apiManager: apiManager,
iapManager: iapManager,
migrationManager: migrationManager,
profileManager: profileManager,
providerManager: providerManager,
preferencesManager: preferencesManager,
profileManager: profileManager,
registry: dependencies.registry,
tunnel: tunnel,
tunnelReceiptURL: tunnelReceiptURL,

View File

@ -56,18 +56,18 @@ extension AppContext {
processor: processor,
interval: Constants.shared.tunnel.refreshInterval
)
let providerManager = ProviderManager(
repository: InMemoryProviderRepository()
let apiManager = APIManager(
repository: InMemoryAPIRepository()
)
let migrationManager = MigrationManager()
let preferencesManager = PreferencesManager()
return AppContext(
apiManager: apiManager,
iapManager: iapManager,
migrationManager: migrationManager,
profileManager: profileManager,
providerManager: providerManager,
preferencesManager: preferencesManager,
profileManager: profileManager,
registry: registry,
tunnel: tunnel,
tunnelReceiptURL: BundleConfiguration.urlForBetaReceipt

View File

@ -140,26 +140,26 @@ private extension ProfileManager {
Parameters("Personal DoH", false, false, [.dns, .onDemand])
]
static var mockHideMeEntity: VPNEntity<OpenVPN.Configuration> {
static var mockHideMeEntity: ProviderEntity<OpenVPNProviderTemplate> {
do {
var cfgBuilder = OpenVPN.Configuration.Builder()
cfgBuilder.ca = .init(pem: "...")
let cfg = try cfgBuilder.tryBuild(isClient: false)
let cfgData = try JSONEncoder().encode(cfg)
let preset = AnyVPNPreset(
let preset = AnyProviderPreset(
providerId: .hideme,
presetId: "default",
description: "Default",
endpoints: [.init(.udp, 1194)],
configurationIdentifier: "OpenVPN",
configuration: cfgData
template: cfgData
)
return VPNEntity(
return ProviderEntity(
server: .init(
provider: .init(
id: .hideme,
metadata: .init(
providerId: .hideme,
serverId: "be-v4",
supportedConfigurationIdentifiers: ["OpenVPN"],
supportedPresetIds: nil,
@ -171,7 +171,7 @@ private extension ProfileManager {
hostname: "be-v4.hideservers.net",
ipAddresses: nil
),
preset: try preset.ofType(OpenVPN.Configuration.self)
preset: try preset.ofType(OpenVPNProviderTemplate.self)
)
} catch {
fatalError("Unable to build Hide.me entity: \(error)")