From a2d4ed370e5975ef4d0ee5681dac0f4bbe17c2d1 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Fri, 20 May 2022 08:27:31 +0200 Subject: [PATCH] Revisit sidebar with per-profile VPN toggles --- Passepartout.xcodeproj/project.pbxproj | 4 + Passepartout/App/L10n/Core+L10n.swift | 19 ++-- .../App/Views/OrganizerView+Profiles.swift | 3 +- Passepartout/App/Views/ProfileRow.swift | 86 +++----------- Passepartout/App/Views/ProfileView+VPN.swift | 107 +++--------------- Passepartout/App/Views/ProfileView.swift | 7 +- Passepartout/App/Views/VPNStatusText.swift | 50 ++++++++ Passepartout/App/Views/VPNToggle.swift | 87 +++++++++++--- .../Managers/VPNManager+Actions.swift | 14 ++- 9 files changed, 176 insertions(+), 201 deletions(-) create mode 100644 Passepartout/App/Views/VPNStatusText.swift diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index 1837b6ac..09af9aeb 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -53,6 +53,7 @@ 0E6059CC27FCC5DE003F4063 /* Providers.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0E6059C927FCC5DE003F4063 /* Providers.xcassets */; }; 0E6059CD27FCC5DE003F4063 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0E6059CA27FCC5DE003F4063 /* Assets.xcassets */; }; 0E6059CF27FCC618003F4063 /* SwiftGen+Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E6059CE27FCC618003F4063 /* SwiftGen+Assets.swift */; }; + 0E70589B28377DC40075D1D2 /* VPNStatusText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E70589A28377DC30075D1D2 /* VPNStatusText.swift */; }; 0E71ACDD27C0295C00F85C4B /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E71ACDC27C0295B00F85C4B /* View+Extensions.swift */; }; 0E71ACE327C0F2E400F85C4B /* Providers+L10n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E71ACE227C0F2E300F85C4B /* Providers+L10n.swift */; }; 0E71ACE927C1055300F85C4B /* NetworkSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E71ACE827C1055200F85C4B /* NetworkSettingsView.swift */; }; @@ -239,6 +240,7 @@ 0E6059C927FCC5DE003F4063 /* Providers.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Providers.xcassets; sourceTree = ""; }; 0E6059CA27FCC5DE003F4063 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 0E6059CE27FCC618003F4063 /* SwiftGen+Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "SwiftGen+Assets.swift"; sourceTree = ""; }; + 0E70589A28377DC30075D1D2 /* VPNStatusText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNStatusText.swift; sourceTree = ""; }; 0E71ACDC27C0295B00F85C4B /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; 0E71ACE227C0F2E300F85C4B /* Providers+L10n.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Providers+L10n.swift"; sourceTree = ""; }; 0E71ACE827C1055200F85C4B /* NetworkSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSettingsView.swift; sourceTree = ""; }; @@ -465,6 +467,7 @@ 0ED89C1B27DE3ABC008B36D6 /* ShortcutsView+Add.swift */, 0E71ACFA27C12E5300F85C4B /* VersionView.swift */, 0E71ACDC27C0295B00F85C4B /* View+Extensions.swift */, + 0E70589A28377DC30075D1D2 /* VPNStatusText.swift */, 0E7577DE2817E22C00081CBE /* VPNToggle.swift */, 0E065F102813269500062CAF /* WelcomeView.swift */, ); @@ -933,6 +936,7 @@ 0EF0FAF927DD212C007EB181 /* IntentActivity.swift in Sources */, 0EBC075B27EC4FFF00208AD9 /* ReportIssueView.swift in Sources */, 0ED89C1727DE0E05008B36D6 /* IntentEditView.swift in Sources */, + 0E70589B28377DC40075D1D2 /* VPNStatusText.swift in Sources */, 0E71ACE927C1055300F85C4B /* NetworkSettingsView.swift in Sources */, 0EB34BCA27C6A70200B126DA /* OnDemandView.swift in Sources */, 0E0BD27327B2EA2C00583AC5 /* MainView.swift in Sources */, diff --git a/Passepartout/App/L10n/Core+L10n.swift b/Passepartout/App/L10n/Core+L10n.swift index 15b4db10..b6fe30b7 100644 --- a/Passepartout/App/L10n/Core+L10n.swift +++ b/Passepartout/App/L10n/Core+L10n.swift @@ -58,18 +58,17 @@ extension PassepartoutError { } extension VPNManager.ObservableState { - func localizedStatusDescription(withErrors: Bool, dataCountIfAvailable: Bool) -> String { - guard isEnabled else { - - // report application errors even if VPN is disabled - if withErrors { - if let errorDescription = (lastError as? PassepartoutError)?.localizedAppDescription, !errorDescription.isEmpty { - return errorDescription - } - } - + func localizedStatusDescription(isActiveProfile: Bool, withErrors: Bool, dataCountIfAvailable: Bool) -> String { + + // FIXME: l10n, sure about this wording? + guard isActiveProfile else { +// return L10n.Tunnelkit.Vpn.unused return L10n.Tunnelkit.Vpn.disabled } + guard isEnabled else { +// return L10n.Tunnelkit.Vpn.disabled + return L10n.Organizer.Sections.active + } if withErrors { if let errorDescription = lastError?.localizedVPNDescription, !errorDescription.isEmpty { return errorDescription diff --git a/Passepartout/App/Views/OrganizerView+Profiles.swift b/Passepartout/App/Views/OrganizerView+Profiles.swift index c439880e..30a2e891 100644 --- a/Passepartout/App/Views/OrganizerView+Profiles.swift +++ b/Passepartout/App/Views/OrganizerView+Profiles.swift @@ -94,7 +94,7 @@ extension OrganizerView { private func profileLabel(forHeader header: Profile.Header) -> some View { ProfileRow( header: header, - isActive: profileManager.isActiveProfile(header.id) + isActiveProfile: profileManager.isActiveProfile(header.id) ) } @@ -117,6 +117,7 @@ extension OrganizerView { private var sortedHeaders: [Profile.Header] { profileManager.headers +// .sorted() .sorted { if profileManager.isActiveProfile($0.id) { return true diff --git a/Passepartout/App/Views/ProfileRow.swift b/Passepartout/App/Views/ProfileRow.swift index 3adf6360..7625e7ea 100644 --- a/Passepartout/App/Views/ProfileRow.swift +++ b/Passepartout/App/Views/ProfileRow.swift @@ -29,83 +29,23 @@ import PassepartoutCore struct ProfileRow: View { let header: Profile.Header - let isActive: Bool + let isActiveProfile: Bool var body: some View { debugChanges() - return VStack(alignment: .leading, spacing: 5) { - nameView - .font(.headline) - .themeLongTextStyle() + return HStack { + VStack(alignment: .leading, spacing: 5) { + Text(header.name) + .font(.headline) + .themeLongTextStyle() - VPNStateView(isActive: isActive) - .font(.subheadline) - .themeSecondaryTextStyle() + VPNStatusText(isActiveProfile: isActiveProfile) + .font(.subheadline) + .themeSecondaryTextStyle() + } + Spacer() + VPNToggle(profileId: header.id, rateLimit: Constants.RateLimit.vpnToggle) + .labelsHidden() }.padding([.top, .bottom], 10) } - - private var nameView: some View { - Text(header.name) - } - - struct VPNStateView: View { - @ObservedObject private var currentVPNState: VPNManager.ObservableState - - private let isActive: Bool - -// @State private var connectedOpacity = 1.0 - - init(isActive: Bool) { - currentVPNState = .shared - self.isActive = isActive - } - - var body: some View { - HStack { - profileImage - if isActive { - Text(statusDescription) - Spacer() - currentVPNState.dataCount.map { - Text($0.localizedDescription) - } - } else { - Text(L10n.Tunnelkit.Vpn.unused) - } - } - } - - private var statusDescription: String { - if currentVPNState.vpnStatus != .disconnected { - return currentVPNState.localizedStatusDescription( - withErrors: false, - dataCountIfAvailable: false - ) - } else { - return L10n.Organizer.Sections.active - } - } - - @ViewBuilder - private var profileImage: some View { - if isConnected { - Image(systemName: themeProfileConnectedImage) -// .opacity(connectedOpacity) -// .onAppear { -// connectedOpacity = 1.0 -// withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) { -// connectedOpacity = 0.05 -// } -// } - } else if isActive { - Image(systemName: themeProfileActiveImage) - } else { - Image(systemName: themeProfileInactiveImage) - } - } - - private var isConnected: Bool { - isActive && currentVPNState.vpnStatus == .connected - } - } } diff --git a/Passepartout/App/Views/ProfileView+VPN.swift b/Passepartout/App/Views/ProfileView+VPN.swift index 7631f206..b9fc170b 100644 --- a/Passepartout/App/Views/ProfileView+VPN.swift +++ b/Passepartout/App/Views/ProfileView+VPN.swift @@ -28,114 +28,41 @@ import PassepartoutCore extension ProfileView { struct VPNSection: View { - @ObservedObject private var appManager: AppManager - @ObservedObject private var profileManager: ProfileManager - @ObservedObject private var providerManager: ProviderManager - - @ObservedObject private var vpnManager: VPNManager - - @ObservedObject private var currentVPNState: VPNManager.ObservableState - - @ObservedObject private var productManager: ProductManager + private let profileId: UUID - @ObservedObject private var currentProfile: ObservableProfile - - private let isLoading: Bool - private var isActiveProfile: Bool { - profileManager.isCurrentProfileActive() + profileManager.isActiveProfile(profileId) } - - private var isEligibleForSiri: Bool { - productManager.isEligible(forFeature: .siriShortcuts) - } - - init(currentProfile: ObservableProfile, isLoading: Bool) { - appManager = .shared + + init(profileId: UUID) { profileManager = .shared - providerManager = .shared - vpnManager = .shared - currentVPNState = .shared - productManager = .shared - self.currentProfile = currentProfile - self.isLoading = isLoading + self.profileId = profileId } var body: some View { - if !isLoading { - if isActiveProfile { - activeView - } else { - inactiveSubview - } - } else { - loadingView - } - } - - private var headerView: some View { - Text(Unlocalized.VPN.vpn) - } - - private var activeView: some View { Section { - VPNToggle(rateLimit: Constants.RateLimit.vpnToggle) { - - // eligibility: donate intents if eligible for Siri - if isEligibleForSiri { - pp_log.debug("Donating connection intents...") - - IntentDispatcher.donateEnableVPN() - IntentDispatcher.donateDisableVPN() - IntentDispatcher.donateConnection( - with: currentProfile.value, - providerManager: providerManager - ) - } - } - - Text(L10n.Profile.Items.ConnectionStatus.caption) - .withTrailingText(currentVPNState.localizedStatusDescription( - withErrors: true, - dataCountIfAvailable: true - )) + toggleView + statusView } header: { - headerView + Text(Unlocalized.VPN.vpn) } footer: { Text(L10n.Profile.Sections.Vpn.footer) .xxxThemeTruncation() } } - - private var inactiveSubview: some View { - Section { - Button(L10n.Profile.Items.UseProfile.caption) { - Task { - - // do this first to not override subsequent animation - // active profile may flicker due to unnecessary VPN updates - await vpnManager.disable() - - withAnimation { - profileManager.activateCurrentProfile() - - // IMPORTANT: save immediately to keep in sync with VPN status - appManager.activeProfileId = profileManager.activeProfileId - } - } - } - } header: { - headerView - } + + private var toggleView: some View { + VPNToggle(profileId: profileId, rateLimit: Constants.RateLimit.vpnToggle) } - private var loadingView: some View { - Section { - ProgressView() - } header: { - headerView + private var statusView: some View { + HStack { + Text(L10n.Profile.Items.ConnectionStatus.caption) + Spacer() + VPNStatusText(isActiveProfile: isActiveProfile) + .themeSecondaryTextStyle() } } } diff --git a/Passepartout/App/Views/ProfileView.swift b/Passepartout/App/Views/ProfileView.swift index 431696a6..e538d1d5 100644 --- a/Passepartout/App/Views/ProfileView.swift +++ b/Passepartout/App/Views/ProfileView.swift @@ -88,11 +88,8 @@ struct ProfileView: View { private var mainView: some View { List { - VPNSection( - currentProfile: currentProfile, - isLoading: isLoading - ) if !isLoading { + VPNSection(profileId: currentProfile.value.id) ProviderSection(currentProfile: currentProfile) ConfigurationSection( currentProfile: currentProfile, @@ -100,6 +97,8 @@ struct ProfileView: View { ) ExtraSection(currentProfile: currentProfile) DiagnosticsSection(currentProfile: currentProfile) + } else { + ProgressView() } }.themeAnimation(on: isLoading) } diff --git a/Passepartout/App/Views/VPNStatusText.swift b/Passepartout/App/Views/VPNStatusText.swift new file mode 100644 index 00000000..25e9edbf --- /dev/null +++ b/Passepartout/App/Views/VPNStatusText.swift @@ -0,0 +1,50 @@ +// +// VPNStatusText.swift +// Passepartout +// +// Created by Davide De Rosa on 5/20/22. +// Copyright (c) 2022 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 +import PassepartoutCore + +struct VPNStatusText: View { + @ObservedObject private var currentVPNState: VPNManager.ObservableState + + let isActiveProfile: Bool + + init(isActiveProfile: Bool) { + currentVPNState = .shared + self.isActiveProfile = isActiveProfile + } + + var body: some View { + Text(statusText) + } + + private var statusText: String { + return currentVPNState.localizedStatusDescription( + isActiveProfile: isActiveProfile, + withErrors: true, + dataCountIfAvailable: true + ) + } +} diff --git a/Passepartout/App/Views/VPNToggle.swift b/Passepartout/App/Views/VPNToggle.swift index 89f141bc..29579a62 100644 --- a/Passepartout/App/Views/VPNToggle.swift +++ b/Passepartout/App/Views/VPNToggle.swift @@ -27,29 +27,50 @@ import SwiftUI import PassepartoutCore struct VPNToggle: View { + @ObservedObject private var appManager: AppManager + + @ObservedObject private var profileManager: ProfileManager + @ObservedObject private var vpnManager: VPNManager @ObservedObject private var currentVPNState: VPNManager.ObservableState - + + @ObservedObject private var productManager: ProductManager + + private let profileId: UUID + private let rateLimit: Int - private let onToggle: (() -> Void)? - private var isEnabled: Binding { .init { - currentVPNState.isEnabled - } set: { _ in - _ = toggleVPN() + isActiveProfile && currentVPNState.isEnabled + } set: { newValue in + guard newValue else { + disableVPN() + return + } + enableVPN() } } + + private var isActiveProfile: Bool { + profileManager.isActiveProfile(profileId) + } + private var isEligibleForSiri: Bool { + productManager.isEligible(forFeature: .siriShortcuts) + } + @State private var canToggle = true - init(rateLimit: Int, onToggle: (() -> Void)? = nil) { + init(profileId: UUID, rateLimit: Int) { + appManager = .shared + profileManager = .shared vpnManager = .shared currentVPNState = .shared + productManager = .shared + self.profileId = profileId self.rateLimit = rateLimit - self.onToggle = onToggle } var body: some View { @@ -57,19 +78,49 @@ struct VPNToggle: View { .disabled(!canToggle) .themeAnimation(on: currentVPNState.isEnabled) } + + private func enableVPN() { + Task { + canToggle = false + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(rateLimit)) { + canToggle = true + } + do { + let profile = try await vpnManager.connect(with: profileId) - private func toggleVPN() -> Bool { - guard vpnManager.toggle() else { - return false + // IMPORTANT: save immediately to keep in sync with VPN status + appManager.activeProfileId = profileId + + donateIntents(withProfile: profile) + } catch { + pp_log.warning("Unable to connect to profile \(profileId): \(error)") + canToggle = true + } } - - // rate limit toggle actions - canToggle = false - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(rateLimit)) { + } + + private func disableVPN() { + Task { + canToggle = false + await vpnManager.disable() canToggle = true } - - onToggle?() - return true + } + + private func donateIntents(withProfile profile: Profile) { + + // eligibility: donate intents if eligible for Siri + guard isEligibleForSiri else { + return + } + + pp_log.debug("Donating connection intents...") + + IntentDispatcher.donateEnableVPN() + IntentDispatcher.donateDisableVPN() + IntentDispatcher.donateConnection( + with: profile, + providerManager: .shared + ) } } diff --git a/PassepartoutCore/Sources/PassepartoutCore/Managers/VPNManager+Actions.swift b/PassepartoutCore/Sources/PassepartoutCore/Managers/VPNManager+Actions.swift index 257c2074..7b7d44ce 100644 --- a/PassepartoutCore/Sources/PassepartoutCore/Managers/VPNManager+Actions.swift +++ b/PassepartoutCore/Sources/PassepartoutCore/Managers/VPNManager+Actions.swift @@ -42,14 +42,15 @@ extension VPNManager { try await connect(with: profileId) } - public func connect(with profileId: UUID) async throws { + @discardableResult + public func connect(with profileId: UUID) async throws -> Profile { let result = try profileManager.liveProfileEx(withId: profileId) let profile = result.profile guard !profileManager.isActiveProfile(profileId) || currentState.vpnStatus != .connected else { pp_log.warning("Profile \(profile.logDescription) is already active and connected") - return + return profile } if !result.isReady { try await profileManager.makeProfileReady(profile) @@ -60,9 +61,11 @@ extension VPNManager { profileManager.activateProfile(profile) await reconnect(cfg) + return profile } - public func connect(with profileId: UUID, toServer newServerId: String) async throws { + @discardableResult + public func connect(with profileId: UUID, toServer newServerId: String) async throws -> Profile { let result = try profileManager.liveProfileEx(withId: profileId) var profile = result.profile guard profile.isProvider else { @@ -83,7 +86,7 @@ extension VPNManager { oldServerId != newServer.id else { pp_log.info("Profile \(profile.logDescription) is already active and connected to: \(newServer.logDescription)") - return + return profile } pp_log.info("Connecting to: \(profile.logDescription) @ \(newServer.logDescription)") @@ -93,9 +96,10 @@ extension VPNManager { profileManager.activateProfile(profile) guard !profileManager.isCurrentProfile(profileId) else { pp_log.debug("Active profile is current, will reconnect via observation") - return + return profile } await reconnect(cfg) + return profile } public func modifyActiveProfile(_ block: (inout Profile) -> Void) async throws {