diff --git a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index de0b3b05..72ba4a43 100644 --- a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -41,7 +41,7 @@ "kind" : "remoteSourceControl", "location" : "git@github.com:passepartoutvpn/passepartoutkit-source", "state" : { - "revision" : "efbf2e135b04e9c0f67b5d2b517c5e13a75c50f6" + "revision" : "d21f1b362dfe667c36483e18b2dbb494bba54660" } }, { diff --git a/Passepartout/Library/Package.swift b/Passepartout/Library/Package.swift index 144d9b9d..6985d19f 100644 --- a/Passepartout/Library/Package.swift +++ b/Passepartout/Library/Package.swift @@ -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"), diff --git a/Passepartout/Library/Sources/AppUI/L10n/AppError+L10n.swift b/Passepartout/Library/Sources/AppUI/L10n/AppError+L10n.swift index 6ca20e1f..857a07ef 100644 --- a/Passepartout/Library/Sources/AppUI/L10n/AppError+L10n.swift +++ b/Passepartout/Library/Sources/AppUI/L10n/AppError+L10n.swift @@ -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 diff --git a/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift b/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift index ed47c9c6..6c931a8b 100644 --- a/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift +++ b/Passepartout/Library/Sources/AppUI/L10n/SwiftGen+Strings.swift @@ -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 { diff --git a/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings b/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings index 70bac877..f3879e1c 100644 --- a/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Library/Sources/AppUI/Resources/en.lproj/Localizable.strings @@ -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=%@)."; diff --git a/Passepartout/Library/Sources/AppUI/Views/App/ProfileImporterModifier.swift b/Passepartout/Library/Sources/AppUI/Views/App/ProfileImporterModifier.swift index 2028c197..8ece598d 100644 --- a/Passepartout/Library/Sources/AppUI/Views/App/ProfileImporterModifier.swift +++ b/Passepartout/Library/Sources/AppUI/Views/App/ProfileImporterModifier.swift @@ -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>) { diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/OpenVPNView+Configuration.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/OpenVPNView+Configuration.swift new file mode 100644 index 00000000..bf3ce765 --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/OpenVPNView+Configuration.swift @@ -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 . +// + +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 + } +} diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/OpenVPNView.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/OpenVPNView.swift index 98a02228..7e5472d1 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Modules/OpenVPNView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/OpenVPNView.swift @@ -23,8 +23,10 @@ // along with Passepartout. If not, see . // +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) { + 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 diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/ProviderContentModifier.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/ProviderContentModifier.swift index b39d1ef6..2d8ffced 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Provider/ProviderContentModifier.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/ProviderContentModifier.swift @@ -41,8 +41,6 @@ struct ProviderContentModifier: ViewModifier where Entity: let entityType: Entity.Type - let isRequired: Bool - @Binding var paywallReason: PaywallReason? @@ -64,9 +62,7 @@ struct ProviderContentModifier: 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.self, - isRequired: false, paywallReason: .constant(nil), providerRows: {}, onSelectProvider: { _, _, _ in } diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderContentModifier.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderContentModifier.swift index 3a2bbb62..7415f6d0 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderContentModifier.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderContentModifier.swift @@ -28,7 +28,7 @@ import PassepartoutKit import SwiftUI import UtilsLibrary -struct VPNProviderContentModifier: ViewModifier where Configuration: ProviderConfigurationIdentifiable & Codable, Destination: Hashable, ProviderRows: View { +struct VPNProviderContentModifier: ViewModifier where Configuration: ProviderConfigurationIdentifiable & Codable, ProviderRows: View { var apis: [APIMapper] = API.shared @@ -38,12 +38,10 @@ struct VPNProviderContentModifier: Vie @Binding var selectedEntity: VPNEntity? - let isRequired: Bool - @Binding var paywallReason: PaywallReason? - let entityDestination: Destination + let entityDestination: any Hashable @ViewBuilder let providerRows: ProviderRows @@ -55,7 +53,6 @@ struct VPNProviderContentModifier: Vie apis: apis, providerId: $providerId, entityType: VPNEntity.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?), - isRequired: false, paywallReason: .constant(nil), entityDestination: "Destination", providerRows: {