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:
Davide 2024-10-15 21:34:02 +02:00 committed by GitHub
parent 87c7d63678
commit ed28126cf7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 461 additions and 296 deletions

View File

@ -41,7 +41,7 @@
"kind" : "remoteSourceControl",
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
"state" : {
"revision" : "aeb982951e2798863e28f55081dd25e2221083e3"
"revision" : "c4182832032fab8fef24386d209572a2c288e28e"
}
},
{

View File

@ -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"),

View File

@ -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 {

View File

@ -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";

View File

@ -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))]

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -96,9 +96,7 @@ private extension VPNFiltersView {
var clearFiltersButton: some View {
Button("Clear filters", role: .destructive) {
Task {
await manager.resetFilters()
}
manager.resetFilters()
}
}
}

View File

@ -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()
}

View File

@ -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