Move module destinations to standalone entities (#1166)

Decouple destinations from the module view so that one can navigate to
them from any point of the app. Affects only OpenVPN for now.

Preparation for "profile shortcuts".
This commit is contained in:
Davide 2025-02-12 11:03:14 +01:00 committed by GitHub
parent 41874c8bb1
commit 5059423bf3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 278 additions and 186 deletions

View File

@ -0,0 +1,147 @@
//
// OpenVPNModule+Destination.swift
// Passepartout
//
// Created by Davide De Rosa on 2/12/25.
// Copyright (c) 2025 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 CommonUtils
import PassepartoutKit
import SwiftUI
extension OpenVPNView {
enum Subroute: Hashable {
case providerServer
case providerConfiguration(OpenVPN.Configuration)
case credentials
case editRemotes
}
}
extension OpenVPNModule.Builder: ModuleDestinationProviding {
public func moduleDestination(with parameters: ModuleDestinationParameters, path: Binding<NavigationPath>) -> some ViewModifier {
DestinationModifier(parameters: parameters, path: path)
}
}
private struct DestinationModifier: ViewModifier {
let parameters: ModuleDestinationParameters
@Binding
var path: NavigationPath
func body(content: Content) -> some View {
content
.navigationDestination(for: OpenVPNView.Subroute.self) {
switch $0 {
case .providerServer:
draft.wrappedValue.providerSelection.map {
ProviderServerView(
moduleId: parameters.module.id,
providerId: $0.id,
selectedEntity: $0.entity,
filtersWithSelection: true,
onSelect: onSelectServer
)
}
case .providerConfiguration(let configuration):
Form {
OpenVPNView.ConfigurationView(
isServerPushed: false,
configuration: configuration.builder(),
credentialsRoute: nil,
remotesRoute: nil,
excludedEndpoints: excludedEndpoints
)
}
.themeForm()
.navigationTitle(Strings.Global.Nouns.configuration)
case .credentials:
Form {
OpenVPNCredentialsView(
profileEditor: parameters.editor,
providerId: draft.wrappedValue.providerId,
isInteractive: draft.isInteractive,
credentials: draft.credentials
)
}
.navigationTitle(Strings.Modules.Openvpn.credentials)
.themeForm()
.themeAnimation(on: draft.wrappedValue.isInteractive, category: .modules)
case .editRemotes:
OpenVPNView.RemotesView(remotes: editableRemotesBinding)
}
}
}
}
private extension DestinationModifier {
var draft: Binding<OpenVPNModule.Builder> {
guard let builder = parameters.module as? OpenVPNModule.Builder else {
fatalError("Not a OpenVPNModule.Builder")
}
return parameters.editor[builder]
}
var excludedEndpoints: ObservableList<ExtendedEndpoint> {
parameters.editor.excludedEndpoints(for: parameters.module.id, preferences: parameters.preferences)
}
var editableRemotesBinding: Binding<[String]> {
Binding {
draft.wrappedValue.configurationBuilder?.remotes?.map(\.rawValue) ?? []
} set: {
draft.wrappedValue.configurationBuilder?.remotes = $0.compactMap {
ExtendedEndpoint(rawValue: $0)
}
}
}
func onSelectServer(server: ProviderServer, preset: ProviderPreset<OpenVPNProviderTemplate>) {
draft.wrappedValue.providerEntity = ProviderEntity(server: server, preset: preset)
resetExcludedEndpointsWithCurrentProviderEntity()
path.removeLast()
}
// filter out exclusions unrelated to current server
func resetExcludedEndpointsWithCurrentProviderEntity() {
do {
let cfg = try draft.wrappedValue.providerSelection?.configuration()
parameters.editor.profile.attributes.editPreferences(inModule: parameters.module.id) {
if let cfg {
$0.excludedEndpoints = Set(cfg.remotes?.filter {
parameters.preferences.isExcludedEndpoint($0)
} ?? [])
} else {
$0.excludedEndpoints = []
}
}
} catch {
pp_log(.app, .error, "Unable to build provider configuration for excluded endpoints: \(error)")
}
}
}

View File

@ -23,7 +23,6 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonUtils
import PassepartoutKit
import SwiftUI
import UILibrary
@ -36,3 +35,67 @@ extension OpenVPNModule.Builder: ModuleViewProviding {
extension OpenVPNModule: ProviderServerCoordinatorSupporting {
}
// MARK: - Previews
// swiftlint: disable force_try
extension OpenVPN.Configuration.Builder {
static var forPreviews: Self {
var builder = OpenVPN.Configuration.Builder(withFallbacks: true)
builder.noPullMask = [.proxy]
builder.authUserPass = true
builder.remotes = [
.init(rawValue: "2.2.2.2:UDP:2222")!,
.init(rawValue: "6.6.6.6:UDP:6666")!,
.init(rawValue: "12.12.12.12:TCP:21212")!,
.init(rawValue: "12:12:12:12:20:20:20:20:TCP6:21212")!
]
builder.ipv4 = IPSettings(subnet: try! .init("5.5.5.5", 24))
.including(routes: [
.init(defaultWithGateway: .ip("120.1.1.1", .v4)),
.init(.init(rawValue: "55.10.20.30/32"), nil)
])
.excluding(routes: [
.init(.init(rawValue: "88.40.30.30/32"), nil),
.init(.init(rawValue: "60.60.60.60/32"), .ip("127.0.0.1", .v4))
])
builder.ipv6 = IPSettings(subnet: try! .init("::5", 24))
.including(routes: [
.init(defaultWithGateway: .ip("120::1:1:1", .v6)),
.init(.init(rawValue: "55:10:20::30/128"), nil),
.init(.init(rawValue: "60:60:60::60/128"), .ip("::2", .v6))
])
.excluding(routes: [
.init(.init(rawValue: "88:40:30::30/32"), nil)
])
builder.routingPolicies = [.IPv4, .IPv6]
builder.dnsServers = ["1.2.3.4", "4.5.6.7"]
builder.dnsDomain = "domain.com"
builder.searchDomains = ["search1.com", "search2.com"]
builder.httpProxy = try! .init("10.10.10.10", 1080)
builder.httpsProxy = try! .init("10.10.10.10", 8080)
builder.proxyAutoConfigurationURL = URL(string: "https://hello.pac")!
builder.proxyBypassDomains = ["bypass1.com", "bypass2.com"]
builder.xorMethod = .xormask(mask: .init(Data(hex: "1234")))
builder.ca = .init(mockPem: "ca-certificate")
builder.clientCertificate = .init(mockPem: "client-certificate")
builder.clientKey = .init(mockPem: "client-key")
builder.tlsWrap = .init(strategy: .auth, key: .init(biData: Data(count: 256)))
builder.keepAliveInterval = 10.0
builder.renegotiatesAfter = 60.0
builder.randomizeEndpoint = true
builder.randomizeHostnames = true
return builder
}
}
// swiftlint: enable force_try
private extension OpenVPN.CryptoContainer {
init(mockPem: String) {
self.init(pem: """
-----BEGIN CERTIFICATE-----
\(mockPem)
-----END CERTIFICATE-----
""")
}
}

View File

@ -1,89 +0,0 @@
//
// OpenVPNView+Extensions.swift
// Passepartout
//
// Created by Davide De Rosa on 12/8/24.
// Copyright (c) 2025 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 Foundation
import PassepartoutKit
// swiftlint: disable force_try
extension OpenVPN.Configuration.Builder {
static var forPreviews: Self {
var builder = OpenVPN.Configuration.Builder(withFallbacks: true)
builder.noPullMask = [.proxy]
builder.authUserPass = true
builder.remotes = [
.init(rawValue: "2.2.2.2:UDP:2222")!,
.init(rawValue: "6.6.6.6:UDP:6666")!,
.init(rawValue: "12.12.12.12:TCP:21212")!,
.init(rawValue: "12:12:12:12:20:20:20:20:TCP6:21212")!
]
builder.ipv4 = IPSettings(subnet: try! .init("5.5.5.5", 24))
.including(routes: [
.init(defaultWithGateway: .ip("120.1.1.1", .v4)),
.init(.init(rawValue: "55.10.20.30/32"), nil)
])
.excluding(routes: [
.init(.init(rawValue: "88.40.30.30/32"), nil),
.init(.init(rawValue: "60.60.60.60/32"), .ip("127.0.0.1", .v4))
])
builder.ipv6 = IPSettings(subnet: try! .init("::5", 24))
.including(routes: [
.init(defaultWithGateway: .ip("120::1:1:1", .v6)),
.init(.init(rawValue: "55:10:20::30/128"), nil),
.init(.init(rawValue: "60:60:60::60/128"), .ip("::2", .v6))
])
.excluding(routes: [
.init(.init(rawValue: "88:40:30::30/32"), nil)
])
builder.routingPolicies = [.IPv4, .IPv6]
builder.dnsServers = ["1.2.3.4", "4.5.6.7"]
builder.dnsDomain = "domain.com"
builder.searchDomains = ["search1.com", "search2.com"]
builder.httpProxy = try! .init("10.10.10.10", 1080)
builder.httpsProxy = try! .init("10.10.10.10", 8080)
builder.proxyAutoConfigurationURL = URL(string: "https://hello.pac")!
builder.proxyBypassDomains = ["bypass1.com", "bypass2.com"]
builder.xorMethod = .xormask(mask: .init(Data(hex: "1234")))
builder.ca = .init(mockPem: "ca-certificate")
builder.clientCertificate = .init(mockPem: "client-certificate")
builder.clientKey = .init(mockPem: "client-key")
builder.tlsWrap = .init(strategy: .auth, key: .init(biData: Data(count: 256)))
builder.keepAliveInterval = 10.0
builder.renegotiatesAfter = 60.0
builder.randomizeEndpoint = true
builder.randomizeHostnames = true
return builder
}
}
// swiftlint: enable force_try
private extension OpenVPN.CryptoContainer {
init(mockPem: String) {
self.init(pem: """
-----BEGIN CERTIFICATE-----
\(mockPem)
-----END CERTIFICATE-----
""")
}
}

View File

@ -80,7 +80,15 @@ struct OpenVPNView: View, ModuleDraftEditing {
isImporting: $isImporting,
errorHandler: errorHandler
))
.navigationDestination(for: Subroute.self, destination: destination)
.modifier(module.moduleDestination(
with: .init(
editor: editor,
module: module,
preferences: modulePreferences,
impl: impl
),
path: path
))
.themeAnimation(on: draft.wrappedValue.providerId, category: .modules)
.modifier(PaywallModifier(reason: $paywallReason))
.withErrorHandler(errorHandler)
@ -144,105 +152,10 @@ private extension OpenVPNView {
}
}
// MARK: - Destinations
private extension OpenVPNView {
enum Subroute: Hashable {
case providerServer
case providerConfiguration(OpenVPN.Configuration)
case credentials
case editRemotes
}
@ViewBuilder
func destination(for route: Subroute) -> some View {
switch route {
case .providerServer:
draft.wrappedValue.providerSelection.map {
ProviderServerView(
moduleId: module.id,
providerId: $0.id,
selectedEntity: $0.entity,
filtersWithSelection: true,
onSelect: onSelectServer
)
}
case .providerConfiguration(let configuration):
Form {
ConfigurationView(
isServerPushed: false,
configuration: configuration.builder(),
credentialsRoute: nil,
remotesRoute: nil,
excludedEndpoints: excludedEndpoints
)
}
.themeForm()
.navigationTitle(Strings.Global.Nouns.configuration)
case .credentials:
Form {
OpenVPNCredentialsView(
profileEditor: editor,
providerId: draft.wrappedValue.providerId,
isInteractive: draft.isInteractive,
credentials: draft.credentials
)
}
.navigationTitle(Strings.Modules.Openvpn.credentials)
.themeForm()
.themeAnimation(on: draft.wrappedValue.isInteractive, category: .modules)
case .editRemotes:
RemotesView(remotes: editableRemotesBinding)
}
}
}
// MARK: - Logic
private extension OpenVPNView {
var excludedEndpoints: ObservableList<ExtendedEndpoint> {
editor.excludedEndpoints(for: module.id, preferences: modulePreferences)
}
var editableRemotesBinding: Binding<[String]> {
Binding {
draft.wrappedValue.configurationBuilder?.remotes?.map(\.rawValue) ?? []
} set: {
draft.wrappedValue.configurationBuilder?.remotes = $0.compactMap {
ExtendedEndpoint(rawValue: $0)
}
}
}
func onSelectServer(server: ProviderServer, preset: ProviderPreset<OpenVPNProviderTemplate>) {
draft.wrappedValue.providerEntity = ProviderEntity(server: server, preset: preset)
resetExcludedEndpointsWithCurrentProviderEntity()
path.wrappedValue.removeLast()
}
// filter out exclusions unrelated to current server
func resetExcludedEndpointsWithCurrentProviderEntity() {
do {
let cfg = try draft.wrappedValue.providerSelection?.configuration()
editor.profile.attributes.editPreferences(inModule: module.id) {
if let cfg {
$0.excludedEndpoints = Set(cfg.remotes?.filter {
modulePreferences.isExcludedEndpoint($0)
} ?? [])
} else {
$0.excludedEndpoints = []
}
}
} catch {
pp_log(.app, .error, "Unable to build provider configuration for excluded endpoints: \(error)")
}
}
}
// MARK: - Previews

View File

@ -0,0 +1,58 @@
//
// ModuleDestinationProviding.swift
// Passepartout
//
// Created by Davide De Rosa on 2/12/25.
// Copyright (c) 2025 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 CommonLibrary
import PassepartoutKit
import SwiftUI
public protocol ModuleDestinationProviding {
associatedtype Destination: ViewModifier
@MainActor
func moduleDestination(with parameters: ModuleDestinationParameters, path: Binding<NavigationPath>) -> Destination
}
public struct ModuleDestinationParameters {
public let editor: ProfileEditor
public let module: any ModuleBuilder
public let preferences: ModulePreferences
public let impl: (any ModuleImplementation)?
@MainActor
public init(
editor: ProfileEditor,
module: any ModuleBuilder,
preferences: ModulePreferences,
impl: (any ModuleImplementation)?
) {
self.editor = editor
self.module = module
self.preferences = preferences
self.impl = impl
}
}