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",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "aeb982951e2798863e28f55081dd25e2221083e3"
|
"revision" : "c4182832032fab8fef24386d209572a2c288e28e"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -28,7 +28,7 @@ let package = Package(
|
||||||
],
|
],
|
||||||
dependencies: [
|
dependencies: [
|
||||||
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"),
|
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"),
|
||||||
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "aeb982951e2798863e28f55081dd25e2221083e3"),
|
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "c4182832032fab8fef24386d209572a2c288e28e"),
|
||||||
// .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"),
|
||||||
|
|
|
@ -209,6 +209,8 @@ public enum Strings {
|
||||||
public static let keepAlive = Strings.tr("Localizable", "global.keep_alive", fallback: "Keep-alive")
|
public static let keepAlive = Strings.tr("Localizable", "global.keep_alive", fallback: "Keep-alive")
|
||||||
/// Key
|
/// Key
|
||||||
public static let key = Strings.tr("Localizable", "global.key", fallback: "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
|
/// Method
|
||||||
public static let method = Strings.tr("Localizable", "global.method", fallback: "Method")
|
public static let method = Strings.tr("Localizable", "global.method", fallback: "Method")
|
||||||
/// Modules
|
/// Modules
|
||||||
|
@ -588,9 +590,19 @@ public enum Strings {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public enum Provider {
|
public enum Provider {
|
||||||
public enum Vpn {
|
/// Last updated on %@
|
||||||
/// Refresh infrastructure
|
public static func lastUpdated(_ p1: Any) -> String {
|
||||||
public static let refreshInfrastructure = Strings.tr("Localizable", "views.provider.vpn.refresh_infrastructure", fallback: "Refresh infrastructure")
|
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.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 {
|
public enum Settings {
|
||||||
|
|
|
@ -31,6 +31,7 @@
|
||||||
"global.interface" = "Interface";
|
"global.interface" = "Interface";
|
||||||
"global.keep_alive" = "Keep-alive";
|
"global.keep_alive" = "Keep-alive";
|
||||||
"global.key" = "Key";
|
"global.key" = "Key";
|
||||||
|
"global.loading" = "Loading";
|
||||||
"global.method" = "Method";
|
"global.method" = "Method";
|
||||||
"global.modules" = "Modules";
|
"global.modules" = "Modules";
|
||||||
"global.n_seconds" = "%d seconds";
|
"global.n_seconds" = "%d seconds";
|
||||||
|
@ -128,7 +129,11 @@
|
||||||
"views.profile.rows.add_module" = "Add module";
|
"views.profile.rows.add_module" = "Add module";
|
||||||
"views.profile.module_list.section.footer" = "Drag modules to rearrange them, as their order determines priority.";
|
"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.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";
|
"views.settings.rows.confirm_quit" = "Ask before quit";
|
||||||
|
|
|
@ -54,41 +54,27 @@ struct OpenVPNView: View {
|
||||||
@Binding
|
@Binding
|
||||||
private var draft: OpenVPNModule.Builder
|
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) {
|
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])
|
||||||
|
|
||||||
self.editor = editor
|
self.editor = editor
|
||||||
_draft = .constant(module)
|
_draft = .constant(module)
|
||||||
_providerId = .constant(nil)
|
|
||||||
_providerEntity = .constant(nil)
|
|
||||||
isServerPushed = true
|
isServerPushed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
init(editor: ProfileEditor, module: OpenVPNModule.Builder) {
|
init(editor: ProfileEditor, module: OpenVPNModule.Builder) {
|
||||||
self.editor = editor
|
self.editor = editor
|
||||||
_draft = editor.binding(forModule: module)
|
_draft = editor.binding(forModule: module)
|
||||||
_providerId = editor.binding(forProviderOf: module.id)
|
|
||||||
_providerEntity = editor.binding(forProviderEntityOf: module.id)
|
|
||||||
isServerPushed = false
|
isServerPushed = false
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
debugChanges()
|
manualView
|
||||||
return contentView
|
|
||||||
.modifier(providerModifier)
|
.modifier(providerModifier)
|
||||||
|
.themeAnimation(on: editor.profile.modulesMetadata, category: .modules)
|
||||||
.moduleView(editor: editor, draft: draft, withName: !isServerPushed)
|
.moduleView(editor: editor, draft: draft, withName: !isServerPushed)
|
||||||
.navigationDestination(for: Subroute.self, destination: destination)
|
.navigationDestination(for: Subroute.self, destination: destination)
|
||||||
.themeAnimation(on: providerId, category: .modules)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,36 +86,53 @@ private extension OpenVPNView {
|
||||||
}
|
}
|
||||||
|
|
||||||
var providerModifier: some ViewModifier {
|
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,
|
isRequired: draft.configurationBuilder == nil,
|
||||||
entityType: VPNEntity<OpenVPN.Configuration>.self,
|
providerRows: {
|
||||||
providerId: $providerId,
|
moduleGroup(for: providerAccountRows)
|
||||||
providerContent: providerContentView,
|
}
|
||||||
onSelectProvider: onSelectProvider
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
var providerAccountRows: [ModuleRow]? {
|
||||||
func providerContentView(providerId: ProviderID) -> some View {
|
[.push(caption: Strings.Modules.Openvpn.credentials, route: HashableRoute(Subroute.credentials))]
|
||||||
providerServerRow
|
}
|
||||||
moduleGroup(for: accountRows)
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
var providerServerRow: some View {
|
@ViewBuilder
|
||||||
NavigationLink(value: Subroute.providerServer) {
|
func destination(for route: Subroute) -> some View {
|
||||||
HStack {
|
switch route {
|
||||||
Text(Strings.Global.server)
|
case .credentials:
|
||||||
if let providerEntity {
|
CredentialsView(
|
||||||
Spacer()
|
isInteractive: $draft.isInteractive,
|
||||||
Text(providerEntity.server.hostname ?? providerEntity.server.serverId)
|
credentials: $draft.credentials
|
||||||
.foregroundStyle(.secondary)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Manual configuration
|
||||||
|
|
||||||
|
private extension OpenVPNView {
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var contentView: some View {
|
var manualView: some View {
|
||||||
moduleSection(for: accountRows, header: Strings.Global.account)
|
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 {
|
||||||
|
@ -153,64 +156,9 @@ private extension OpenVPNView {
|
||||||
}
|
}
|
||||||
moduleSection(for: otherRows, header: Strings.Global.other)
|
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]? {
|
var accountRows: [ModuleRow]? {
|
||||||
guard configuration.authUserPass == true || providerId != nil else {
|
guard configuration.authUserPass == true else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return [.push(caption: Strings.Modules.Openvpn.credentials, route: HashableRoute(Subroute.credentials))]
|
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 {
|
var clearFiltersButton: some View {
|
||||||
Button("Clear filters", role: .destructive) {
|
Button("Clear filters", role: .destructive) {
|
||||||
Task {
|
manager.resetFilters()
|
||||||
await 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 PassepartoutKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct VPNProviderServerView<Configuration>: View where Configuration: ProviderConfigurationIdentifiable & Hashable & Codable {
|
struct VPNProviderServerView<Configuration>: View where Configuration: ProviderConfigurationIdentifiable & Codable {
|
||||||
|
|
||||||
@Environment(\.dismiss)
|
@Environment(\.dismiss)
|
||||||
private var dismiss
|
private var dismiss
|
||||||
|
|
Loading…
Reference in New Issue