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:
parent
ecb0348b90
commit
0ec06c2c65
|
@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -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"),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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=%@).";
|
||||||
|
|
|
@ -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>) {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
Loading…
Reference in New Issue