From ee8ef34f0622720e0df1d6453e2507e8ce0328b6 Mon Sep 17 00:00:00 2001 From: Davide Date: Wed, 23 Oct 2024 15:42:54 +0200 Subject: [PATCH] Avoid nested module navigation (#749) A NavigationLink in VPNProviderContentModifier raised a few questions about the navigation approach in module views. It turned out that having a Binding to a local ObservedObject (ProfileEditor) is a recipe for disaster. Therefore: - We don't need a binding to the editor module (the draft), because by doing so we end up _observing_ the same changes from two properties, the binding and the editor. This seems to drive SwiftUI crazy and freezes the app once we navigate from the module to another view (e.g. in OpenVPN the credentials or the provider server). Use the module binding as a shortcut, but do not assign the binding to the view to avoid unnecessary observation. - Keep .navigationDestination() in the module view, and pass a known destination to VPNProviderContentModifier. This will save the modifier from creating a nested NavigationLink destination. The VPNProviderServerView is now openly instantiated by the module view when such destination is triggered by the NavigationLink in the modifier. - Do not implicitly dismiss VPNProviderServerView on selection, let the presenter take care. In order to do so, we add a .navigationPath environment key through which the module view can modify the current navigation stack. --- .../EnvironmentValues+Extensions.swift | 41 +++++++++++++ .../Views/Extensions/ModuleDraftEditing.swift | 43 +++++++++++++ .../Sources/AppUI/Views/Modules/DNSView.swift | 28 ++++----- .../Extensions/OpenVPNModule+Extensions.swift | 2 +- .../AppUI/Views/Modules/HTTPProxyView.swift | 26 +++----- .../Sources/AppUI/Views/Modules/IPView.swift | 50 +++++++-------- .../AppUI/Views/Modules/OnDemandView.swift | 61 +++++++++---------- .../AppUI/Views/Modules/OpenVPNView.swift | 48 ++++++++++----- .../AppUI/Views/Modules/WireGuardView.swift | 46 ++------------ .../Profile/iOS/ProfileEditView+iOS.swift | 1 + .../macOS/ProfileSplitView+macOS.swift | 6 +- .../ProfileEditor/ProfileEditor+UI.swift | 2 +- .../Provider/VPNProviderContentModifier.swift | 53 ++++++++-------- .../Provider/VPNProviderServerView.swift | 7 +-- 14 files changed, 228 insertions(+), 186 deletions(-) create mode 100644 Passepartout/Library/Sources/AppUI/Views/Extensions/EnvironmentValues+Extensions.swift create mode 100644 Passepartout/Library/Sources/AppUI/Views/Extensions/ModuleDraftEditing.swift diff --git a/Passepartout/Library/Sources/AppUI/Views/Extensions/EnvironmentValues+Extensions.swift b/Passepartout/Library/Sources/AppUI/Views/Extensions/EnvironmentValues+Extensions.swift new file mode 100644 index 00000000..3ccc3dbb --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/Extensions/EnvironmentValues+Extensions.swift @@ -0,0 +1,41 @@ +// +// EnvironmentValues+Extensions.swift +// Passepartout +// +// Created by Davide De Rosa on 10/23/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 SwiftUI + +extension EnvironmentValues { + var navigationPath: Binding { + get { + self[NavigationPathKey.self] + } + set { + self[NavigationPathKey.self] = newValue + } + } +} + +private struct NavigationPathKey: EnvironmentKey { + static let defaultValue: Binding = .constant(NavigationPath()) +} diff --git a/Passepartout/Library/Sources/AppUI/Views/Extensions/ModuleDraftEditing.swift b/Passepartout/Library/Sources/AppUI/Views/Extensions/ModuleDraftEditing.swift new file mode 100644 index 00000000..c50b5365 --- /dev/null +++ b/Passepartout/Library/Sources/AppUI/Views/Extensions/ModuleDraftEditing.swift @@ -0,0 +1,43 @@ +// +// ModuleDraftEditing.swift +// Passepartout +// +// Created by Davide De Rosa on 10/23/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 + +protocol ModuleDraftEditing { + associatedtype Draft: ModuleBuilder + + var editor: ProfileEditor { get } + + var module: Draft { get } +} + +extension ModuleDraftEditing { + + @MainActor + var draft: Binding { + editor[module] + } +} diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/DNSView.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/DNSView.swift index 7fcbfd82..561f88cb 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Modules/DNSView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/DNSView.swift @@ -28,21 +28,15 @@ import PassepartoutKit import SwiftUI import UtilsLibrary -struct DNSView: View { +struct DNSView: View, ModuleDraftEditing { @EnvironmentObject private var theme: Theme @ObservedObject - private var editor: ProfileEditor + var editor: ProfileEditor - @Binding - private var draft: DNSModule.Builder - - init(editor: ProfileEditor, module: DNSModule.Builder) { - self.editor = editor - _draft = editor.binding(forModule: module) - } + let module: DNSModule.Builder var body: some View { debugChanges() @@ -56,7 +50,7 @@ struct DNSView: View { .labelsHidden() } .themeManualInput() - .moduleView(editor: editor, draft: draft) + .moduleView(editor: editor, draft: draft.wrappedValue) } } @@ -69,21 +63,21 @@ private extension DNSView { var protocolSection: some View { Section { - Picker(Strings.Global.protocol, selection: $draft.protocolType) { + Picker(Strings.Global.protocol, selection: draft.protocolType) { ForEach(Self.allProtocols, id: \.self) { Text($0.localizedDescription) } } - switch draft.protocolType { + switch draft.wrappedValue.protocolType { case .cleartext: EmptyView() case .https: - ThemeTextField(Strings.Unlocalized.url, text: $draft.dohURL, placeholder: Strings.Unlocalized.Placeholders.dohURL) + ThemeTextField(Strings.Unlocalized.url, text: draft.dohURL, placeholder: Strings.Unlocalized.Placeholders.dohURL) .labelsHidden() case .tls: - ThemeTextField(Strings.Global.hostname, text: $draft.dotHostname, placeholder: Strings.Unlocalized.Placeholders.dotHostname) + ThemeTextField(Strings.Global.hostname, text: draft.dotHostname, placeholder: Strings.Unlocalized.Placeholders.dotHostname) .labelsHidden() } } @@ -91,7 +85,7 @@ private extension DNSView { var domainSection: some View { Group { - ThemeTextField(Strings.Global.domain, text: $draft.domainName ?? "", placeholder: Strings.Unlocalized.Placeholders.hostname) + ThemeTextField(Strings.Global.domain, text: draft.domainName ?? "", placeholder: Strings.Unlocalized.Placeholders.hostname) } .themeSection(header: Strings.Global.domain) } @@ -100,7 +94,7 @@ private extension DNSView { theme.listSection( Strings.Entities.Dns.servers, addTitle: Strings.Modules.Dns.Servers.add, - originalItems: $draft.servers, + originalItems: draft.servers, itemLabel: { if $0 { Text($1.wrappedValue) @@ -115,7 +109,7 @@ private extension DNSView { theme.listSection( Strings.Entities.Dns.searchDomains, addTitle: Strings.Modules.Dns.SearchDomains.add, - originalItems: $draft.searchDomains ?? [], + originalItems: draft.searchDomains ?? [], itemLabel: { if $0 { Text($1.wrappedValue) diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/Extensions/OpenVPNModule+Extensions.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/Extensions/OpenVPNModule+Extensions.swift index a2de730d..80fa95f0 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Modules/Extensions/OpenVPNModule+Extensions.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/Extensions/OpenVPNModule+Extensions.swift @@ -34,7 +34,7 @@ extension OpenVPNModule.Builder: ModuleViewProviding { extension OpenVPNModule.Builder: InteractiveViewProviding { func interactiveView(with editor: ProfileEditor) -> some View { - let draft = editor.binding(forModule: self) + let draft = editor[self] return OpenVPNView.CredentialsView( isInteractive: draft.isInteractive, diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/HTTPProxyView.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/HTTPProxyView.swift index 63ac3e49..594af411 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Modules/HTTPProxyView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/HTTPProxyView.swift @@ -27,21 +27,15 @@ import PassepartoutKit import SwiftUI import UtilsLibrary -struct HTTPProxyView: View { +struct HTTPProxyView: View, ModuleDraftEditing { @EnvironmentObject private var theme: Theme @ObservedObject - private var editor: ProfileEditor + var editor: ProfileEditor - @Binding - private var draft: HTTPProxyModule.Builder - - init(editor: ProfileEditor, module: HTTPProxyModule.Builder) { - self.editor = editor - _draft = editor.binding(forModule: module) - } + let module: HTTPProxyModule.Builder var body: some View { Group { @@ -52,30 +46,30 @@ struct HTTPProxyView: View { } .labelsHidden() .themeManualInput() - .moduleView(editor: editor, draft: draft) + .moduleView(editor: editor, draft: draft.wrappedValue) } } private extension HTTPProxyView { var httpSection: some View { Group { - ThemeTextField(Strings.Global.address, text: $draft.address, placeholder: Strings.Unlocalized.Placeholders.proxyIPv4Address) - ThemeTextField(Strings.Global.port, text: $draft.port.toString(omittingZero: true), placeholder: Strings.Unlocalized.Placeholders.proxyPort) + ThemeTextField(Strings.Global.address, text: draft.address, placeholder: Strings.Unlocalized.Placeholders.proxyIPv4Address) + ThemeTextField(Strings.Global.port, text: draft.port.toString(omittingZero: true), placeholder: Strings.Unlocalized.Placeholders.proxyPort) } .themeSection(header: Strings.Unlocalized.http) } var httpsSection: some View { Group { - ThemeTextField(Strings.Global.address, text: $draft.secureAddress, placeholder: Strings.Unlocalized.Placeholders.proxyIPv4Address) - ThemeTextField(Strings.Global.port, text: $draft.securePort.toString(omittingZero: true), placeholder: Strings.Unlocalized.Placeholders.proxyPort) + ThemeTextField(Strings.Global.address, text: draft.secureAddress, placeholder: Strings.Unlocalized.Placeholders.proxyIPv4Address) + ThemeTextField(Strings.Global.port, text: draft.securePort.toString(omittingZero: true), placeholder: Strings.Unlocalized.Placeholders.proxyPort) } .themeSection(header: Strings.Unlocalized.https) } var pacSection: some View { Group { - ThemeTextField(Strings.Unlocalized.url, text: $draft.pacURLString, placeholder: Strings.Unlocalized.Placeholders.pacURL) + ThemeTextField(Strings.Unlocalized.url, text: draft.pacURLString, placeholder: Strings.Unlocalized.Placeholders.pacURL) } .themeSection(header: Strings.Unlocalized.pac) } @@ -85,7 +79,7 @@ private extension HTTPProxyView { theme.listSection( Strings.Entities.HttpProxy.bypassDomains, addTitle: Strings.Modules.HttpProxy.BypassDomains.add, - originalItems: $draft.bypassDomains, + originalItems: draft.bypassDomains, itemLabel: { if $0 { Text($1.wrappedValue) diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/IPView.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/IPView.swift index 937e6910..4ffbcf6a 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Modules/IPView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/IPView.swift @@ -27,29 +27,23 @@ import PassepartoutKit import SwiftUI import UtilsLibrary -struct IPView: View { +struct IPView: View, ModuleDraftEditing { @ObservedObject - private var editor: ProfileEditor + var editor: ProfileEditor - @Binding - private var draft: IPModule.Builder + let module: IPModule.Builder @State private var routePresentation: RoutePresentation? - init(editor: ProfileEditor, module: IPModule.Builder) { - self.editor = editor - _draft = editor.binding(forModule: module) - } - var body: some View { Group { ipSections(for: .v4) ipSections(for: .v6) interfaceSection } - .moduleView(editor: editor, draft: draft) + .moduleView(editor: editor, draft: draft.wrappedValue) .themeModal(item: $routePresentation, content: routeModal) } } @@ -137,9 +131,9 @@ private extension IPView { ThemeTextField( Strings.Unlocalized.mtu, text: Binding { - draft.mtu?.description ?? "" + draft.wrappedValue.mtu?.description ?? "" } set: { - draft.mtu = Int($0) + draft.wrappedValue.mtu = Int($0) }, placeholder: Strings.Unlocalized.Placeholders.mtu ) @@ -153,16 +147,16 @@ private extension IPView { switch family { case .v4: return Binding { - draft.ipv4 ?? IPSettings(subnet: nil) + draft.wrappedValue.ipv4 ?? IPSettings(subnet: nil) } set: { - draft.ipv4 = $0 + draft.wrappedValue.ipv4 = $0 } case .v6: return Binding { - draft.ipv6 ?? IPSettings(subnet: nil) + draft.wrappedValue.ipv6 ?? IPSettings(subnet: nil) } set: { - draft.ipv6 = $0 + draft.wrappedValue.ipv6 = $0 } } } @@ -180,31 +174,31 @@ private extension IPView { case .included(let family): switch family { case .v4: - if draft.ipv4 == nil { - draft.ipv4 = IPSettings(subnet: nil) + if draft.wrappedValue.ipv4 == nil { + draft.wrappedValue.ipv4 = IPSettings(subnet: nil) } - draft.ipv4?.include(route) + draft.wrappedValue.ipv4?.include(route) case .v6: - if draft.ipv6 == nil { - draft.ipv6 = IPSettings(subnet: nil) + if draft.wrappedValue.ipv6 == nil { + draft.wrappedValue.ipv6 = IPSettings(subnet: nil) } - draft.ipv6?.include(route) + draft.wrappedValue.ipv6?.include(route) } case .excluded(let family): switch family { case .v4: - if draft.ipv4 == nil { - draft.ipv4 = IPSettings(subnet: nil) + if draft.wrappedValue.ipv4 == nil { + draft.wrappedValue.ipv4 = IPSettings(subnet: nil) } - draft.ipv4?.exclude(route) + draft.wrappedValue.ipv4?.exclude(route) case .v6: - if draft.ipv6 == nil { - draft.ipv6 = IPSettings(subnet: nil) + if draft.wrappedValue.ipv6 == nil { + draft.wrappedValue.ipv6 = IPSettings(subnet: nil) } - draft.ipv6?.exclude(route) + draft.wrappedValue.ipv6?.exclude(route) } } } diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/OnDemandView.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/OnDemandView.swift index 79a29f1b..6ecdbb6f 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Modules/OnDemandView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/OnDemandView.swift @@ -27,7 +27,7 @@ import PassepartoutKit import SwiftUI import UtilsLibrary -struct OnDemandView: View { +struct OnDemandView: View, ModuleDraftEditing { @EnvironmentObject private var theme: Theme @@ -36,13 +36,12 @@ struct OnDemandView: View { private var iapManager: IAPManager @ObservedObject - private var editor: ProfileEditor + var editor: ProfileEditor + + let module: OnDemandModule.Builder private let wifi: Wifi - @Binding - private var draft: OnDemandModule.Builder - @State private var paywallReason: PaywallReason? @@ -52,8 +51,8 @@ struct OnDemandView: View { observer: WifiObserver? = nil ) { self.editor = editor + self.module = module wifi = Wifi(observer: observer ?? CoreLocationWifiObserver()) - _draft = editor.binding(forModule: module) } var body: some View { @@ -61,7 +60,7 @@ struct OnDemandView: View { enabledSection restrictedArea } - .moduleView(editor: editor, draft: draft) + .moduleView(editor: editor, draft: draft.wrappedValue) .modifier(PaywallModifier(reason: $paywallReason)) } } @@ -75,7 +74,7 @@ private extension OnDemandView { var enabledSection: some View { Section { - Toggle(Strings.Global.enabled, isOn: $draft.isEnabled) + Toggle(Strings.Global.enabled, isOn: draft.isEnabled) } } @@ -91,9 +90,9 @@ private extension OnDemandView { EmptyView() default: - if draft.isEnabled { + if draft.wrappedValue.isEnabled { policySection - if draft.policy != .any { + if draft.wrappedValue.policy != .any { networkSection wifiSection } @@ -102,7 +101,7 @@ private extension OnDemandView { } var policySection: some View { - Picker(Strings.Modules.OnDemand.policy, selection: $draft.policy) { + Picker(Strings.Modules.OnDemand.policy, selection: draft.policy) { ForEach(Self.allPolicies, id: \.self) { Text($0.localizedDescription) } @@ -111,16 +110,16 @@ private extension OnDemandView { } var policyFooterDescription: String { - guard draft.isEnabled else { + guard draft.wrappedValue.isEnabled else { return "" // better animation than removing footer completely } let suffix: String - switch draft.policy { + switch draft.wrappedValue.policy { case .any: suffix = Strings.Modules.OnDemand.Policy.Footer.any case .including, .excluding: - if draft.policy == .including { + if draft.wrappedValue.policy == .including { suffix = Strings.Modules.OnDemand.Policy.Footer.including } else { suffix = Strings.Modules.OnDemand.Policy.Footer.excluding @@ -132,9 +131,9 @@ private extension OnDemandView { var networkSection: some View { Group { if Utils.hasCellularData() { - Toggle(Strings.Modules.OnDemand.mobile, isOn: $draft.withMobileNetwork) + Toggle(Strings.Modules.OnDemand.mobile, isOn: draft.withMobileNetwork) } else if Utils.hasEthernet() { - Toggle(Strings.Modules.OnDemand.ethernet, isOn: $draft.withEthernetNetwork) + Toggle(Strings.Modules.OnDemand.ethernet, isOn: draft.withEthernetNetwork) } } .themeSection(header: Strings.Global.networks) @@ -173,53 +172,53 @@ private extension OnDemandView { private extension OnDemandView { var allSSIDs: Binding<[String]> { .init { - Array(draft.withSSIDs.keys) + Array(draft.wrappedValue.withSSIDs.keys) } set: { newValue in - draft.withSSIDs.forEach { + draft.wrappedValue.withSSIDs.forEach { guard newValue.contains($0.key) else { - draft.withSSIDs.removeValue(forKey: $0.key) + draft.wrappedValue.withSSIDs.removeValue(forKey: $0.key) return } } newValue.forEach { - guard draft.withSSIDs[$0] == nil else { + guard draft.wrappedValue.withSSIDs[$0] == nil else { return } - draft.withSSIDs[$0] = false + draft.wrappedValue.withSSIDs[$0] = false } } } var onSSIDs: Binding> { .init { - Set(draft.withSSIDs.filter { + Set(draft.wrappedValue.withSSIDs.filter { $0.value }.map(\.key)) } set: { newValue in - draft.withSSIDs.forEach { + draft.wrappedValue.withSSIDs.forEach { guard newValue.contains($0.key) else { - if draft.withSSIDs[$0.key] != nil { - draft.withSSIDs[$0.key] = false + if draft.wrappedValue.withSSIDs[$0.key] != nil { + draft.wrappedValue.withSSIDs[$0.key] = false } else { - draft.withSSIDs.removeValue(forKey: $0.key) + draft.wrappedValue.withSSIDs.removeValue(forKey: $0.key) } return } } newValue.forEach { - guard !(draft.withSSIDs[$0] ?? false) else { + guard !(draft.wrappedValue.withSSIDs[$0] ?? false) else { return } - draft.withSSIDs[$0] = true + draft.wrappedValue.withSSIDs[$0] = true } } } func isSSIDOn(_ ssid: String) -> Binding { .init { - draft.withSSIDs[ssid] ?? false + draft.wrappedValue.withSSIDs[ssid] ?? false } set: { - draft.withSSIDs[ssid] = $0 + draft.wrappedValue.withSSIDs[ssid] = $0 } } } @@ -228,7 +227,7 @@ private extension OnDemandView { func requestSSID(_ text: Binding) { Task { @MainActor in let ssid = try await wifi.currentSSID() - if !draft.withSSIDs.keys.contains(ssid) { + if !draft.wrappedValue.withSSIDs.keys.contains(ssid) { text.wrappedValue = ssid } } diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/OpenVPNView.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/OpenVPNView.swift index 5048e11c..13bf1b0f 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Modules/OpenVPNView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/OpenVPNView.swift @@ -26,35 +26,36 @@ import PassepartoutKit import SwiftUI -struct OpenVPNView: View { +struct OpenVPNView: View, ModuleDraftEditing { + + @Environment(\.navigationPath) + private var path @ObservedObject - private var editor: ProfileEditor + var editor: ProfileEditor + + let module: OpenVPNModule.Builder private let isServerPushed: Bool - @Binding - private var draft: OpenVPNModule.Builder - init(serverConfiguration: OpenVPN.Configuration) { let module = OpenVPNModule.Builder(configurationBuilder: serverConfiguration.builder()) let editor = ProfileEditor(modules: [module]) self.editor = editor - _draft = .constant(module) + self.module = module isServerPushed = true } init(editor: ProfileEditor, module: OpenVPNModule.Builder) { self.editor = editor - _draft = editor.binding(forModule: module) + self.module = module isServerPushed = false } var body: some View { contentView - .themeAnimation(on: draft, category: .modules) - .moduleView(editor: editor, draft: draft, withName: !isServerPushed) + .moduleView(editor: editor, draft: draft.wrappedValue, withName: !isServerPushed) .navigationDestination(for: Subroute.self, destination: destination) } } @@ -63,7 +64,7 @@ struct OpenVPNView: View { private extension OpenVPNView { var configuration: OpenVPN.Configuration.Builder { - draft.configurationBuilder ?? .init(withFallbacks: true) + draft.wrappedValue.configurationBuilder ?? .init(withFallbacks: true) } @ViewBuilder @@ -80,7 +81,8 @@ private extension OpenVPNView { VPNProviderContentModifier( providerId: providerId, selectedEntity: providerEntity, - isRequired: draft.configurationBuilder == nil, + isRequired: draft.wrappedValue.configurationBuilder == nil, + entityDestination: Subroute.providerServer, providerRows: { moduleGroup(for: providerAccountRows) } @@ -88,11 +90,11 @@ private extension OpenVPNView { } var providerId: Binding { - editor.binding(forProviderOf: draft.id) + editor.binding(forProviderOf: module.id) } var providerEntity: Binding?> { - editor.binding(forProviderEntityOf: draft.id) + editor.binding(forProviderEntityOf: module.id) } var providerAccountRows: [ModuleRow]? { @@ -101,6 +103,11 @@ private extension OpenVPNView { } private extension OpenVPNView { + func onSelectServer(server: VPNServer, preset: VPNPreset) { + providerEntity.wrappedValue = VPNEntity(server: server, preset: preset) + path.wrappedValue.removeLast() + } + func importConfiguration(from url: URL) { // TODO: #657, import draft from external URL } @@ -110,16 +117,27 @@ private extension OpenVPNView { private extension OpenVPNView { enum Subroute: Hashable { + case providerServer + case credentials } @ViewBuilder func destination(for route: Subroute) -> some View { switch route { + case .providerServer: + providerId.wrappedValue.map { + VPNProviderServerView( + providerId: $0, + configurationType: OpenVPN.Configuration.self, + onSelect: onSelectServer + ) + } + case .credentials: CredentialsView( - isInteractive: $draft.isInteractive, - credentials: $draft.credentials + isInteractive: draft.isInteractive, + credentials: draft.credentials ) } } diff --git a/Passepartout/Library/Sources/AppUI/Views/Modules/WireGuardView.swift b/Passepartout/Library/Sources/AppUI/Views/Modules/WireGuardView.swift index 3c71e18d..0d7cf516 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Modules/WireGuardView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Modules/WireGuardView.swift @@ -28,41 +28,16 @@ import PassepartoutKit import PassepartoutWireGuardGo import SwiftUI -struct WireGuardView: View { - private enum Subroute: Hashable { - case providerServer(id: ProviderID) - } +struct WireGuardView: View, ModuleDraftEditing { @ObservedObject - private var editor: ProfileEditor + var editor: ProfileEditor - @Binding - private var draft: WireGuardModule.Builder - -// @Binding -// private var providerId: ProviderID? -// -// @State -// private var providerServer: VPNServer? - - init(editor: ProfileEditor, module: WireGuardModule.Builder) { - self.editor = editor - _draft = editor.binding(forModule: module) -// _providerId = editor.binding(forProviderOf: module.id) - } + let module: WireGuardModule.Builder var body: some View { contentView -// .modifier(providerModifier) - .moduleView(editor: editor, draft: draft) -// .navigationDestination(for: Subroute.self) { -// switch $0 { -// case .providerServer(let id): -// VPNProviderServerView(providerId: id) { -// providerServer = $1 -// } -// } -// } + .moduleView(editor: editor, draft: draft.wrappedValue) } } @@ -70,7 +45,7 @@ struct WireGuardView: View { private extension WireGuardView { var configuration: WireGuard.Configuration.Builder { - draft.configurationBuilder ?? .default + draft.wrappedValue.configurationBuilder ?? .default } @ViewBuilder @@ -81,17 +56,6 @@ private extension WireGuardView { moduleSection(for: peersRows(for: peer), header: Strings.Modules.Wireguard.peer(index + 1)) } } - -// var providerModifier: some ViewModifier { -// ProviderPanelModifier( -// providerId: $providerId, -// selectedServer: $providerServer, -// configurationType: WireGuard.Configuration.self, -// serverRoute: { -// Subroute.providerServer(id: $0) -// } -// ) -// } } // MARK: - Subviews diff --git a/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift b/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift index c390c0e5..a6c15b37 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Profile/iOS/ProfileEditView+iOS.swift @@ -71,6 +71,7 @@ struct ProfileEditView: View, Routable { .navigationTitle(Strings.Global.profile) .navigationBarBackButtonHidden(true) .navigationDestination(for: NavigationRoute.self, destination: pushDestination) + .environment(\.navigationPath, $path) } } diff --git a/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileSplitView+macOS.swift b/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileSplitView+macOS.swift index 2e9eeca0..793f75d5 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileSplitView+macOS.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Profile/macOS/ProfileSplitView+macOS.swift @@ -36,6 +36,9 @@ struct ProfileSplitView: View, Routable { var flow: ProfileCoordinator.Flow? + @State + private var detailPath = NavigationPath() + @State private var selectedModuleId: UUID? = ModuleListView.generalModuleId @@ -52,7 +55,7 @@ struct ProfileSplitView: View, Routable { flow: flow ) } detail: { - NavigationStack { + NavigationStack(path: $detailPath) { switch selectedModuleId { case ModuleListView.generalModuleId: detailView(for: .general) @@ -62,6 +65,7 @@ struct ProfileSplitView: View, Routable { } } .toolbar(content: toolbarContent) + .environment(\.navigationPath, $detailPath) } } } diff --git a/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ProfileEditor+UI.swift b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ProfileEditor+UI.swift index a0bbbb62..cc0da83f 100644 --- a/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ProfileEditor+UI.swift +++ b/Passepartout/Library/Sources/AppUI/Views/ProfileEditor/ProfileEditor+UI.swift @@ -51,7 +51,7 @@ extension ProfileEditor { } } - func binding(forModule module: T) -> Binding where T: ModuleBuilder { + subscript(module: T) -> Binding where T: ModuleBuilder { Binding { [weak self] in guard let foundModule = self?.module(withId: module.id) else { fatalError("Module not found in editor: \(module.id)") diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderContentModifier.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderContentModifier.swift index 4d3244f6..2373ae24 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderContentModifier.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderContentModifier.swift @@ -28,8 +28,7 @@ import PassepartoutKit import SwiftUI import UtilsLibrary -@MainActor -struct VPNProviderContentModifier: ViewModifier where Configuration: ProviderConfigurationIdentifiable & Codable, ProviderRows: View { +struct VPNProviderContentModifier: ViewModifier where Configuration: ProviderConfigurationIdentifiable & Codable, Destination: Hashable, ProviderRows: View { var apis: [APIMapper] = API.shared @@ -41,6 +40,8 @@ struct VPNProviderContentModifier: ViewModifier whe let isRequired: Bool + let entityDestination: Destination + @ViewBuilder let providerRows: ProviderRows @@ -53,7 +54,7 @@ struct VPNProviderContentModifier: ViewModifier whe entityType: VPNEntity.self, isRequired: isRequired, providerRows: { - providerServerRow + providerEntityRow providerRows }, onSelectProvider: onSelectProvider @@ -62,18 +63,8 @@ struct VPNProviderContentModifier: ViewModifier whe } private extension VPNProviderContentModifier { - var providerServerRow: some View { - NavigationLink { - providerId.map { - VPNProviderServerView( - apis: apis, - providerId: $0, - configurationType: Configuration.self, - selectedEntity: selectedEntity, - onSelect: onSelectServer - ) - } - } label: { + var providerEntityRow: some View { + NavigationLink(value: entityDestination) { HStack { Text(Strings.Global.server) if let selectedEntity { @@ -92,25 +83,29 @@ private extension VPNProviderContentModifier { selectedEntity = nil } } - - func onSelectServer(server: VPNServer, preset: VPNPreset) { - selectedEntity = VPNEntity(server: server, preset: preset) - } } // MARK: - Preview #Preview { - List { - EmptyView() - .modifier(VPNProviderContentModifier( - providerId: .constant(.hideme), - selectedEntity: .constant(nil as VPNEntity?), - isRequired: false, - providerRows: { - Text("Other") - } - )) + NavigationStack { + List { + EmptyView() + .modifier(VPNProviderContentModifier( + apis: [API.bundled], + providerId: .constant(.hideme), + selectedEntity: .constant(nil as VPNEntity?), + isRequired: false, + entityDestination: "Destination", + providerRows: { + Text("Other") + } + )) + } + .navigationTitle("Preview") + .navigationDestination(for: String.self) { + Text($0) + } } .withMockEnvironment() } diff --git a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift index 0790a153..1ccbefc0 100644 --- a/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift +++ b/Passepartout/Library/Sources/AppUI/Views/Provider/VPNProviderServerView.swift @@ -33,10 +33,7 @@ struct VPNProviderServerView: View where Configuration: ProviderC @EnvironmentObject private var providerManager: ProviderManager - @Environment(\.dismiss) - private var dismiss - - let apis: [APIMapper] + var apis: [APIMapper] = API.shared let providerId: ProviderID @@ -112,14 +109,12 @@ extension VPNProviderServerView { return } onSelect(server, preset) - dismiss() } } // MARK: - Preview #Preview { - NavigationStack { VPNProviderServerView( apis: [API.bundled],