From 89d7af4df7c57001deca086bff86ee59dddb8d53 Mon Sep 17 00:00:00 2001 From: Davide Date: Mon, 18 Nov 2024 17:43:01 +0100 Subject: [PATCH] Rethink eligibility checks (#889) - Allow unrestricted save, but show PurchaseRequiredButton - Warn however about paid features (FIXME) - Redesign features in paywall - Strip already eligible features from paywall - List required features in restricted alert - Localize feature descriptions - Review propagation of paywall modifiers/reasons Extra: - Move more domain entities from UILibrary to CommonLibrary - Default on-demand policy to .any (free feature) - Fix modals not reappearing after closing with gesture - Extend UILibrary start-up assertions --- .../xcshareddata/swiftpm/Package.resolved | 2 +- Passepartout/Library/Package.swift | 2 +- .../Library/Sources/AppUIMain/AppUIMain.swift | 23 +++-- .../AppUIMain/Views/App/AppCoordinator.swift | 49 +++++++---- .../Views/App/InstalledProfileView.swift | 7 +- .../Views/App/ProfileContextMenu.swift | 33 ++++--- .../AppUIMain/Views/App/ProfileFlow.swift | 3 + .../AppUIMain/Views/App/ProfileRowView.swift | 7 +- .../Views/App/TunnelRestartButton.swift | 4 + .../Views/Migration/MigrateView.swift | 2 +- .../Views/Modules/OnDemandView.swift | 21 ++--- .../Views/Profile/ProfileCoordinator.swift | 79 +++++++++++------ .../Views/Profile/ProfileSaveButton.swift | 10 +-- .../Views/Profile/StorageSection.swift | 29 ++++--- .../Profile/iOS/ProfileEditView+iOS.swift | 25 ++++-- .../Profile/macOS/ModuleListView+macOS.swift | 18 +++- .../macOS/ProfileGeneralView+macOS.swift | 8 +- .../macOS/ProfileSplitView+macOS.swift | 20 +++-- .../Provider/ProviderContentModifier.swift | 38 +++----- .../Views/Provider/ProviderPicker.swift | 11 ++- .../Views/Profile/ActiveProfileView.swift | 6 ++ .../Views/Profile/ProfileListView.swift | 6 ++ .../Business/ProfileManager.swift | 2 +- .../Business/ProfileProcessor.swift | 1 + .../CommonLibrary/Domain/AppError.swift | 2 + .../Domain/EditableProfile.swift | 27 ++++-- .../Domain/Module+ModuleType.swift | 0 .../Domain/ModuleType+Known.swift | 0 .../Domain/ModuleType+New.swift | 4 +- .../Domain/ModuleType.swift | 0 .../Domain/TunnelInstallation.swift | 0 .../IAP/AppFeatureRequiring.swift | 87 +++++++++++++++++++ .../CommonLibrary/IAP/IAPManager+Verify.swift | 64 ++++++++++++++ .../CommonLibrary/IAP/IAPManager.swift | 9 +- .../CommonLibrary/IAP/PaywallReason.swift | 4 +- .../CommonUtils/Views/View+Extensions.swift | 22 +++++ .../Business/InteractiveManager.swift | 1 + .../UILibrary/Business/ProfileEditor.swift | 11 ++- .../UILibrary/L10n/AppError+L10n.swift | 6 +- .../UILibrary/L10n/AppFeature+L10n.swift | 8 +- .../L10n/ModuleBuilder+Description.swift | 1 + .../UILibrary/L10n/ModuleType+L10n.swift | 1 + .../UILibrary/L10n/SwiftGen+Strings.swift | 64 +++++++++----- .../Resources/en.lproj/Localizable.strings | 24 ++--- .../Theme/Platforms/Theme+macOS.swift | 2 +- .../UILibrary/Theme/Theme+ImageName.swift | 4 + .../Sources/UILibrary/Theme/Theme.swift | 2 + .../Library/Sources/UILibrary/UILibrary.swift | 28 ++++++ .../Modules/OpenVPNView+Credentials.swift | 32 +++---- .../Views/Paywall/FeatureListView.swift | 62 +++++++++++++ .../UILibrary/Views/Paywall/PaywallView.swift | 51 +++++++---- .../Views/Paywall/StoreKitProductView.swift | 25 ++++-- .../Views/UI/InteractiveCoordinator.swift | 4 + .../{Paywall => UI}/PaywallModifier.swift | 46 +++++++--- .../Views/UI/PurchaseButtonModifier.swift | 15 ++-- .../Views/UI/PurchaseRequiredButton.swift | 87 +++++++++++++++++++ .../Views/UI/TunnelToggleButton.swift | 12 ++- Passepartout/Shared/Shared+App.swift | 20 +---- .../Tunnel/PacketTunnelProvider.swift | 24 +---- 59 files changed, 844 insertions(+), 311 deletions(-) rename Passepartout/Library/Sources/{UILibrary => CommonLibrary}/Domain/EditableProfile.swift (83%) rename Passepartout/Library/Sources/{UILibrary => CommonLibrary}/Domain/Module+ModuleType.swift (100%) rename Passepartout/Library/Sources/{UILibrary => CommonLibrary}/Domain/ModuleType+Known.swift (100%) rename Passepartout/Library/Sources/{UILibrary => CommonLibrary}/Domain/ModuleType+New.swift (94%) rename Passepartout/Library/Sources/{UILibrary => CommonLibrary}/Domain/ModuleType.swift (100%) rename Passepartout/Library/Sources/{UILibrary => CommonLibrary}/Domain/TunnelInstallation.swift (100%) create mode 100644 Passepartout/Library/Sources/CommonLibrary/IAP/AppFeatureRequiring.swift create mode 100644 Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager+Verify.swift create mode 100644 Passepartout/Library/Sources/UILibrary/Views/Paywall/FeatureListView.swift rename Passepartout/Library/Sources/UILibrary/Views/{Paywall => UI}/PaywallModifier.swift (68%) create mode 100644 Passepartout/Library/Sources/UILibrary/Views/UI/PurchaseRequiredButton.swift diff --git a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index be4a2c01..18f6e455 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" : "c0a615bc7a85d68a9b00d3703d0dae6efab9bdd2" + "revision" : "db02de5247d0231ff06fb3c4d166645a434255be" } }, { diff --git a/Passepartout/Library/Package.swift b/Passepartout/Library/Package.swift index e27bb2a4..52441ebc 100644 --- a/Passepartout/Library/Package.swift +++ b/Passepartout/Library/Package.swift @@ -44,7 +44,7 @@ let package = Package( ], dependencies: [ // .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.11.0"), - .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "c0a615bc7a85d68a9b00d3703d0dae6efab9bdd2"), + .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "db02de5247d0231ff06fb3c4d166645a434255be"), // .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/AppUIMain/AppUIMain.swift b/Passepartout/Library/Sources/AppUIMain/AppUIMain.swift index ab87d5c6..d317a5b3 100644 --- a/Passepartout/Library/Sources/AppUIMain/AppUIMain.swift +++ b/Passepartout/Library/Sources/AppUIMain/AppUIMain.swift @@ -23,6 +23,7 @@ // along with Passepartout. If not, see . // +import CommonLibrary import Foundation import PassepartoutKit @_exported import UILibrary @@ -42,19 +43,23 @@ private extension AppUIMain { .openVPN ] ModuleType.allCases.forEach { moduleType in - let builder = moduleType.newModule(with: registry) - guard builder is any ModuleViewProviding else { - fatalError("\(moduleType): is not ModuleViewProviding") - } - if providerModuleTypes.contains(moduleType) { - do { - let module = try builder.tryBuild() + do { + let builder = moduleType.newModule(with: registry) + let module = try builder.tryBuild() + + // ModuleViewProviding + guard builder is any ModuleViewProviding else { + fatalError("\(moduleType): is not ModuleViewProviding") + } + + // ProviderEntityViewProviding + if providerModuleTypes.contains(moduleType) { guard module is any ProviderEntityViewProviding else { fatalError("\(moduleType): is not ProviderEntityViewProviding") } - } catch { - fatalError("\(moduleType): empty module is not buildable") } + } catch { + fatalError("\(moduleType): empty module is not buildable: \(error)") } } } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift index 799cd6ec..8e00bd99 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift @@ -54,6 +54,9 @@ public struct AppCoordinator: View, AppCoordinatorConforming { @State private var migrationPath = NavigationPath() + @State + private var paywallReason: PaywallReason? + @StateObject private var errorHandler: ErrorHandler = .default() @@ -72,6 +75,7 @@ public struct AppCoordinator: View, AppCoordinatorConforming { contentView .toolbar(content: toolbarContent) } + .modifier(PaywallModifier(reason: $paywallReason)) .themeModal( item: $modalRoute, size: modalRoute?.size ?? .large, @@ -87,6 +91,8 @@ public struct AppCoordinator: View, AppCoordinatorConforming { extension AppCoordinator { enum ModalRoute: Identifiable { + case about + case editProfile case editProviderEntity(Profile, Module, SerializedProvider) @@ -95,15 +101,13 @@ extension AppCoordinator { case settings - case about - var id: Int { switch self { - case .editProfile: return 1 - case .editProviderEntity: return 2 - case .migrateProfiles: return 3 - case .settings: return 4 - case .about: return 5 + case .about: return 1 + case .editProfile: return 2 + case .editProviderEntity: return 3 + case .migrateProfiles: return 4 + case .settings: return 5 } } @@ -171,6 +175,11 @@ extension AppCoordinator { }, onMigrateProfiles: { modalRoute = .migrateProfiles + }, + onPurchaseRequired: { features in + setLater(.purchase(features)) { + paywallReason = $0 + } } ) ) @@ -197,6 +206,12 @@ extension AppCoordinator { @ViewBuilder func modalDestination(for item: ModalRoute?) -> some View { switch item { + case .about: + AboutRouterView( + profileManager: profileManager, + tunnel: tunnel + ) + case .editProfile: ProfileCoordinator( profileManager: profileManager, @@ -205,9 +220,7 @@ extension AppCoordinator { moduleViewFactory: DefaultModuleViewFactory(registry: registry), modally: true, path: $profilePath, - onDismiss: { - present(nil) - } + onDismiss: onDismiss ) case .editProviderEntity(let profile, let module, let provider): @@ -230,12 +243,6 @@ extension AppCoordinator { case .settings: SettingsView(profileManager: profileManager) - case .about: - AboutRouterView( - profileManager: profileManager, - tunnel: tunnel - ) - default: EmptyView() } @@ -256,11 +263,15 @@ extension AppCoordinator { present(.editProfile) } + func onDismiss() { + present(nil) + } + func present(_ route: ModalRoute?) { + // XXX: this is a workaround for #791 on iOS 16 - Task { - try await Task.sleep(for: .milliseconds(50)) - modalRoute = route + setLater(route) { + modalRoute = $0 } } } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/InstalledProfileView.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/InstalledProfileView.swift index e2894acf..3ba3bcfb 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/InstalledProfileView.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/InstalledProfileView.swift @@ -201,7 +201,12 @@ private struct ToggleButton: View { nextProfileId: $nextProfileId, interactiveManager: interactiveManager, errorHandler: errorHandler, - onProviderEntityRequired: flow?.onEditProviderEntity, + onProviderEntityRequired: { + flow?.onEditProviderEntity($0) + }, + onPurchaseRequired: { + flow?.onPurchaseRequired($0) + }, label: { _ in ThemeImage(.tunnelToggle) .scaleEffect(1.5, anchor: .trailing) diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileContextMenu.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileContextMenu.swift index 34d08e90..2d7929ac 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileContextMenu.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileContextMenu.swift @@ -69,13 +69,20 @@ private extension ProfileContextMenu { profile: profile, nextProfileId: .constant(nil), interactiveManager: interactiveManager, - errorHandler: errorHandler - ) { - ThemeImageLabel( - $0 ? Strings.Global.enable : Strings.Global.disable, - $0 ? .tunnelEnable : .tunnelDisable - ) - } + errorHandler: errorHandler, + onProviderEntityRequired: { + flow?.onEditProviderEntity($0) + }, + onPurchaseRequired: { + flow?.onPurchaseRequired($0) + }, + label: { + ThemeImageLabel( + $0 ? Strings.Global.enable : Strings.Global.disable, + $0 ? .tunnelEnable : .tunnelDisable + ) + } + ) } var providerConnectToButton: some View { @@ -92,10 +99,14 @@ private extension ProfileContextMenu { TunnelRestartButton( tunnel: tunnel, profile: profile, - errorHandler: errorHandler - ) { - ThemeImageLabel(Strings.Global.restart, .tunnelRestart) - } + errorHandler: errorHandler, + onPurchaseRequired: { + flow?.onPurchaseRequired($0) + }, + label: { + ThemeImageLabel(Strings.Global.restart, .tunnelRestart) + } + ) } var profileEditButton: some View { diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileFlow.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileFlow.swift index 4739327d..ad5370d8 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileFlow.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileFlow.swift @@ -23,6 +23,7 @@ // along with Passepartout. If not, see . // +import CommonLibrary import Foundation import PassepartoutKit @@ -32,4 +33,6 @@ struct ProfileFlow { let onEditProviderEntity: (Profile) -> Void let onMigrateProfiles: () -> Void + + let onPurchaseRequired: (Set) -> Void } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift index 7c9f028f..56667b94 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift @@ -122,7 +122,12 @@ private extension ProfileRowView { nextProfileId: $nextProfileId, interactiveManager: interactiveManager, errorHandler: errorHandler, - onProviderEntityRequired: flow?.onEditProviderEntity, + onProviderEntityRequired: { + flow?.onEditProviderEntity($0) + }, + onPurchaseRequired: { + flow?.onPurchaseRequired($0) + }, label: { _ in ProfileCardView( style: style, diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/TunnelRestartButton.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/TunnelRestartButton.swift index 2b27f3a5..a18739be 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/TunnelRestartButton.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/TunnelRestartButton.swift @@ -37,6 +37,8 @@ struct TunnelRestartButton