Import OpenVPN configuration (#765)

At module creation time, choose whether to use a provider or import a
configuration file. After the import, the provider picker is hidden for
mutual exclusion.

For clarity, refactor the configuration part of OpenVPNView into a
ConfigurationView subview.
This commit is contained in:
Davide 2024-10-28 20:07:19 +01:00 committed by GitHub
parent ecb0348b90
commit 0ec06c2c65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 402 additions and 289 deletions

View File

@ -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" : "efbf2e135b04e9c0f67b5d2b517c5e13a75c50f6" "revision" : "d21f1b362dfe667c36483e18b2dbb494bba54660"
} }
}, },
{ {

View File

@ -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: "efbf2e135b04e9c0f67b5d2b517c5e13a75c50f6"), .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "d21f1b362dfe667c36483e18b2dbb494bba54660"),
// .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"),

View File

@ -65,19 +65,24 @@ extension PassepartoutError: LocalizedError {
return Strings.Errors.App.Passepartout.incompatibleModules return Strings.Errors.App.Passepartout.incompatibleModules
case .invalidFields: case .invalidFields:
guard let fields = userInfo as? [String: String?] else { let fields = (userInfo as? [String: String?])
return nil
}
let fieldsDescription = fields
.map { .map {
"\($0)=\($1?.description ?? "")" $0.map {
"\($0)=\($1?.description ?? "")"
}
.joined(separator: ",")
} }
.joined(separator: ",")
return Strings.Errors.App.Passepartout.invalidFields(fieldsDescription) return [Strings.Errors.App.Passepartout.invalidFields, fields]
.compactMap { $0 }
.joined(separator: " ")
case .parsing: case .parsing:
return reason?.localizedDescription ?? Strings.Errors.App.Passepartout.parsing let message = userInfo as? String ?? reason?.localizedDescription
return [Strings.Errors.App.Passepartout.parsing, message]
.compactMap { $0 }
.joined(separator: " ")
case .unhandled: case .unhandled:
return reason?.localizedDescription return reason?.localizedDescription

View File

@ -27,6 +27,16 @@ public enum Strings {
public static let title = Strings.tr("Localizable", "alerts.iap.restricted.title", fallback: "Restricted") public static let title = Strings.tr("Localizable", "alerts.iap.restricted.title", fallback: "Restricted")
} }
} }
public enum Import {
public enum Passphrase {
/// Enter passphrase for '%@'.
public static func message(_ p1: Any) -> String {
return Strings.tr("Localizable", "alerts.import.passphrase.message", String(describing: p1), fallback: "Enter passphrase for '%@'.")
}
/// Decrypt
public static let ok = Strings.tr("Localizable", "alerts.import.passphrase.ok", fallback: "Decrypt")
}
}
} }
public enum Entities { public enum Entities {
public enum ConnectionStatus { public enum ConnectionStatus {
@ -121,10 +131,8 @@ public enum Strings {
} }
/// Some active modules are incompatible, try to only activate one of them. /// Some active modules are incompatible, try to only activate one of them.
public static let incompatibleModules = Strings.tr("Localizable", "errors.app.passepartout.incompatible_modules", fallback: "Some active modules are incompatible, try to only activate one of them.") public static let incompatibleModules = Strings.tr("Localizable", "errors.app.passepartout.incompatible_modules", fallback: "Some active modules are incompatible, try to only activate one of them.")
/// Invalid fields (%@). /// Invalid fields.
public static func invalidFields(_ p1: Any) -> String { public static let invalidFields = Strings.tr("Localizable", "errors.app.passepartout.invalid_fields", fallback: "Invalid fields.")
return Strings.tr("Localizable", "errors.app.passepartout.invalid_fields", String(describing: p1), fallback: "Invalid fields (%@).")
}
/// Unable to parse file. /// Unable to parse file.
public static let parsing = Strings.tr("Localizable", "errors.app.passepartout.parsing", fallback: "Unable to parse file.") public static let parsing = Strings.tr("Localizable", "errors.app.passepartout.parsing", fallback: "Unable to parse file.")
} }
@ -313,6 +321,8 @@ public enum Strings {
public enum Rows { public enum Rows {
/// Shared on iCloud /// Shared on iCloud
public static let icloudSharing = Strings.tr("Localizable", "modules.general.rows.icloud_sharing", fallback: "Shared on iCloud") public static let icloudSharing = Strings.tr("Localizable", "modules.general.rows.icloud_sharing", fallback: "Shared on iCloud")
/// Import from file...
public static let importFromFile = Strings.tr("Localizable", "modules.general.rows.import_from_file", fallback: "Import from file...")
public enum IcloudSharing { public enum IcloudSharing {
/// Share on iCloud /// Share on iCloud
public static let purchase = Strings.tr("Localizable", "modules.general.rows.icloud_sharing.purchase", fallback: "Share on iCloud") public static let purchase = Strings.tr("Localizable", "modules.general.rows.icloud_sharing.purchase", fallback: "Share on iCloud")
@ -598,18 +608,6 @@ public enum Strings {
} }
} }
public enum Profiles { public enum Profiles {
public enum Alerts {
public enum Import {
public enum Passphrase {
/// Enter passphrase for '%@'.
public static func message(_ p1: Any) -> String {
return Strings.tr("Localizable", "views.profiles.alerts.import.passphrase.message", String(describing: p1), fallback: "Enter passphrase for '%@'.")
}
/// Decrypt
public static let ok = Strings.tr("Localizable", "views.profiles.alerts.import.passphrase.ok", fallback: "Decrypt")
}
}
}
public enum Errors { public enum Errors {
/// Unable to duplicate profile '%@'. /// Unable to duplicate profile '%@'.
public static func duplicate(_ p1: Any) -> String { public static func duplicate(_ p1: Any) -> String {

View File

@ -124,8 +124,6 @@
"views.profiles.folders.no_profiles" = "No profiles"; "views.profiles.folders.no_profiles" = "No profiles";
"views.profiles.toolbar.new_profile" = "New profile"; "views.profiles.toolbar.new_profile" = "New profile";
"views.profiles.toolbar.import_profile" = "Import profile"; "views.profiles.toolbar.import_profile" = "Import profile";
"views.profiles.alerts.import.passphrase.message" = "Enter passphrase for '%@'.";
"views.profiles.alerts.import.passphrase.ok" = "Decrypt";
"views.profiles.errors.tunnel" = "Unable to execute tunnel operation."; "views.profiles.errors.tunnel" = "Unable to execute tunnel operation.";
"views.profiles.errors.duplicate" = "Unable to duplicate profile '%@'."; "views.profiles.errors.duplicate" = "Unable to duplicate profile '%@'.";
"views.profiles.errors.import" = "Unable to import profiles."; "views.profiles.errors.import" = "Unable to import profiles.";
@ -175,6 +173,7 @@
"modules.general.sections.storage.footer" = "Profiles are stored to iCloud encrypted."; "modules.general.sections.storage.footer" = "Profiles are stored to iCloud encrypted.";
"modules.general.rows.icloud_sharing" = "Shared on iCloud"; "modules.general.rows.icloud_sharing" = "Shared on iCloud";
"modules.general.rows.import_from_file" = "Import from file...";
"modules.dns.servers.add" = "Add address"; "modules.dns.servers.add" = "Add address";
"modules.dns.search_domains.add" = "Add domain"; "modules.dns.search_domains.add" = "Add domain";
@ -248,6 +247,9 @@
// MARK: - Alerts // MARK: - Alerts
"alerts.import.passphrase.message" = "Enter passphrase for '%@'.";
"alerts.import.passphrase.ok" = "Decrypt";
"alerts.iap.restricted.title" = "Restricted"; "alerts.iap.restricted.title" = "Restricted";
"alerts.iap.restricted.message" = "The requested feature is unavailable in this build."; "alerts.iap.restricted.message" = "The requested feature is unavailable in this build.";
@ -262,7 +264,7 @@
"errors.app.default" = "Unable to complete operation."; "errors.app.default" = "Unable to complete operation.";
"errors.app.passepartout.connection_module_required" = "Routing module can only be enabled together with a connection."; "errors.app.passepartout.connection_module_required" = "Routing module can only be enabled together with a connection.";
"errors.app.passepartout.corrupt_provider_module" = "Unable to connect to provider server (reason=%@)."; "errors.app.passepartout.corrupt_provider_module" = "Unable to connect to provider server (reason=%@).";
"errors.app.passepartout.invalid_fields" = "Invalid fields (%@)."; "errors.app.passepartout.invalid_fields" = "Invalid fields.";
"errors.app.passepartout.incompatible_modules" = "Some active modules are incompatible, try to only activate one of them."; "errors.app.passepartout.incompatible_modules" = "Some active modules are incompatible, try to only activate one of them.";
"errors.app.passepartout.parsing" = "Unable to parse file."; "errors.app.passepartout.parsing" = "Unable to parse file.";
"errors.app.passepartout.default" = "Unable to complete operation (code=%@)."; "errors.app.passepartout.default" = "Unable to complete operation (code=%@).";

View File

@ -70,7 +70,7 @@ private extension ProfileImporterModifier {
Strings.Placeholders.secret, Strings.Placeholders.secret,
text: $importer.currentPassphrase text: $importer.currentPassphrase
) )
Button(Strings.Views.Profiles.Alerts.Import.Passphrase.ok) { Button(Strings.Alerts.Import.Passphrase.ok) {
Task { Task {
try await importer.reImport( try await importer.reImport(
url: url, url: url,
@ -85,7 +85,7 @@ private extension ProfileImporterModifier {
} }
func message(for url: URL) -> some View { func message(for url: URL) -> some View {
Text(Strings.Views.Profiles.Alerts.Import.Passphrase.message(url.lastPathComponent)) Text(Strings.Alerts.Import.Passphrase.message(url.lastPathComponent))
} }
func handleResult(_ result: Result<[URL], Error>) { func handleResult(_ result: Result<[URL], Error>) {

View File

@ -0,0 +1,272 @@
//
// OpenVPNView+Configuration.swift
// Passepartout
//
// Created by Davide De Rosa on 10/28/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
extension OpenVPNView {
struct ConfigurationView: View {
let isServerPushed: Bool
let configuration: OpenVPN.Configuration.Builder
let credentialsRoute: any Hashable
var body: some View {
moduleSection(for: accountRows, header: Strings.Global.account)
moduleSection(for: remotesRows, header: Strings.Modules.Openvpn.remotes)
if !isServerPushed {
moduleSection(for: pullRows, header: Strings.Modules.Openvpn.pull)
}
moduleSection(for: redirectRows, header: Strings.Modules.Openvpn.redirectGateway)
moduleSection(
for: ipRows(for: configuration.ipv4, routes: configuration.routes4),
header: Strings.Unlocalized.ipv4
)
moduleSection(
for: ipRows(for: configuration.ipv6, routes: configuration.routes6),
header: Strings.Unlocalized.ipv6
)
moduleSection(for: dnsRows, header: Strings.Unlocalized.dns)
moduleSection(for: proxyRows, header: Strings.Unlocalized.proxy)
moduleSection(for: communicationRows, header: Strings.Modules.Openvpn.communication)
moduleSection(for: compressionRows, header: Strings.Modules.Openvpn.compression)
if !isServerPushed {
moduleSection(for: tlsRows, header: Strings.Unlocalized.tls)
}
moduleSection(for: otherRows, header: Strings.Global.other)
}
}
}
private extension OpenVPNView.ConfigurationView {
var accountRows: [ModuleRow]? {
guard configuration.authUserPass == true else {
return nil
}
return [.push(
caption: Strings.Modules.Openvpn.credentials,
route: HashableRoute(credentialsRoute))
]
}
var remotesRows: [ModuleRow]? {
configuration.remotes?.map {
.copiableText(
value: "\($0.address.rawValue)\($0.proto.socketType.rawValue):\($0.proto.port)"
)
}
.nilIfEmpty
}
var pullRows: [ModuleRow]? {
configuration.pullMask?.map {
.text(caption: $0.localizedDescription, value: nil)
}
.nilIfEmpty
}
func ipRows(for ip: IPSettings?, routes: [Route]?) -> [ModuleRow]? {
var rows: [ModuleRow] = []
if let ip {
ip.localizedDescription(optionalStyle: .address).map {
rows.append(.copiableText(caption: Strings.Global.address, value: $0))
}
ip.localizedDescription(optionalStyle: .defaultGateway).map {
rows.append(.copiableText(caption: Strings.Global.gateway, value: $0))
}
ip.includedRoutes
.filter { !$0.isDefault }
.nilIfEmpty
.map {
rows.append(.textList(
caption: Strings.Modules.Ip.Routes.included,
values: $0.map(\.localizedDescription)
))
}
ip.excludedRoutes
.nilIfEmpty
.map {
rows.append(.textList(
caption: Strings.Modules.Ip.Routes.excluded,
values: $0.map(\.localizedDescription)
))
}
}
routes?.forEach {
rows.append(.longContent(caption: Strings.Global.route, value: $0.localizedDescription))
}
return rows.nilIfEmpty
}
var redirectRows: [ModuleRow]? {
configuration.routingPolicies?
.compactMap {
switch $0 {
case .IPv4:
return .text(caption: Strings.Unlocalized.ipv4)
case .IPv6:
return .text(caption: Strings.Unlocalized.ipv6)
default:
return nil
}
}
.nilIfEmpty
}
var dnsRows: [ModuleRow]? {
var rows: [ModuleRow] = []
configuration.dnsServers?
.nilIfEmpty
.map {
rows.append(.textList(
caption: Strings.Global.servers,
values: $0
))
}
configuration.dnsDomain.map {
rows.append(.copiableText(
caption: Strings.Global.domain,
value: $0
))
}
configuration.searchDomains?
.nilIfEmpty
.map {
rows.append(.textList(
caption: Strings.Entities.Dns.searchDomains,
values: $0
))
}
return rows.nilIfEmpty
}
var proxyRows: [ModuleRow]? {
var rows: [ModuleRow] = []
configuration.httpProxy.map {
rows.append(.copiableText(
caption: Strings.Unlocalized.http,
value: $0.rawValue
))
}
configuration.httpsProxy.map {
rows.append(.copiableText(
caption: Strings.Unlocalized.https,
value: $0.rawValue
))
}
configuration.proxyAutoConfigurationURL.map {
rows.append(.copiableText(
caption: Strings.Unlocalized.pac,
value: $0.absoluteString
))
}
configuration.proxyBypassDomains?
.nilIfEmpty
.map {
rows.append(.textList(
caption: Strings.Entities.HttpProxy.bypassDomains,
values: $0
))
}
return rows.nilIfEmpty
}
var communicationRows: [ModuleRow]? {
var rows: [ModuleRow] = []
configuration.cipher.map {
rows.append(.text(caption: Strings.Modules.Openvpn.cipher, value: $0.localizedDescription))
}
configuration.digest.map {
rows.append(.text(caption: Strings.Modules.Openvpn.digest, value: $0.localizedDescription))
}
if let xorMethod = configuration.xorMethod {
rows.append(.longContentPreview(
caption: Strings.Unlocalized.xor,
value: xorMethod.localizedDescription(style: .long),
preview: xorMethod.localizedDescription(style: .short)
))
}
return rows.nilIfEmpty
}
var compressionRows: [ModuleRow]? {
var rows: [ModuleRow] = []
configuration.compressionFraming.map {
rows.append(.text(caption: Strings.Modules.Openvpn.compressionFraming, value: $0.localizedDescription))
}
configuration.compressionAlgorithm.map {
rows.append(.text(caption: Strings.Modules.Openvpn.compressionAlgorithm, value: $0.localizedDescription))
}
return rows.nilIfEmpty
}
var tlsRows: [ModuleRow]? {
var rows: [ModuleRow] = []
configuration.ca.map {
rows.append(.longContentPreview(caption: Strings.Unlocalized.ca, value: $0.pem, preview: nil))
}
configuration.clientCertificate.map {
rows.append(.longContentPreview(caption: Strings.Global.certificate, value: $0.pem, preview: nil))
}
configuration.clientKey.map {
rows.append(.longContentPreview(caption: Strings.Global.key, value: $0.pem, preview: nil))
}
configuration.tlsWrap.map {
rows.append(.longContentPreview(
caption: Strings.Modules.Openvpn.tlsWrap,
value: $0.key.hexString,
preview: configuration.localizedDescription(style: .tlsWrap)
))
}
rows.append(.text(caption: Strings.Modules.Openvpn.eku, value: configuration.localizedDescription(style: .eku)))
return rows.nilIfEmpty
}
var otherRows: [ModuleRow]? {
var rows: [ModuleRow] = []
configuration.localizedDescription(optionalStyle: .keepAlive).map {
rows.append(.text(caption: Strings.Global.keepAlive, value: $0))
}
configuration.localizedDescription(optionalStyle: .renegotiatesAfter).map {
rows.append(.text(caption: Strings.Modules.Openvpn.renegotiation, value: $0))
}
configuration.localizedDescription(optionalStyle: .randomizeEndpoint).map {
rows.append(.text(caption: Strings.Modules.Openvpn.randomizeEndpoint, value: $0))
}
configuration.localizedDescription(optionalStyle: .randomizeHostnames).map {
rows.append(.text(caption: Strings.Modules.Openvpn.randomizeHostname, value: $0))
}
return rows.nilIfEmpty
}
}

View File

@ -23,8 +23,10 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>. // along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
// //
import CPassepartoutOpenVPNOpenSSL
import PassepartoutKit import PassepartoutKit
import SwiftUI import SwiftUI
import UtilsLibrary
struct OpenVPNView: View, ModuleDraftEditing { struct OpenVPNView: View, ModuleDraftEditing {
@ -38,12 +40,28 @@ struct OpenVPNView: View, ModuleDraftEditing {
private let isServerPushed: Bool private let isServerPushed: Bool
@State
private var isImporting = false
@State
private var importURL: URL?
@State
private var importPassphrase: String?
@State
private var requiresPassphrase = false
@State @State
private var paywallReason: PaywallReason? private var paywallReason: PaywallReason?
@StateObject
private var errorHandler: ErrorHandler = .default()
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])
assert(module.configurationBuilder != nil, "isServerPushed must imply module.configurationBuilder != nil")
self.editor = editor self.editor = editor
self.module = module self.module = module
@ -59,7 +77,13 @@ struct OpenVPNView: View, ModuleDraftEditing {
var body: some View { var body: some View {
contentView contentView
.moduleView(editor: editor, draft: draft.wrappedValue, withName: !isServerPushed) .moduleView(editor: editor, draft: draft.wrappedValue, withName: !isServerPushed)
.fileImporter(
isPresented: $isImporting,
allowedContentTypes: [.item],
onCompletion: importConfiguration
)
.modifier(PaywallModifier(reason: $paywallReason)) .modifier(PaywallModifier(reason: $paywallReason))
.withErrorHandler(errorHandler)
.navigationDestination(for: Subroute.self, destination: destination) .navigationDestination(for: Subroute.self, destination: destination)
} }
} }
@ -67,25 +91,54 @@ struct OpenVPNView: View, ModuleDraftEditing {
// MARK: - Content // MARK: - Content
private extension OpenVPNView { private extension OpenVPNView {
var configuration: OpenVPN.Configuration.Builder {
draft.wrappedValue.configurationBuilder ?? .init(withFallbacks: true)
}
@ViewBuilder @ViewBuilder
var contentView: some View { var contentView: some View {
if isServerPushed || draft.wrappedValue.configurationBuilder != nil { if let configuration = draft.wrappedValue.configurationBuilder {
manualView ConfigurationView(
isServerPushed: isServerPushed,
configuration: configuration,
credentialsRoute: Subroute.credentials
)
} else { } else {
manualView importView
.modifier(providerModifier) .modifier(providerModifier)
} }
} }
@ViewBuilder
var importView: some View {
if providerId.wrappedValue == nil {
Button(Strings.Modules.General.Rows.importFromFile) {
isImporting = true
}
.alert(
module.typeDescription,
isPresented: $requiresPassphrase,
presenting: importURL,
actions: { url in
SecureField(
Strings.Placeholders.secret,
text: $importPassphrase ?? ""
)
Button(Strings.Alerts.Import.Passphrase.ok) {
importConfiguration(from: .success(url))
}
Button(Strings.Global.cancel, role: .cancel) {
isImporting = false
}
},
message: {
Text(Strings.Alerts.Import.Passphrase.message($0.lastPathComponent))
}
)
}
}
var providerModifier: some ViewModifier { var providerModifier: some ViewModifier {
VPNProviderContentModifier( VPNProviderContentModifier(
providerId: providerId, providerId: providerId,
selectedEntity: providerEntity, selectedEntity: providerEntity,
isRequired: true,
paywallReason: $paywallReason, paywallReason: $paywallReason,
entityDestination: Subroute.providerServer, entityDestination: Subroute.providerServer,
providerRows: { providerRows: {
@ -113,8 +166,36 @@ private extension OpenVPNView {
path.wrappedValue.removeLast() path.wrappedValue.removeLast()
} }
func importConfiguration(from url: URL) { func importConfiguration(from result: Result<URL, Error>) {
// TODO: #657, import draft from external URL do {
let url = try result.get()
guard url.startAccessingSecurityScopedResource() else {
throw AppError.permissionDenied
}
defer {
url.stopAccessingSecurityScopedResource()
}
importURL = url
let parsed = try StandardOpenVPNParser(decrypter: OSSLTLSBox())
.parsed(fromURL: url, passphrase: importPassphrase)
draft.wrappedValue.configurationBuilder = parsed.configuration.builder()
} catch StandardOpenVPNParserError.encryptionPassphrase,
StandardOpenVPNParserError.unableToDecrypt {
Task {
// XXX: re-present same alert after artificial delay
try? await Task.sleep(for: .milliseconds(500))
importPassphrase = nil
requiresPassphrase = true
}
} catch {
pp_log(.app, .error, "Unable to import OpenVPN configuration: \(error)")
errorHandler.handle(
(error as? StandardOpenVPNParserError)?.asPassepartoutError ?? error,
title: module.typeDescription
)
}
} }
} }
@ -152,242 +233,6 @@ private extension OpenVPNView {
} }
} }
// MARK: - Manual configuration
private extension OpenVPNView {
@ViewBuilder
var manualView: some View {
moduleSection(for: accountRows, header: Strings.Global.account)
moduleSection(for: remotesRows, header: Strings.Modules.Openvpn.remotes)
if !isServerPushed {
moduleSection(for: pullRows, header: Strings.Modules.Openvpn.pull)
}
moduleSection(for: redirectRows, header: Strings.Modules.Openvpn.redirectGateway)
moduleSection(
for: ipRows(for: configuration.ipv4, routes: configuration.routes4),
header: Strings.Unlocalized.ipv4
)
moduleSection(
for: ipRows(for: configuration.ipv6, routes: configuration.routes6),
header: Strings.Unlocalized.ipv6
)
moduleSection(for: dnsRows, header: Strings.Unlocalized.dns)
moduleSection(for: proxyRows, header: Strings.Unlocalized.proxy)
moduleSection(for: communicationRows, header: Strings.Modules.Openvpn.communication)
moduleSection(for: compressionRows, header: Strings.Modules.Openvpn.compression)
if !isServerPushed {
moduleSection(for: tlsRows, header: Strings.Unlocalized.tls)
}
moduleSection(for: otherRows, header: Strings.Global.other)
}
var accountRows: [ModuleRow]? {
guard configuration.authUserPass == true else {
return nil
}
return [.push(caption: Strings.Modules.Openvpn.credentials, route: HashableRoute(Subroute.credentials))]
}
var remotesRows: [ModuleRow]? {
configuration.remotes?.map {
.copiableText(
value: "\($0.address.rawValue)\($0.proto.socketType.rawValue):\($0.proto.port)"
)
}
.nilIfEmpty
}
var pullRows: [ModuleRow]? {
configuration.pullMask?.map {
.text(caption: $0.localizedDescription, value: nil)
}
.nilIfEmpty
}
func ipRows(for ip: IPSettings?, routes: [Route]?) -> [ModuleRow]? {
var rows: [ModuleRow] = []
if let ip {
ip.localizedDescription(optionalStyle: .address).map {
rows.append(.copiableText(caption: Strings.Global.address, value: $0))
}
ip.localizedDescription(optionalStyle: .defaultGateway).map {
rows.append(.copiableText(caption: Strings.Global.gateway, value: $0))
}
ip.includedRoutes
.filter { !$0.isDefault }
.nilIfEmpty
.map {
rows.append(.textList(
caption: Strings.Modules.Ip.Routes.included,
values: $0.map(\.localizedDescription)
))
}
ip.excludedRoutes
.nilIfEmpty
.map {
rows.append(.textList(
caption: Strings.Modules.Ip.Routes.excluded,
values: $0.map(\.localizedDescription)
))
}
}
routes?.forEach {
rows.append(.longContent(caption: Strings.Global.route, value: $0.localizedDescription))
}
return rows.nilIfEmpty
}
var redirectRows: [ModuleRow]? {
configuration.routingPolicies?
.compactMap {
switch $0 {
case .IPv4:
return .text(caption: Strings.Unlocalized.ipv4)
case .IPv6:
return .text(caption: Strings.Unlocalized.ipv6)
default:
return nil
}
}
.nilIfEmpty
}
var dnsRows: [ModuleRow]? {
var rows: [ModuleRow] = []
configuration.dnsServers?
.nilIfEmpty
.map {
rows.append(.textList(
caption: Strings.Global.servers,
values: $0
))
}
configuration.dnsDomain.map {
rows.append(.copiableText(
caption: Strings.Global.domain,
value: $0
))
}
configuration.searchDomains?
.nilIfEmpty
.map {
rows.append(.textList(
caption: Strings.Entities.Dns.searchDomains,
values: $0
))
}
return rows.nilIfEmpty
}
var proxyRows: [ModuleRow]? {
var rows: [ModuleRow] = []
configuration.httpProxy.map {
rows.append(.copiableText(
caption: Strings.Unlocalized.http,
value: $0.rawValue
))
}
configuration.httpsProxy.map {
rows.append(.copiableText(
caption: Strings.Unlocalized.https,
value: $0.rawValue
))
}
configuration.proxyAutoConfigurationURL.map {
rows.append(.copiableText(
caption: Strings.Unlocalized.pac,
value: $0.absoluteString
))
}
configuration.proxyBypassDomains?
.nilIfEmpty
.map {
rows.append(.textList(
caption: Strings.Entities.HttpProxy.bypassDomains,
values: $0
))
}
return rows.nilIfEmpty
}
var communicationRows: [ModuleRow]? {
var rows: [ModuleRow] = []
configuration.cipher.map {
rows.append(.text(caption: Strings.Modules.Openvpn.cipher, value: $0.localizedDescription))
}
configuration.digest.map {
rows.append(.text(caption: Strings.Modules.Openvpn.digest, value: $0.localizedDescription))
}
if let xorMethod = configuration.xorMethod {
rows.append(.longContentPreview(
caption: Strings.Unlocalized.xor,
value: xorMethod.localizedDescription(style: .long),
preview: xorMethod.localizedDescription(style: .short)
))
}
return rows.nilIfEmpty
}
var compressionRows: [ModuleRow]? {
var rows: [ModuleRow] = []
configuration.compressionFraming.map {
rows.append(.text(caption: Strings.Modules.Openvpn.compressionFraming, value: $0.localizedDescription))
}
configuration.compressionAlgorithm.map {
rows.append(.text(caption: Strings.Modules.Openvpn.compressionAlgorithm, value: $0.localizedDescription))
}
return rows.nilIfEmpty
}
var tlsRows: [ModuleRow]? {
var rows: [ModuleRow] = []
configuration.ca.map {
rows.append(.longContentPreview(caption: Strings.Unlocalized.ca, value: $0.pem, preview: nil))
}
configuration.clientCertificate.map {
rows.append(.longContentPreview(caption: Strings.Global.certificate, value: $0.pem, preview: nil))
}
configuration.clientKey.map {
rows.append(.longContentPreview(caption: Strings.Global.key, value: $0.pem, preview: nil))
}
configuration.tlsWrap.map {
rows.append(.longContentPreview(
caption: Strings.Modules.Openvpn.tlsWrap,
value: $0.key.hexString,
preview: configuration.localizedDescription(style: .tlsWrap)
))
}
rows.append(.text(caption: Strings.Modules.Openvpn.eku, value: configuration.localizedDescription(style: .eku)))
return rows.nilIfEmpty
}
var otherRows: [ModuleRow]? {
var rows: [ModuleRow] = []
configuration.localizedDescription(optionalStyle: .keepAlive).map {
rows.append(.text(caption: Strings.Global.keepAlive, value: $0))
}
configuration.localizedDescription(optionalStyle: .renegotiatesAfter).map {
rows.append(.text(caption: Strings.Modules.Openvpn.renegotiation, value: $0))
}
configuration.localizedDescription(optionalStyle: .randomizeEndpoint).map {
rows.append(.text(caption: Strings.Modules.Openvpn.randomizeEndpoint, value: $0))
}
configuration.localizedDescription(optionalStyle: .randomizeHostnames).map {
rows.append(.text(caption: Strings.Modules.Openvpn.randomizeHostname, value: $0))
}
return rows.nilIfEmpty
}
}
// MARK: - Previews // MARK: - Previews
// swiftlint: disable force_try // swiftlint: disable force_try

View File

@ -41,8 +41,6 @@ struct ProviderContentModifier<Entity, ProviderRows>: ViewModifier where Entity:
let entityType: Entity.Type let entityType: Entity.Type
let isRequired: Bool
@Binding @Binding
var paywallReason: PaywallReason? var paywallReason: PaywallReason?
@ -64,9 +62,7 @@ struct ProviderContentModifier<Entity, ProviderRows>: ViewModifier where Entity:
} }
.disabled(providerManager.isLoading) .disabled(providerManager.isLoading)
if providerId == nil && !isRequired { content
content
}
} }
static func == (lhs: Self, rhs: Self) -> Bool { static func == (lhs: Self, rhs: Self) -> Bool {
@ -129,7 +125,7 @@ private extension ProviderContentModifier {
ProviderPicker( ProviderPicker(
providers: supportedProviders, providers: supportedProviders,
providerId: $providerId, providerId: $providerId,
isRequired: isRequired, isRequired: true,
isLoading: providerManager.isLoading isLoading: providerManager.isLoading
) )
} }
@ -221,7 +217,6 @@ private extension ProviderContentModifier {
apis: [API.bundled], apis: [API.bundled],
providerId: .constant(.hideme), providerId: .constant(.hideme),
entityType: VPNEntity<OpenVPN.Configuration>.self, entityType: VPNEntity<OpenVPN.Configuration>.self,
isRequired: false,
paywallReason: .constant(nil), paywallReason: .constant(nil),
providerRows: {}, providerRows: {},
onSelectProvider: { _, _, _ in } onSelectProvider: { _, _, _ in }

View File

@ -28,7 +28,7 @@ import PassepartoutKit
import SwiftUI import SwiftUI
import UtilsLibrary import UtilsLibrary
struct VPNProviderContentModifier<Configuration, Destination, ProviderRows>: ViewModifier where Configuration: ProviderConfigurationIdentifiable & Codable, Destination: Hashable, ProviderRows: View { struct VPNProviderContentModifier<Configuration, ProviderRows>: ViewModifier where Configuration: ProviderConfigurationIdentifiable & Codable, ProviderRows: View {
var apis: [APIMapper] = API.shared var apis: [APIMapper] = API.shared
@ -38,12 +38,10 @@ struct VPNProviderContentModifier<Configuration, Destination, ProviderRows>: Vie
@Binding @Binding
var selectedEntity: VPNEntity<Configuration>? var selectedEntity: VPNEntity<Configuration>?
let isRequired: Bool
@Binding @Binding
var paywallReason: PaywallReason? var paywallReason: PaywallReason?
let entityDestination: Destination let entityDestination: any Hashable
@ViewBuilder @ViewBuilder
let providerRows: ProviderRows let providerRows: ProviderRows
@ -55,7 +53,6 @@ struct VPNProviderContentModifier<Configuration, Destination, ProviderRows>: Vie
apis: apis, apis: apis,
providerId: $providerId, providerId: $providerId,
entityType: VPNEntity<Configuration>.self, entityType: VPNEntity<Configuration>.self,
isRequired: isRequired,
paywallReason: $paywallReason, paywallReason: $paywallReason,
providerRows: { providerRows: {
providerEntityRow providerEntityRow
@ -99,7 +96,6 @@ private extension VPNProviderContentModifier {
apis: [API.bundled], apis: [API.bundled],
providerId: .constant(.hideme), providerId: .constant(.hideme),
selectedEntity: .constant(nil as VPNEntity<OpenVPN.Configuration>?), selectedEntity: .constant(nil as VPNEntity<OpenVPN.Configuration>?),
isRequired: false,
paywallReason: .constant(nil), paywallReason: .constant(nil),
entityDestination: "Destination", entityDestination: "Destination",
providerRows: { providerRows: {