Redo provider managers lifecycle (#732)
Update library with more efficient choices for interacting with the providers API. Fixes #731
This commit is contained in:
parent
a5d4f6aee5
commit
87c7d63678
|
@ -41,8 +41,7 @@
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "ebb142e836e9e2a8a1867c7ae3d4f44b6b96e917",
|
"revision" : "aeb982951e2798863e28f55081dd25e2221083e3"
|
||||||
"version" : "0.9.1"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -27,8 +27,8 @@ let package = Package(
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.1"),
|
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"),
|
||||||
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "0bfd4578b71a905584cdd5c9c39ab3087521af78"),
|
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "aeb982951e2798863e28f55081dd25e2221083e3"),
|
||||||
// .package(path: "../../../passepartoutkit-source"),
|
// .package(path: "../../../passepartoutkit-source"),
|
||||||
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", from: "0.9.1"),
|
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", from: "0.9.1"),
|
||||||
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"),
|
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"),
|
||||||
|
|
|
@ -1,39 +0,0 @@
|
||||||
//
|
|
||||||
// ProviderFactory.swift
|
|
||||||
// Passepartout
|
|
||||||
//
|
|
||||||
// Created by Davide De Rosa on 10/8/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
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
public final class ProviderFactory: ObservableObject {
|
|
||||||
public let providerManager: ProviderManager
|
|
||||||
|
|
||||||
public let vpnProviderManager: VPNProviderManager
|
|
||||||
|
|
||||||
public init(providerManager: ProviderManager, vpnProviderManager: VPNProviderManager) {
|
|
||||||
self.providerManager = providerManager
|
|
||||||
self.vpnProviderManager = vpnProviderManager
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -46,7 +46,7 @@ public final class AppContext: ObservableObject {
|
||||||
|
|
||||||
public let registry: Registry
|
public let registry: Registry
|
||||||
|
|
||||||
public let providerFactory: ProviderFactory
|
public let providerManager: ProviderManager
|
||||||
|
|
||||||
private let constants: Constants
|
private let constants: Constants
|
||||||
|
|
||||||
|
@ -59,7 +59,7 @@ public final class AppContext: ObservableObject {
|
||||||
tunnel: Tunnel,
|
tunnel: Tunnel,
|
||||||
tunnelEnvironment: TunnelEnvironment,
|
tunnelEnvironment: TunnelEnvironment,
|
||||||
registry: Registry,
|
registry: Registry,
|
||||||
providerFactory: ProviderFactory,
|
providerManager: ProviderManager,
|
||||||
constants: Constants
|
constants: Constants
|
||||||
) {
|
) {
|
||||||
self.iapManager = iapManager
|
self.iapManager = iapManager
|
||||||
|
@ -73,7 +73,7 @@ public final class AppContext: ObservableObject {
|
||||||
interval: constants.tunnel.refreshInterval
|
interval: constants.tunnel.refreshInterval
|
||||||
)
|
)
|
||||||
self.registry = registry
|
self.registry = registry
|
||||||
self.providerFactory = providerFactory
|
self.providerManager = providerManager
|
||||||
self.constants = constants
|
self.constants = constants
|
||||||
subscriptions = []
|
subscriptions = []
|
||||||
|
|
||||||
|
|
|
@ -145,13 +145,13 @@ extension OnDemandModule.Policy: LocalizableEntity {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension VPNServer {
|
extension VPNServer {
|
||||||
public var sortableRegion: String {
|
public var region: String {
|
||||||
[countryCodes.first?.localizedAsRegionCode, area]
|
[provider.countryCodes.first?.localizedAsRegionCode, provider.area]
|
||||||
.compactMap { $0 }
|
.compactMap { $0 }
|
||||||
.joined(separator: " - ")
|
.joined(separator: " - ")
|
||||||
}
|
}
|
||||||
|
|
||||||
public var sortableAddresses: String {
|
public var address: String {
|
||||||
if let hostname {
|
if let hostname {
|
||||||
return hostname
|
return hostname
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,9 +62,9 @@ extension AppContext {
|
||||||
tunnel: Tunnel(strategy: FakeTunnelStrategy(environment: env)),
|
tunnel: Tunnel(strategy: FakeTunnelStrategy(environment: env)),
|
||||||
tunnelEnvironment: env,
|
tunnelEnvironment: env,
|
||||||
registry: registry,
|
registry: registry,
|
||||||
providerFactory: ProviderFactory(
|
providerManager: ProviderManager(
|
||||||
providerManager: ProviderManager(repository: InMemoryProviderRepository()),
|
repository: InMemoryProviderRepository(),
|
||||||
vpnProviderManager: VPNProviderManager(repository: InMemoryVPNProviderRepository())
|
vpnRepository: InMemoryVPNProviderRepository()
|
||||||
),
|
),
|
||||||
constants: .shared
|
constants: .shared
|
||||||
)
|
)
|
||||||
|
@ -83,12 +83,6 @@ extension ProfileManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ProviderFactory {
|
|
||||||
public static var mock: ProviderFactory {
|
|
||||||
AppContext.mock.providerFactory
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ProfileProcessor {
|
extension ProfileProcessor {
|
||||||
public static var mock: ProfileProcessor {
|
public static var mock: ProfileProcessor {
|
||||||
AppContext.mock.profileProcessor
|
AppContext.mock.profileProcessor
|
||||||
|
@ -107,6 +101,12 @@ extension ConnectionObserver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ProviderManager {
|
||||||
|
public static var mock: ProviderManager {
|
||||||
|
AppContext.mock.providerManager
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Profile
|
// MARK: - Profile
|
||||||
|
|
||||||
extension Profile {
|
extension Profile {
|
||||||
|
|
|
@ -60,6 +60,9 @@ struct OpenVPNView: View {
|
||||||
@Binding
|
@Binding
|
||||||
private var providerEntity: VPNEntity<OpenVPN.Configuration>?
|
private var providerEntity: VPNEntity<OpenVPN.Configuration>?
|
||||||
|
|
||||||
|
@StateObject
|
||||||
|
private var vpnProviderManager = VPNProviderManager()
|
||||||
|
|
||||||
init(serverConfiguration: OpenVPN.Configuration) {
|
init(serverConfiguration: OpenVPN.Configuration) {
|
||||||
let module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder())
|
let module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder())
|
||||||
let editor = ProfileEditor(modules: [module])
|
let editor = ProfileEditor(modules: [module])
|
||||||
|
@ -99,41 +102,35 @@ private extension OpenVPNView {
|
||||||
var providerModifier: some ViewModifier {
|
var providerModifier: some ViewModifier {
|
||||||
ProviderPanelModifier(
|
ProviderPanelModifier(
|
||||||
isRequired: draft.configurationBuilder == nil,
|
isRequired: draft.configurationBuilder == nil,
|
||||||
|
entityType: VPNEntity<OpenVPN.Configuration>.self,
|
||||||
providerId: $providerId,
|
providerId: $providerId,
|
||||||
selectedEntity: $providerEntity,
|
providerContent: providerContentView,
|
||||||
providerContent: providerContentView
|
onSelectProvider: onSelectProvider
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var contentView: some View {
|
func providerContentView(providerId: ProviderID) -> some View {
|
||||||
credentialsView
|
providerServerRow
|
||||||
if providerId == nil {
|
moduleGroup(for: accountRows)
|
||||||
manualView
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
var providerServerRow: some View {
|
||||||
func providerContentView(providerId: ProviderID, entity: VPNEntity<OpenVPN.Configuration>?) -> some View {
|
NavigationLink(value: Subroute.providerServer) {
|
||||||
NavigationLink(value: Subroute.providerServer(id: providerId)) {
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("Server")
|
Text(Strings.Global.server)
|
||||||
if let entity {
|
if let providerEntity {
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(entity.server.hostname ?? entity.server.serverId)
|
Text(providerEntity.server.hostname ?? providerEntity.server.serverId)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
credentialsView
|
|
||||||
}
|
|
||||||
|
|
||||||
var credentialsView: some View {
|
|
||||||
moduleSection(for: accountRows, header: Strings.Global.account)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var manualView: some View {
|
var contentView: some View {
|
||||||
|
moduleSection(for: accountRows, header: Strings.Global.account)
|
||||||
moduleSection(for: remotesRows, header: Strings.Modules.Openvpn.remotes)
|
moduleSection(for: remotesRows, header: Strings.Modules.Openvpn.remotes)
|
||||||
if !isServerPushed {
|
if !isServerPushed {
|
||||||
moduleSection(for: pullRows, header: Strings.Modules.Openvpn.pull)
|
moduleSection(for: pullRows, header: Strings.Modules.Openvpn.pull)
|
||||||
|
@ -159,6 +156,20 @@ private extension OpenVPNView {
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension OpenVPNView {
|
private extension OpenVPNView {
|
||||||
|
func onSelectProvider(manager: ProviderManager) {
|
||||||
|
guard let providerId else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
vpnProviderManager.view = manager.vpnView(
|
||||||
|
withId: providerId,
|
||||||
|
initialParameters: .init(sorting: [
|
||||||
|
.localizedCountry,
|
||||||
|
.area,
|
||||||
|
.hostname
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func onSelect(server: VPNServer, preset: VPNPreset<OpenVPN.Configuration>) {
|
func onSelect(server: VPNServer, preset: VPNPreset<OpenVPN.Configuration>) {
|
||||||
providerEntity = VPNEntity(server: server, preset: preset)
|
providerEntity = VPNEntity(server: server, preset: preset)
|
||||||
}
|
}
|
||||||
|
@ -172,7 +183,7 @@ private extension OpenVPNView {
|
||||||
|
|
||||||
private extension OpenVPNView {
|
private extension OpenVPNView {
|
||||||
enum Subroute: Hashable {
|
enum Subroute: Hashable {
|
||||||
case providerServer(id: ProviderID)
|
case providerServer
|
||||||
|
|
||||||
case credentials
|
case credentials
|
||||||
}
|
}
|
||||||
|
@ -180,9 +191,9 @@ private extension OpenVPNView {
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func destination(for route: Subroute) -> some View {
|
func destination(for route: Subroute) -> some View {
|
||||||
switch route {
|
switch route {
|
||||||
case .providerServer(let id):
|
case .providerServer:
|
||||||
VPNProviderServerView<OpenVPN.Configuration>(
|
VPNProviderServerView<OpenVPN.Configuration>(
|
||||||
providerId: id,
|
manager: vpnProviderManager,
|
||||||
onSelect: onSelect
|
onSelect: onSelect
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -71,11 +71,17 @@ struct HashableRoute: Hashable {
|
||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
func moduleSection(for rows: [ModuleRow]?, header: String) -> some View {
|
func moduleGroup(for rows: [ModuleRow]?) -> some View {
|
||||||
rows.map { rows in
|
rows.map { rows in
|
||||||
Group {
|
Group {
|
||||||
ForEach(rows, id: \.self, content: moduleRowView)
|
ForEach(rows, id: \.self, content: moduleRowView)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moduleSection(for rows: [ModuleRow]?, header: String) -> some View {
|
||||||
|
rows.map { rows in
|
||||||
|
moduleGroup(for: rows)
|
||||||
.themeSection(header: header)
|
.themeSection(header: header)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,6 +26,9 @@
|
||||||
import AppLibrary
|
import AppLibrary
|
||||||
import PassepartoutKit
|
import PassepartoutKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UtilsLibrary
|
||||||
|
|
||||||
|
// FIXME: #703, providers UI, reorg subviews
|
||||||
|
|
||||||
struct ProviderPanelModifier<Entity, ProviderContent>: ViewModifier where Entity: ProviderEntity, Entity.Configuration: ProviderConfigurationIdentifiable & Codable, ProviderContent: View {
|
struct ProviderPanelModifier<Entity, ProviderContent>: ViewModifier where Entity: ProviderEntity, Entity.Configuration: ProviderConfigurationIdentifiable & Codable, ProviderContent: View {
|
||||||
|
|
||||||
|
@ -36,28 +39,34 @@ struct ProviderPanelModifier<Entity, ProviderContent>: ViewModifier where Entity
|
||||||
|
|
||||||
let isRequired: Bool
|
let isRequired: Bool
|
||||||
|
|
||||||
|
let entityType: Entity.Type
|
||||||
|
|
||||||
@Binding
|
@Binding
|
||||||
var providerId: ProviderID?
|
var providerId: ProviderID?
|
||||||
|
|
||||||
@Binding
|
|
||||||
var selectedEntity: Entity?
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
let providerContent: (ProviderID, Entity?) -> ProviderContent
|
let providerContent: (ProviderID) -> ProviderContent
|
||||||
|
|
||||||
|
let onSelectProvider: (ProviderManager) -> Void
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
|
debugChanges()
|
||||||
|
return Group {
|
||||||
providerPicker
|
providerPicker
|
||||||
.task {
|
.onLoad(perform: loadCurrentProvider)
|
||||||
await refreshIndex()
|
|
||||||
}
|
|
||||||
|
|
||||||
if let providerId {
|
if let providerId {
|
||||||
providerContent(providerId, selectedEntity)
|
providerContent(providerId)
|
||||||
|
.asSectionWithTrailingContent {
|
||||||
|
refreshButton
|
||||||
|
}
|
||||||
|
.disabled(providerManager.isLoading)
|
||||||
} else if !isRequired {
|
} else if !isRequired {
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private extension ProviderPanelModifier {
|
private extension ProviderPanelModifier {
|
||||||
var supportedProviders: [ProviderMetadata] {
|
var supportedProviders: [ProviderMetadata] {
|
||||||
|
@ -70,33 +79,91 @@ private extension ProviderPanelModifier {
|
||||||
let hasProviders = !supportedProviders.isEmpty
|
let hasProviders = !supportedProviders.isEmpty
|
||||||
return Picker(Strings.Global.provider, selection: $providerId) {
|
return Picker(Strings.Global.provider, selection: $providerId) {
|
||||||
if hasProviders {
|
if hasProviders {
|
||||||
Text(Strings.Global.none)
|
// FIXME: #703, providers UI
|
||||||
|
Text("Select a provider")
|
||||||
.tag(nil as ProviderID?)
|
.tag(nil as ProviderID?)
|
||||||
ForEach(supportedProviders, id: \.id) {
|
ForEach(supportedProviders, id: \.id) {
|
||||||
Text($0.description)
|
Text($0.description)
|
||||||
.tag($0.id as ProviderID?)
|
.tag($0.id as ProviderID?)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(" ") // enforce constant picker height on iOS
|
// enforce constant picker height on iOS
|
||||||
|
Text(providerManager.isLoading ? "..." : "Unavailable")
|
||||||
.tag(providerId) // tag always exists
|
.tag(providerId) // tag always exists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: providerId) { _ in
|
.onChange(of: providerId) { newId in
|
||||||
selectedEntity = nil
|
Task {
|
||||||
|
if let newId {
|
||||||
|
await refreshInfrastructure(for: newId)
|
||||||
|
}
|
||||||
|
onSelectProvider(providerManager)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.disabled(!hasProviders)
|
.disabled(!hasProviders)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var refreshButton: some View {
|
||||||
|
Button {
|
||||||
|
guard let providerId else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task {
|
||||||
|
await refreshInfrastructure(for: providerId)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(Strings.Views.Provider.Vpn.refreshInfrastructure)
|
||||||
|
#if os(iOS)
|
||||||
|
if let providerId, providerManager.pendingServices.contains(.provider(providerId)) {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.disabled(providerManager.isLoading || providerId == nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ProviderPanelModifier {
|
private extension ProviderPanelModifier {
|
||||||
|
func loadCurrentProvider() {
|
||||||
|
Task {
|
||||||
|
if let providerId {
|
||||||
|
async let index = await refreshIndex()
|
||||||
|
async let provider = await refreshInfrastructure(for: providerId)
|
||||||
|
_ = await (index, provider)
|
||||||
|
onSelectProvider(providerManager)
|
||||||
|
} else {
|
||||||
|
await refreshIndex()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FIXME: #707, fetch bundled providers on launch
|
@discardableResult
|
||||||
// FIXME: #704, rate-limit fetch
|
func refreshIndex() async -> Bool {
|
||||||
func refreshIndex() async {
|
|
||||||
do {
|
do {
|
||||||
try await providerManager.fetchIndex(from: apis)
|
try await providerManager.fetchIndex(from: apis)
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
pp_log(.app, .error, "Unable to fetch index: \(error)")
|
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,
|
||||||
|
ofType: Entity.Configuration.self
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
// FIXME: #703, alert unable to refresh infrastructure
|
||||||
|
pp_log(.app, .error, "Unable to refresh infrastructure: \(error)")
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -110,26 +177,17 @@ private extension ProviderID {
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
@State
|
List {
|
||||||
var providerId: ProviderID? = .hideme
|
|
||||||
|
|
||||||
@State
|
|
||||||
var vpnEntity: VPNEntity<OpenVPN.Configuration>?
|
|
||||||
|
|
||||||
return List {
|
|
||||||
EmptyView()
|
EmptyView()
|
||||||
.modifier(ProviderPanelModifier(
|
.modifier(ProviderPanelModifier(
|
||||||
apis: [API.bundled],
|
apis: [API.bundled],
|
||||||
isRequired: false,
|
isRequired: false,
|
||||||
providerId: $providerId,
|
entityType: VPNEntity<OpenVPN.Configuration>.self,
|
||||||
selectedEntity: $vpnEntity,
|
providerId: .constant(.hideme),
|
||||||
providerContent: { _, entity in
|
providerContent: { _ in
|
||||||
HStack {
|
|
||||||
Text("Server")
|
Text("Server")
|
||||||
Spacer()
|
},
|
||||||
Text(entity?.server.serverId ?? "None")
|
onSelectProvider: { _ in }
|
||||||
}
|
|
||||||
}
|
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
.withMockEnvironment()
|
.withMockEnvironment()
|
||||||
|
|
|
@ -31,16 +31,12 @@ struct VPNFiltersModifier<Configuration>: ViewModifier where Configuration: Deco
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
var manager: VPNProviderManager
|
var manager: VPNProviderManager
|
||||||
|
|
||||||
let providerId: ProviderID
|
|
||||||
|
|
||||||
let onRefresh: () async -> Void
|
|
||||||
|
|
||||||
@State
|
@State
|
||||||
var isFiltersPresented = false
|
var isFiltersPresented = false
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
contentView(with: content)
|
contentView(with: content)
|
||||||
.onChange(of: manager.filters) { _ in
|
.onChange(of: manager.parameters.filters) { _ in
|
||||||
Task {
|
Task {
|
||||||
await manager.applyFilters()
|
await manager.applyFilters()
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,10 +34,6 @@ struct VPNFiltersView<Configuration>: View where Configuration: Decodable {
|
||||||
@ObservedObject
|
@ObservedObject
|
||||||
var manager: VPNProviderManager
|
var manager: VPNProviderManager
|
||||||
|
|
||||||
let providerId: ProviderID
|
|
||||||
|
|
||||||
let onRefresh: () async -> Void
|
|
||||||
|
|
||||||
@State
|
@State
|
||||||
private var isRefreshing = false
|
private var isRefreshing = false
|
||||||
|
|
||||||
|
@ -54,22 +50,16 @@ struct VPNFiltersView<Configuration>: View where Configuration: Decodable {
|
||||||
HStack {
|
HStack {
|
||||||
Spacer()
|
Spacer()
|
||||||
clearFiltersButton
|
clearFiltersButton
|
||||||
refreshButton
|
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
|
||||||
Section {
|
|
||||||
refreshButton
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension VPNFiltersView {
|
private extension VPNFiltersView {
|
||||||
var categoryPicker: some View {
|
var categoryPicker: some View {
|
||||||
Picker("Category", selection: $manager.filters.categoryName) {
|
Picker("Category", selection: $manager.parameters.filters.categoryName) {
|
||||||
Text("Any")
|
Text("Any")
|
||||||
.tag(nil as String?)
|
.tag(nil as String?)
|
||||||
ForEach(categories, id: \.self) {
|
ForEach(categories, id: \.self) {
|
||||||
|
@ -80,7 +70,7 @@ private extension VPNFiltersView {
|
||||||
}
|
}
|
||||||
|
|
||||||
var countryPicker: some View {
|
var countryPicker: some View {
|
||||||
Picker("Country", selection: $manager.filters.countryCode) {
|
Picker("Country", selection: $manager.parameters.filters.countryCode) {
|
||||||
Text("Any")
|
Text("Any")
|
||||||
.tag(nil as String?)
|
.tag(nil as String?)
|
||||||
ForEach(countries, id: \.code) {
|
ForEach(countries, id: \.code) {
|
||||||
|
@ -92,8 +82,8 @@ private extension VPNFiltersView {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var presetPicker: some View {
|
var presetPicker: some View {
|
||||||
if manager.anyPresets.count > 1 {
|
if manager.allPresets.count > 1 {
|
||||||
Picker("Preset", selection: $manager.filters.presetId) {
|
Picker("Preset", selection: $manager.parameters.filters.presetId) {
|
||||||
Text("Any")
|
Text("Any")
|
||||||
.tag(nil as String?)
|
.tag(nil as String?)
|
||||||
ForEach(presets, id: \.presetId) {
|
ForEach(presets, id: \.presetId) {
|
||||||
|
@ -111,27 +101,6 @@ private extension VPNFiltersView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var refreshButton: some View {
|
|
||||||
Button {
|
|
||||||
Task {
|
|
||||||
isRefreshing = true
|
|
||||||
await onRefresh()
|
|
||||||
isRefreshing = false
|
|
||||||
}
|
|
||||||
} label: {
|
|
||||||
HStack {
|
|
||||||
Text(Strings.Views.Provider.Vpn.refreshInfrastructure)
|
|
||||||
#if os(iOS)
|
|
||||||
if isRefreshing {
|
|
||||||
Spacer()
|
|
||||||
ProgressView()
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.disabled(isRefreshing)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension VPNFiltersView {
|
private extension VPNFiltersView {
|
||||||
|
@ -149,7 +118,7 @@ private extension VPNFiltersView {
|
||||||
let allCodes = manager
|
let allCodes = manager
|
||||||
.allServers
|
.allServers
|
||||||
.values
|
.values
|
||||||
.flatMap(\.countryCodes)
|
.flatMap(\.provider.countryCodes)
|
||||||
|
|
||||||
return Set(allCodes)
|
return Set(allCodes)
|
||||||
.map {
|
.map {
|
||||||
|
@ -171,10 +140,6 @@ private extension VPNFiltersView {
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VPNFiltersView<String>(
|
VPNFiltersView<String>(manager: VPNProviderManager())
|
||||||
manager: ProviderFactory.mock.vpnProviderManager,
|
|
||||||
providerId: .hideme,
|
|
||||||
onRefresh: {}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,66 +29,24 @@ import SwiftUI
|
||||||
|
|
||||||
struct VPNProviderServerView<Configuration>: View where Configuration: ProviderConfigurationIdentifiable & Hashable & Codable {
|
struct VPNProviderServerView<Configuration>: View where Configuration: ProviderConfigurationIdentifiable & Hashable & Codable {
|
||||||
|
|
||||||
@EnvironmentObject
|
|
||||||
private var providerManager: ProviderManager
|
|
||||||
|
|
||||||
@EnvironmentObject
|
|
||||||
private var vpnProviderManager: VPNProviderManager
|
|
||||||
|
|
||||||
@Environment(\.dismiss)
|
@Environment(\.dismiss)
|
||||||
private var dismiss
|
private var dismiss
|
||||||
|
|
||||||
var apis: [APIMapper] = API.shared
|
@ObservedObject
|
||||||
|
var manager: VPNProviderManager
|
||||||
let providerId: ProviderID
|
|
||||||
|
|
||||||
let onSelect: (_ server: VPNServer, _ preset: VPNPreset<Configuration>) -> Void
|
let onSelect: (_ server: VPNServer, _ preset: VPNPreset<Configuration>) -> Void
|
||||||
|
|
||||||
@State
|
|
||||||
private var isLoading = true
|
|
||||||
|
|
||||||
@State
|
|
||||||
var sortOrder = [
|
|
||||||
KeyPathComparator(\VPNServer.sortableRegion)
|
|
||||||
]
|
|
||||||
|
|
||||||
@State
|
|
||||||
var sortedServers: [VPNServer] = []
|
|
||||||
|
|
||||||
// FIXME: #703, flickers on appear
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
serversView
|
serversView
|
||||||
.modifier(VPNFiltersModifier<Configuration>(
|
.modifier(VPNFiltersModifier<Configuration>(manager: manager))
|
||||||
manager: vpnProviderManager,
|
.navigationTitle(Strings.Global.servers)
|
||||||
providerId: providerId,
|
|
||||||
onRefresh: {
|
|
||||||
await refreshInfrastructure(for: providerId)
|
|
||||||
}
|
|
||||||
))
|
|
||||||
.themeAnimation(on: isLoading, category: .providers)
|
|
||||||
.navigationTitle(providerMetadata?.description ?? Strings.Global.servers)
|
|
||||||
.task {
|
|
||||||
await loadInfrastructure(for: providerId)
|
|
||||||
}
|
|
||||||
.onReceive(vpnProviderManager.$filteredServers, perform: onFilteredServers)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension VPNProviderServerView {
|
|
||||||
var providerMetadata: ProviderMetadata? {
|
|
||||||
providerManager.metadata(withId: providerId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
extension VPNProviderServerView {
|
extension VPNProviderServerView {
|
||||||
func onFilteredServers(_ servers: [String: VPNServer]) {
|
|
||||||
sortedServers = servers
|
|
||||||
.values
|
|
||||||
.sorted(using: sortOrder)
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectServer(_ server: VPNServer) {
|
func selectServer(_ server: VPNServer) {
|
||||||
guard let preset = compatiblePreset(with: server) else {
|
guard let preset = compatiblePreset(with: server) else {
|
||||||
// FIXME: #703, alert select a preset
|
// FIXME: #703, alert select a preset
|
||||||
|
@ -101,7 +59,7 @@ extension VPNProviderServerView {
|
||||||
|
|
||||||
private extension VPNProviderServerView {
|
private extension VPNProviderServerView {
|
||||||
func compatiblePreset(with server: VPNServer) -> VPNPreset<Configuration>? {
|
func compatiblePreset(with server: VPNServer) -> VPNPreset<Configuration>? {
|
||||||
vpnProviderManager
|
manager
|
||||||
.presets(ofType: Configuration.self)
|
.presets(ofType: Configuration.self)
|
||||||
.first {
|
.first {
|
||||||
if let supportedIds = server.provider.supportedPresetIds {
|
if let supportedIds = server.provider.supportedPresetIds {
|
||||||
|
@ -110,38 +68,13 @@ private extension VPNProviderServerView {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadInfrastructure(for providerId: ProviderID) async {
|
|
||||||
await vpnProviderManager.setProvider(providerId)
|
|
||||||
if await vpnProviderManager.lastUpdated() == nil {
|
|
||||||
await refreshInfrastructure(for: providerId)
|
|
||||||
}
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIXME: #704, rate-limit fetch
|
|
||||||
func refreshInfrastructure(for providerId: ProviderID) async {
|
|
||||||
do {
|
|
||||||
isLoading = true
|
|
||||||
try await vpnProviderManager.fetchInfrastructure(
|
|
||||||
from: apis,
|
|
||||||
for: providerId,
|
|
||||||
ofType: Configuration.self
|
|
||||||
)
|
|
||||||
isLoading = false
|
|
||||||
} catch {
|
|
||||||
// FIXME: #703, alert unable to refresh infrastructure
|
|
||||||
pp_log(.app, .error, "Unable to refresh infrastructure: \(error)")
|
|
||||||
isLoading = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VPNProviderServerView<OpenVPN.Configuration>(apis: [API.bundled], providerId: .protonvpn) { _, _ in
|
VPNProviderServerView<OpenVPN.Configuration>(manager: VPNProviderManager()) { _, _ in
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.withMockEnvironment()
|
.withMockEnvironment()
|
||||||
|
|
|
@ -43,7 +43,7 @@ extension VPNFiltersModifier {
|
||||||
}
|
}
|
||||||
.themeModal(isPresented: $isFiltersPresented) {
|
.themeModal(isPresented: $isFiltersPresented) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
VPNFiltersView<Configuration>(manager: manager, providerId: providerId, onRefresh: onRefresh)
|
VPNFiltersView<Configuration>(manager: manager)
|
||||||
.navigationTitle("Filters")
|
.navigationTitle("Filters")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,15 +30,15 @@ import SwiftUI
|
||||||
// FIXME: #703, providers UI
|
// FIXME: #703, providers UI
|
||||||
|
|
||||||
extension VPNProviderServerView {
|
extension VPNProviderServerView {
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
var serversView: some View {
|
var serversView: some View {
|
||||||
sortedServers.nilIfEmpty.map { servers in
|
ForEach(manager.filteredServers) { server in
|
||||||
ForEach(sortedServers) { server in
|
Button("\(server.hostname ?? server.id) \(server.provider.countryCodes)") {
|
||||||
Button("\(server.hostname ?? server.id) \(server.countryCodes)") {
|
|
||||||
selectServer(server)
|
selectServer(server)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
@ -30,7 +30,7 @@ import SwiftUI
|
||||||
extension VPNFiltersModifier {
|
extension VPNFiltersModifier {
|
||||||
func contentView(with content: Content) -> some View {
|
func contentView(with content: Content) -> some View {
|
||||||
VStack {
|
VStack {
|
||||||
VPNFiltersView<Configuration>(manager: manager, providerId: providerId, onRefresh: onRefresh)
|
VPNFiltersView<Configuration>(manager: manager)
|
||||||
.padding()
|
.padding()
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,14 +30,18 @@ import SwiftUI
|
||||||
// FIXME: #703, providers UI
|
// FIXME: #703, providers UI
|
||||||
|
|
||||||
extension VPNProviderServerView {
|
extension VPNProviderServerView {
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
var serversView: some View {
|
var serversView: some View {
|
||||||
Table(sortedServers, sortOrder: $sortOrder) {
|
Table(manager.filteredServers) {
|
||||||
TableColumn("Region", value: \.sortableRegion)
|
TableColumn("Region") { server in
|
||||||
|
Text(server.region)
|
||||||
|
}
|
||||||
.width(max: 200.0)
|
.width(max: 200.0)
|
||||||
|
|
||||||
TableColumn("Address", value: \.sortableAddresses)
|
TableColumn("Address", value: \.address)
|
||||||
|
|
||||||
TableColumn("", value: \.serverId) { server in
|
TableColumn("") { server in
|
||||||
Button {
|
Button {
|
||||||
selectServer(server)
|
selectServer(server)
|
||||||
} label: {
|
} label: {
|
||||||
|
|
|
@ -31,10 +31,9 @@ extension View {
|
||||||
public func withEnvironment(from context: AppContext, theme: Theme) -> some View {
|
public func withEnvironment(from context: AppContext, theme: Theme) -> some View {
|
||||||
environmentObject(theme)
|
environmentObject(theme)
|
||||||
.environmentObject(context.iapManager)
|
.environmentObject(context.iapManager)
|
||||||
.environmentObject(context.connectionObserver)
|
|
||||||
.environmentObject(context.providerFactory.providerManager)
|
|
||||||
.environmentObject(context.providerFactory.vpnProviderManager)
|
|
||||||
.environmentObject(context.profileProcessor)
|
.environmentObject(context.profileProcessor)
|
||||||
|
.environmentObject(context.connectionObserver)
|
||||||
|
.environmentObject(context.providerManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func withMockEnvironment() -> some View {
|
public func withMockEnvironment() -> some View {
|
||||||
|
|
|
@ -63,7 +63,7 @@ extension API {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
[API.bundled]
|
[API.bundled]
|
||||||
#else
|
#else
|
||||||
[API.remoteThenBundled]
|
API.remoteThenBundled
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -40,7 +40,7 @@ extension AppContext {
|
||||||
tunnel: .shared,
|
tunnel: .shared,
|
||||||
tunnelEnvironment: .shared,
|
tunnelEnvironment: .shared,
|
||||||
registry: .shared,
|
registry: .shared,
|
||||||
providerFactory: .shared,
|
providerManager: .shared,
|
||||||
constants: .shared
|
constants: .shared
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -208,10 +208,10 @@ private extension ProfileManager {
|
||||||
// MARK: -
|
// MARK: -
|
||||||
|
|
||||||
// FIXME: #705, store providers to Core Data
|
// FIXME: #705, store providers to Core Data
|
||||||
extension ProviderFactory {
|
extension ProviderManager {
|
||||||
static let shared = ProviderFactory(
|
static let shared = ProviderManager(
|
||||||
providerManager: ProviderManager(repository: InMemoryProviderRepository()),
|
repository: InMemoryProviderRepository(),
|
||||||
vpnProviderManager: VPNProviderManager(repository: InMemoryVPNProviderRepository())
|
vpnRepository: InMemoryVPNProviderRepository()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue