Rework OpenVPN view with provider modifiers (#733)
Improve rendering and work around some SwiftUI bugs, e.g. with .menu Picker on iOS (use .navigationLink instead). Here goes the hierarchy bottom-up: - ProviderPicker: a Picker wrapper built around ProviderManager - ProviderContentModifier: adds a ProviderPicker on top and replaces the content with a set of provider selectors when a provider is selected - VPNProviderContentModifier: wrapper for ProviderContentModifier that adds a VPN server selector - OpenVPNView: provides a view of specific OpenVPN settings, and adds a credentials selector to the provider/server selectors provided by VPNProviderContentModifier
This commit is contained in:
parent
87c7d63678
commit
ed28126cf7
|
@ -41,7 +41,7 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||
"state" : {
|
||||
"revision" : "aeb982951e2798863e28f55081dd25e2221083e3"
|
||||
"revision" : "c4182832032fab8fef24386d209572a2c288e28e"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -28,7 +28,7 @@ let package = Package(
|
|||
],
|
||||
dependencies: [
|
||||
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"),
|
||||
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "aeb982951e2798863e28f55081dd25e2221083e3"),
|
||||
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "c4182832032fab8fef24386d209572a2c288e28e"),
|
||||
// .package(path: "../../../passepartoutkit-source"),
|
||||
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", from: "0.9.1"),
|
||||
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"),
|
||||
|
|
|
@ -209,6 +209,8 @@ public enum Strings {
|
|||
public static let keepAlive = Strings.tr("Localizable", "global.keep_alive", fallback: "Keep-alive")
|
||||
/// Key
|
||||
public static let key = Strings.tr("Localizable", "global.key", fallback: "Key")
|
||||
/// Loading
|
||||
public static let loading = Strings.tr("Localizable", "global.loading", fallback: "Loading")
|
||||
/// Method
|
||||
public static let method = Strings.tr("Localizable", "global.method", fallback: "Method")
|
||||
/// Modules
|
||||
|
@ -588,9 +590,19 @@ public enum Strings {
|
|||
}
|
||||
}
|
||||
public enum Provider {
|
||||
public enum Vpn {
|
||||
/// Last updated on %@
|
||||
public static func lastUpdated(_ p1: Any) -> String {
|
||||
return Strings.tr("Localizable", "views.provider.last_updated", String(describing: p1), fallback: "Last updated on %@")
|
||||
}
|
||||
/// None
|
||||
public static let noProvider = Strings.tr("Localizable", "views.provider.no_provider", fallback: "None")
|
||||
/// Refresh infrastructure
|
||||
public static let refreshInfrastructure = Strings.tr("Localizable", "views.provider.vpn.refresh_infrastructure", fallback: "Refresh infrastructure")
|
||||
public static let refreshInfrastructure = Strings.tr("Localizable", "views.provider.refresh_infrastructure", fallback: "Refresh infrastructure")
|
||||
/// Select a provider
|
||||
public static let selectProvider = Strings.tr("Localizable", "views.provider.select_provider", fallback: "Select a provider")
|
||||
public enum LastUpdated {
|
||||
/// Loading...
|
||||
public static let loading = Strings.tr("Localizable", "views.provider.last_updated.loading", fallback: "Loading...")
|
||||
}
|
||||
}
|
||||
public enum Settings {
|
||||
|
|
|
@ -31,6 +31,7 @@
|
|||
"global.interface" = "Interface";
|
||||
"global.keep_alive" = "Keep-alive";
|
||||
"global.key" = "Key";
|
||||
"global.loading" = "Loading";
|
||||
"global.method" = "Method";
|
||||
"global.modules" = "Modules";
|
||||
"global.n_seconds" = "%d seconds";
|
||||
|
@ -128,7 +129,11 @@
|
|||
"views.profile.rows.add_module" = "Add module";
|
||||
"views.profile.module_list.section.footer" = "Drag modules to rearrange them, as their order determines priority.";
|
||||
|
||||
"views.provider.vpn.refresh_infrastructure" = "Refresh infrastructure";
|
||||
"views.provider.no_provider" = "None";
|
||||
"views.provider.select_provider" = "Select a provider";
|
||||
"views.provider.refresh_infrastructure" = "Refresh infrastructure";
|
||||
"views.provider.last_updated" = "Last updated on %@";
|
||||
"views.provider.last_updated.loading" = "Loading...";
|
||||
|
||||
"views.settings.sections.icloud.footer" = "To erase the iCloud store securely, do so on all your synced devices. This will not affect local profiles.";
|
||||
"views.settings.rows.confirm_quit" = "Ask before quit";
|
||||
|
|
|
@ -54,41 +54,27 @@ struct OpenVPNView: View {
|
|||
@Binding
|
||||
private var draft: OpenVPNModule.Builder
|
||||
|
||||
@Binding
|
||||
private var providerId: ProviderID?
|
||||
|
||||
@Binding
|
||||
private var providerEntity: VPNEntity<OpenVPN.Configuration>?
|
||||
|
||||
@StateObject
|
||||
private var vpnProviderManager = VPNProviderManager()
|
||||
|
||||
init(serverConfiguration: OpenVPN.Configuration) {
|
||||
let module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder())
|
||||
let editor = ProfileEditor(modules: [module])
|
||||
|
||||
self.editor = editor
|
||||
_draft = .constant(module)
|
||||
_providerId = .constant(nil)
|
||||
_providerEntity = .constant(nil)
|
||||
isServerPushed = true
|
||||
}
|
||||
|
||||
init(editor: ProfileEditor, module: OpenVPNModule.Builder) {
|
||||
self.editor = editor
|
||||
_draft = editor.binding(forModule: module)
|
||||
_providerId = editor.binding(forProviderOf: module.id)
|
||||
_providerEntity = editor.binding(forProviderEntityOf: module.id)
|
||||
isServerPushed = false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
debugChanges()
|
||||
return contentView
|
||||
manualView
|
||||
.modifier(providerModifier)
|
||||
.themeAnimation(on: editor.profile.modulesMetadata, category: .modules)
|
||||
.moduleView(editor: editor, draft: draft, withName: !isServerPushed)
|
||||
.navigationDestination(for: Subroute.self, destination: destination)
|
||||
.themeAnimation(on: providerId, category: .modules)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -100,36 +86,53 @@ private extension OpenVPNView {
|
|||
}
|
||||
|
||||
var providerModifier: some ViewModifier {
|
||||
ProviderPanelModifier(
|
||||
VPNProviderContentModifier(
|
||||
providerId: editor.binding(forProviderOf: draft.id),
|
||||
selectedEntity: editor.binding(forProviderEntityOf: draft.id),
|
||||
configurationType: OpenVPN.Configuration.self,
|
||||
isRequired: draft.configurationBuilder == nil,
|
||||
entityType: VPNEntity<OpenVPN.Configuration>.self,
|
||||
providerId: $providerId,
|
||||
providerContent: providerContentView,
|
||||
onSelectProvider: onSelectProvider
|
||||
providerRows: {
|
||||
moduleGroup(for: providerAccountRows)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func providerContentView(providerId: ProviderID) -> some View {
|
||||
providerServerRow
|
||||
moduleGroup(for: accountRows)
|
||||
var providerAccountRows: [ModuleRow]? {
|
||||
[.push(caption: Strings.Modules.Openvpn.credentials, route: HashableRoute(Subroute.credentials))]
|
||||
}
|
||||
}
|
||||
|
||||
var providerServerRow: some View {
|
||||
NavigationLink(value: Subroute.providerServer) {
|
||||
HStack {
|
||||
Text(Strings.Global.server)
|
||||
if let providerEntity {
|
||||
Spacer()
|
||||
Text(providerEntity.server.hostname ?? providerEntity.server.serverId)
|
||||
.foregroundStyle(.secondary)
|
||||
private extension OpenVPNView {
|
||||
func importConfiguration(from url: URL) {
|
||||
// TODO: #657, import draft from external URL
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Destinations
|
||||
|
||||
private extension OpenVPNView {
|
||||
enum Subroute: Hashable {
|
||||
case credentials
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func destination(for route: Subroute) -> some View {
|
||||
switch route {
|
||||
case .credentials:
|
||||
CredentialsView(
|
||||
isInteractive: $draft.isInteractive,
|
||||
credentials: $draft.credentials
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Manual configuration
|
||||
|
||||
private extension OpenVPNView {
|
||||
|
||||
@ViewBuilder
|
||||
var contentView: some View {
|
||||
var manualView: some View {
|
||||
moduleSection(for: accountRows, header: Strings.Global.account)
|
||||
moduleSection(for: remotesRows, header: Strings.Modules.Openvpn.remotes)
|
||||
if !isServerPushed {
|
||||
|
@ -153,64 +156,9 @@ private extension OpenVPNView {
|
|||
}
|
||||
moduleSection(for: otherRows, header: Strings.Global.other)
|
||||
}
|
||||
}
|
||||
|
||||
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>) {
|
||||
providerEntity = VPNEntity(server: server, preset: preset)
|
||||
}
|
||||
|
||||
func importConfiguration(from url: URL) {
|
||||
// TODO: #657, import draft from external URL
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Destinations
|
||||
|
||||
private extension OpenVPNView {
|
||||
enum Subroute: Hashable {
|
||||
case providerServer
|
||||
|
||||
case credentials
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func destination(for route: Subroute) -> some View {
|
||||
switch route {
|
||||
case .providerServer:
|
||||
VPNProviderServerView<OpenVPN.Configuration>(
|
||||
manager: vpnProviderManager,
|
||||
onSelect: onSelect
|
||||
)
|
||||
|
||||
case .credentials:
|
||||
CredentialsView(
|
||||
isInteractive: $draft.isInteractive,
|
||||
credentials: $draft.credentials
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
private extension OpenVPNView {
|
||||
var accountRows: [ModuleRow]? {
|
||||
guard configuration.authUserPass == true || providerId != nil else {
|
||||
guard configuration.authUserPass == true else {
|
||||
return nil
|
||||
}
|
||||
return [.push(caption: Strings.Modules.Openvpn.credentials, route: HashableRoute(Subroute.credentials))]
|
||||
|
|
|
@ -0,0 +1,215 @@
|
|||
//
|
||||
// ProviderContentModifier.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/14/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 PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
struct ProviderContentModifier<Entity, ProviderRows>: ViewModifier where Entity: ProviderEntity, Entity.Configuration: ProviderConfigurationIdentifiable & Codable, ProviderRows: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var providerManager: ProviderManager
|
||||
|
||||
var apis: [APIMapper] = API.shared
|
||||
|
||||
@Binding
|
||||
var providerId: ProviderID?
|
||||
|
||||
let entityType: Entity.Type
|
||||
|
||||
let isRequired: Bool
|
||||
|
||||
@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)
|
||||
}
|
||||
onSelectProvider(providerManager, newId, false)
|
||||
}
|
||||
}
|
||||
.disabled(providerManager.isLoading)
|
||||
|
||||
if providerId == nil && !isRequired {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.providerId == rhs.providerId
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProviderContentModifier {
|
||||
|
||||
#if os(iOS)
|
||||
var providerView: some View {
|
||||
Group {
|
||||
providerPicker
|
||||
if providerId != nil {
|
||||
providerRows
|
||||
refreshButton {
|
||||
HStack {
|
||||
Text(Strings.Views.Provider.refreshInfrastructure)
|
||||
if providerManager.isLoading {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.themeSection(footer: lastUpdatedString)
|
||||
}
|
||||
#else
|
||||
var providerView: some View {
|
||||
Group {
|
||||
providerPicker
|
||||
if providerId != nil {
|
||||
providerRows
|
||||
HStack {
|
||||
lastUpdatedString.map {
|
||||
Text($0)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
refreshButton {
|
||||
Text(Strings.Views.Provider.refreshInfrastructure)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
var providerPicker: some View {
|
||||
ProviderPicker(
|
||||
providers: supportedProviders,
|
||||
providerId: $providerId,
|
||||
isRequired: isRequired,
|
||||
isLoading: providerManager.isLoading
|
||||
)
|
||||
}
|
||||
|
||||
func refreshButton<Label>(label: () -> Label) -> some View where Label: View {
|
||||
Button(action: onRefreshInfrastructure, label: label)
|
||||
}
|
||||
|
||||
var supportedProviders: [ProviderMetadata] {
|
||||
providerManager.providers.filter {
|
||||
$0.supports(Entity.Configuration.self)
|
||||
}
|
||||
}
|
||||
|
||||
var lastUpdated: Date? {
|
||||
guard let providerId else {
|
||||
return nil
|
||||
}
|
||||
return providerManager.lastUpdated(
|
||||
for: providerId,
|
||||
configurationType: Entity.Configuration.self
|
||||
)
|
||||
}
|
||||
|
||||
var lastUpdatedString: String? {
|
||||
guard let lastUpdated else {
|
||||
return providerManager.isLoading ? Strings.Views.Provider.LastUpdated.loading : nil
|
||||
}
|
||||
return Strings.Views.Provider.lastUpdated(lastUpdated.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProviderContentModifier {
|
||||
func loadCurrentProvider() {
|
||||
Task {
|
||||
if let providerId {
|
||||
async let index = await refreshIndex()
|
||||
async let provider = await refreshInfrastructure(for: providerId)
|
||||
_ = await (index, provider)
|
||||
onSelectProvider(providerManager, providerId, true)
|
||||
} else {
|
||||
await refreshIndex()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
ofType: Entity.Configuration.self
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
pp_log(.app, .error, "Unable to refresh infrastructure: \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func onRefreshInfrastructure() {
|
||||
guard let providerId else {
|
||||
return
|
||||
}
|
||||
Task {
|
||||
await refreshInfrastructure(for: providerId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
List {
|
||||
EmptyView()
|
||||
.modifier(ProviderContentModifier(
|
||||
apis: [API.bundled],
|
||||
providerId: .constant(.hideme),
|
||||
entityType: VPNEntity<OpenVPN.Configuration>.self,
|
||||
isRequired: false,
|
||||
providerRows: {},
|
||||
onSelectProvider: { _, _, _ in }
|
||||
))
|
||||
}
|
||||
.withMockEnvironment()
|
||||
}
|
|
@ -1,194 +0,0 @@
|
|||
//
|
||||
// ProviderPanelModifier.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/7/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 AppLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
import UtilsLibrary
|
||||
|
||||
// FIXME: #703, providers UI, reorg subviews
|
||||
|
||||
struct ProviderPanelModifier<Entity, ProviderContent>: ViewModifier where Entity: ProviderEntity, Entity.Configuration: ProviderConfigurationIdentifiable & Codable, ProviderContent: View {
|
||||
|
||||
@EnvironmentObject
|
||||
private var providerManager: ProviderManager
|
||||
|
||||
var apis: [APIMapper] = API.shared
|
||||
|
||||
let isRequired: Bool
|
||||
|
||||
let entityType: Entity.Type
|
||||
|
||||
@Binding
|
||||
var providerId: ProviderID?
|
||||
|
||||
@ViewBuilder
|
||||
let providerContent: (ProviderID) -> ProviderContent
|
||||
|
||||
let onSelectProvider: (ProviderManager) -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
debugChanges()
|
||||
return Group {
|
||||
providerPicker
|
||||
.onLoad(perform: loadCurrentProvider)
|
||||
|
||||
if let providerId {
|
||||
providerContent(providerId)
|
||||
.asSectionWithTrailingContent {
|
||||
refreshButton
|
||||
}
|
||||
.disabled(providerManager.isLoading)
|
||||
} else if !isRequired {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProviderPanelModifier {
|
||||
var supportedProviders: [ProviderMetadata] {
|
||||
providerManager.providers.filter {
|
||||
$0.supports(Entity.Configuration.self)
|
||||
}
|
||||
}
|
||||
|
||||
var providerPicker: some View {
|
||||
let hasProviders = !supportedProviders.isEmpty
|
||||
return Picker(Strings.Global.provider, selection: $providerId) {
|
||||
if hasProviders {
|
||||
// FIXME: #703, providers UI
|
||||
Text("Select a provider")
|
||||
.tag(nil as ProviderID?)
|
||||
ForEach(supportedProviders, id: \.id) {
|
||||
Text($0.description)
|
||||
.tag($0.id as ProviderID?)
|
||||
}
|
||||
} else {
|
||||
// enforce constant picker height on iOS
|
||||
Text(providerManager.isLoading ? "..." : "Unavailable")
|
||||
.tag(providerId) // tag always exists
|
||||
}
|
||||
}
|
||||
.onChange(of: providerId) { newId in
|
||||
Task {
|
||||
if let newId {
|
||||
await refreshInfrastructure(for: newId)
|
||||
}
|
||||
onSelectProvider(providerManager)
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension ProviderID {
|
||||
var nilIfEmpty: ProviderID? {
|
||||
!rawValue.isEmpty ? self : nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
List {
|
||||
EmptyView()
|
||||
.modifier(ProviderPanelModifier(
|
||||
apis: [API.bundled],
|
||||
isRequired: false,
|
||||
entityType: VPNEntity<OpenVPN.Configuration>.self,
|
||||
providerId: .constant(.hideme),
|
||||
providerContent: { _ in
|
||||
Text("Server")
|
||||
},
|
||||
onSelectProvider: { _ in }
|
||||
))
|
||||
}
|
||||
.withMockEnvironment()
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// ProviderPicker.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/15/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 PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
struct ProviderPicker: View {
|
||||
let providers: [ProviderMetadata]
|
||||
|
||||
@Binding
|
||||
var providerId: ProviderID?
|
||||
|
||||
let isRequired: Bool
|
||||
|
||||
let isLoading: Bool
|
||||
|
||||
var body: some View {
|
||||
Picker(Strings.Global.provider, selection: $providerId) {
|
||||
if !providers.isEmpty {
|
||||
Text(isRequired ? Strings.Views.Provider.selectProvider : Strings.Views.Provider.noProvider)
|
||||
.tag(nil as ProviderID?)
|
||||
ForEach(providers, id: \.id) {
|
||||
Text($0.description)
|
||||
.tag($0.id as ProviderID?)
|
||||
}
|
||||
} else {
|
||||
Text(isLoading ? Strings.Global.loading : Strings.Global.none)
|
||||
.tag(providerId) // tag always exists
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
// picker menu animations are buggy on iOS
|
||||
.pickerStyle(.navigationLink)
|
||||
#endif
|
||||
.disabled(isLoading || providers.isEmpty)
|
||||
}
|
||||
}
|
|
@ -96,9 +96,7 @@ private extension VPNFiltersView {
|
|||
|
||||
var clearFiltersButton: some View {
|
||||
Button("Clear filters", role: .destructive) {
|
||||
Task {
|
||||
await manager.resetFilters()
|
||||
}
|
||||
manager.resetFilters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,122 @@
|
|||
//
|
||||
// VPNProviderContentModifier.swift
|
||||
// Passepartout
|
||||
//
|
||||
// Created by Davide De Rosa on 10/7/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 AppLibrary
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
import UtilsLibrary
|
||||
|
||||
struct VPNProviderContentModifier<Configuration, ProviderRows>: ViewModifier where Configuration: ProviderConfigurationIdentifiable & Codable, ProviderRows: View {
|
||||
|
||||
@Binding
|
||||
var providerId: ProviderID?
|
||||
|
||||
@Binding
|
||||
var selectedEntity: VPNEntity<Configuration>?
|
||||
|
||||
let configurationType: Configuration.Type
|
||||
|
||||
let isRequired: Bool
|
||||
|
||||
@ViewBuilder
|
||||
let providerRows: ProviderRows
|
||||
|
||||
@StateObject
|
||||
private var vpnProviderManager = VPNProviderManager()
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.modifier(ProviderContentModifier(
|
||||
providerId: $providerId,
|
||||
entityType: VPNEntity<Configuration>.self,
|
||||
isRequired: isRequired,
|
||||
providerRows: {
|
||||
providerServerRow
|
||||
providerRows
|
||||
},
|
||||
onSelectProvider: onSelectProvider
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private extension VPNProviderContentModifier {
|
||||
var providerServerRow: some View {
|
||||
NavigationLink {
|
||||
VPNProviderServerView<Configuration>(
|
||||
manager: vpnProviderManager,
|
||||
onSelect: onSelectServer
|
||||
)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(Strings.Global.server)
|
||||
if let selectedEntity {
|
||||
Spacer()
|
||||
Text(selectedEntity.server.hostname ?? selectedEntity.server.serverId)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onSelectProvider(manager: ProviderManager, providerId: ProviderID?, isInitial: Bool) {
|
||||
guard let providerId else {
|
||||
return
|
||||
}
|
||||
if !isInitial {
|
||||
selectedEntity = nil
|
||||
}
|
||||
vpnProviderManager.view = manager.vpnView(
|
||||
for: providerId,
|
||||
configurationType: OpenVPN.Configuration.self,
|
||||
initialParameters: .init(sorting: [
|
||||
.localizedCountry,
|
||||
.area,
|
||||
.hostname
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
func onSelectServer(server: VPNServer, preset: VPNPreset<Configuration>) {
|
||||
selectedEntity = VPNEntity(server: server, preset: preset)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
List {
|
||||
EmptyView()
|
||||
.modifier(VPNProviderContentModifier(
|
||||
providerId: .constant(.hideme),
|
||||
selectedEntity: .constant(nil),
|
||||
configurationType: OpenVPN.Configuration.self,
|
||||
isRequired: false,
|
||||
providerRows: {
|
||||
Text("Other")
|
||||
}
|
||||
))
|
||||
}
|
||||
.withMockEnvironment()
|
||||
}
|
|
@ -27,7 +27,7 @@ import AppLibrary
|
|||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
struct VPNProviderServerView<Configuration>: View where Configuration: ProviderConfigurationIdentifiable & Hashable & Codable {
|
||||
struct VPNProviderServerView<Configuration>: View where Configuration: ProviderConfigurationIdentifiable & Codable {
|
||||
|
||||
@Environment(\.dismiss)
|
||||
private var dismiss
|
||||
|
|
Loading…
Reference in New Issue