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",
|
||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||
"state" : {
|
||||
"revision" : "efbf2e135b04e9c0f67b5d2b517c5e13a75c50f6"
|
||||
"revision" : "d21f1b362dfe667c36483e18b2dbb494bba54660"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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: "efbf2e135b04e9c0f67b5d2b517c5e13a75c50f6"),
|
||||
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "d21f1b362dfe667c36483e18b2dbb494bba54660"),
|
||||
// .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"),
|
||||
|
|
|
@ -65,19 +65,24 @@ extension PassepartoutError: LocalizedError {
|
|||
return Strings.Errors.App.Passepartout.incompatibleModules
|
||||
|
||||
case .invalidFields:
|
||||
guard let fields = userInfo as? [String: String?] else {
|
||||
return nil
|
||||
}
|
||||
let fieldsDescription = fields
|
||||
let fields = (userInfo as? [String: String?])
|
||||
.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:
|
||||
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:
|
||||
return reason?.localizedDescription
|
||||
|
|
|
@ -27,6 +27,16 @@ public enum Strings {
|
|||
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 ConnectionStatus {
|
||||
|
@ -121,10 +131,8 @@ public enum Strings {
|
|||
}
|
||||
/// 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 (%@).
|
||||
public static func invalidFields(_ p1: Any) -> String {
|
||||
return Strings.tr("Localizable", "errors.app.passepartout.invalid_fields", String(describing: p1), fallback: "Invalid fields (%@).")
|
||||
}
|
||||
/// Invalid fields.
|
||||
public static let invalidFields = Strings.tr("Localizable", "errors.app.passepartout.invalid_fields", fallback: "Invalid fields.")
|
||||
/// 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 {
|
||||
/// 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 {
|
||||
/// 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 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 {
|
||||
/// Unable to duplicate profile '%@'.
|
||||
public static func duplicate(_ p1: Any) -> String {
|
||||
|
|
|
@ -124,8 +124,6 @@
|
|||
"views.profiles.folders.no_profiles" = "No profiles";
|
||||
"views.profiles.toolbar.new_profile" = "New 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.duplicate" = "Unable to duplicate profile '%@'.";
|
||||
"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.rows.icloud_sharing" = "Shared on iCloud";
|
||||
"modules.general.rows.import_from_file" = "Import from file...";
|
||||
|
||||
"modules.dns.servers.add" = "Add address";
|
||||
"modules.dns.search_domains.add" = "Add domain";
|
||||
|
@ -248,6 +247,9 @@
|
|||
|
||||
// MARK: - Alerts
|
||||
|
||||
"alerts.import.passphrase.message" = "Enter passphrase for '%@'.";
|
||||
"alerts.import.passphrase.ok" = "Decrypt";
|
||||
|
||||
"alerts.iap.restricted.title" = "Restricted";
|
||||
"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.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.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.parsing" = "Unable to parse file.";
|
||||
"errors.app.passepartout.default" = "Unable to complete operation (code=%@).";
|
||||
|
|
|
@ -70,7 +70,7 @@ private extension ProfileImporterModifier {
|
|||
Strings.Placeholders.secret,
|
||||
text: $importer.currentPassphrase
|
||||
)
|
||||
Button(Strings.Views.Profiles.Alerts.Import.Passphrase.ok) {
|
||||
Button(Strings.Alerts.Import.Passphrase.ok) {
|
||||
Task {
|
||||
try await importer.reImport(
|
||||
url: url,
|
||||
|
@ -85,7 +85,7 @@ private extension ProfileImporterModifier {
|
|||
}
|
||||
|
||||
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>) {
|
||||
|
|
|
@ -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/>.
|
||||
//
|
||||
|
||||
import CPassepartoutOpenVPNOpenSSL
|
||||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
import UtilsLibrary
|
||||
|
||||
struct OpenVPNView: View, ModuleDraftEditing {
|
||||
|
||||
|
@ -38,12 +40,28 @@ struct OpenVPNView: View, ModuleDraftEditing {
|
|||
|
||||
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
|
||||
private var paywallReason: PaywallReason?
|
||||
|
||||
@StateObject
|
||||
private var errorHandler: ErrorHandler = .default()
|
||||
|
||||
init(serverConfiguration: OpenVPN.Configuration) {
|
||||
let module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder())
|
||||
let editor = ProfileEditor(modules: [module])
|
||||
assert(module.configurationBuilder != nil, "isServerPushed must imply module.configurationBuilder != nil")
|
||||
|
||||
self.editor = editor
|
||||
self.module = module
|
||||
|
@ -59,7 +77,13 @@ struct OpenVPNView: View, ModuleDraftEditing {
|
|||
var body: some View {
|
||||
contentView
|
||||
.moduleView(editor: editor, draft: draft.wrappedValue, withName: !isServerPushed)
|
||||
.fileImporter(
|
||||
isPresented: $isImporting,
|
||||
allowedContentTypes: [.item],
|
||||
onCompletion: importConfiguration
|
||||
)
|
||||
.modifier(PaywallModifier(reason: $paywallReason))
|
||||
.withErrorHandler(errorHandler)
|
||||
.navigationDestination(for: Subroute.self, destination: destination)
|
||||
}
|
||||
}
|
||||
|
@ -67,25 +91,54 @@ struct OpenVPNView: View, ModuleDraftEditing {
|
|||
// MARK: - Content
|
||||
|
||||
private extension OpenVPNView {
|
||||
var configuration: OpenVPN.Configuration.Builder {
|
||||
draft.wrappedValue.configurationBuilder ?? .init(withFallbacks: true)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var contentView: some View {
|
||||
if isServerPushed || draft.wrappedValue.configurationBuilder != nil {
|
||||
manualView
|
||||
if let configuration = draft.wrappedValue.configurationBuilder {
|
||||
ConfigurationView(
|
||||
isServerPushed: isServerPushed,
|
||||
configuration: configuration,
|
||||
credentialsRoute: Subroute.credentials
|
||||
)
|
||||
} else {
|
||||
manualView
|
||||
importView
|
||||
.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 {
|
||||
VPNProviderContentModifier(
|
||||
providerId: providerId,
|
||||
selectedEntity: providerEntity,
|
||||
isRequired: true,
|
||||
paywallReason: $paywallReason,
|
||||
entityDestination: Subroute.providerServer,
|
||||
providerRows: {
|
||||
|
@ -113,8 +166,36 @@ private extension OpenVPNView {
|
|||
path.wrappedValue.removeLast()
|
||||
}
|
||||
|
||||
func importConfiguration(from url: URL) {
|
||||
// TODO: #657, import draft from external URL
|
||||
func importConfiguration(from result: Result<URL, Error>) {
|
||||
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
|
||||
|
||||
// swiftlint: disable force_try
|
||||
|
|
|
@ -41,8 +41,6 @@ struct ProviderContentModifier<Entity, ProviderRows>: ViewModifier where Entity:
|
|||
|
||||
let entityType: Entity.Type
|
||||
|
||||
let isRequired: Bool
|
||||
|
||||
@Binding
|
||||
var paywallReason: PaywallReason?
|
||||
|
||||
|
@ -64,9 +62,7 @@ struct ProviderContentModifier<Entity, ProviderRows>: ViewModifier where Entity:
|
|||
}
|
||||
.disabled(providerManager.isLoading)
|
||||
|
||||
if providerId == nil && !isRequired {
|
||||
content
|
||||
}
|
||||
content
|
||||
}
|
||||
|
||||
static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
|
@ -129,7 +125,7 @@ private extension ProviderContentModifier {
|
|||
ProviderPicker(
|
||||
providers: supportedProviders,
|
||||
providerId: $providerId,
|
||||
isRequired: isRequired,
|
||||
isRequired: true,
|
||||
isLoading: providerManager.isLoading
|
||||
)
|
||||
}
|
||||
|
@ -221,7 +217,6 @@ private extension ProviderContentModifier {
|
|||
apis: [API.bundled],
|
||||
providerId: .constant(.hideme),
|
||||
entityType: VPNEntity<OpenVPN.Configuration>.self,
|
||||
isRequired: false,
|
||||
paywallReason: .constant(nil),
|
||||
providerRows: {},
|
||||
onSelectProvider: { _, _, _ in }
|
||||
|
|
|
@ -28,7 +28,7 @@ import PassepartoutKit
|
|||
import SwiftUI
|
||||
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
|
||||
|
||||
|
@ -38,12 +38,10 @@ struct VPNProviderContentModifier<Configuration, Destination, ProviderRows>: Vie
|
|||
@Binding
|
||||
var selectedEntity: VPNEntity<Configuration>?
|
||||
|
||||
let isRequired: Bool
|
||||
|
||||
@Binding
|
||||
var paywallReason: PaywallReason?
|
||||
|
||||
let entityDestination: Destination
|
||||
let entityDestination: any Hashable
|
||||
|
||||
@ViewBuilder
|
||||
let providerRows: ProviderRows
|
||||
|
@ -55,7 +53,6 @@ struct VPNProviderContentModifier<Configuration, Destination, ProviderRows>: Vie
|
|||
apis: apis,
|
||||
providerId: $providerId,
|
||||
entityType: VPNEntity<Configuration>.self,
|
||||
isRequired: isRequired,
|
||||
paywallReason: $paywallReason,
|
||||
providerRows: {
|
||||
providerEntityRow
|
||||
|
@ -99,7 +96,6 @@ private extension VPNProviderContentModifier {
|
|||
apis: [API.bundled],
|
||||
providerId: .constant(.hideme),
|
||||
selectedEntity: .constant(nil as VPNEntity<OpenVPN.Configuration>?),
|
||||
isRequired: false,
|
||||
paywallReason: .constant(nil),
|
||||
entityDestination: "Destination",
|
||||
providerRows: {
|
||||
|
|
Loading…
Reference in New Issue