diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift index 56667b94..3e63f8da 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileRowView.swift @@ -86,10 +86,18 @@ private struct MarkerView: View { @ObservedObject var tunnel: ExtendedTunnel + let requiredFeatures: Set? + var body: some View { - ThemeImage(headerId == nextProfileId ? .pending : statusImage) - .opaque(headerId == nextProfileId || headerId == tunnel.currentProfile?.id) - .frame(width: 24.0) + ZStack { + ThemeImage(headerId == nextProfileId ? .pending : statusImage) + .opaque(requiredFeatures == nil && (headerId == nextProfileId || headerId == tunnel.currentProfile?.id)) + + if let requiredFeatures { + PurchaseRequiredButton(features: requiredFeatures, paywallReason: .constant(nil)) + } + } + .frame(width: 24.0) } var statusImage: Theme.ImageName { @@ -111,7 +119,8 @@ private extension ProfileRowView { MarkerView( headerId: header.id, nextProfileId: nextProfileId, - tunnel: tunnel + tunnel: tunnel, + requiredFeatures: requiredFeatures ) } @@ -149,6 +158,10 @@ private extension ProfileRowView { return [] } + var requiredFeatures: Set? { + profileManager.requiredFeatures(forProfileWithId: header.id) + } + var isShared: Bool { profileManager.isRemotelyShared(profileWithId: header.id) } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift b/Passepartout/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift index a6254be5..6eee0192 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Profile/ProfileCoordinator.swift @@ -151,7 +151,7 @@ private extension ProfileCoordinator { func onCommitEditingStandard() async throws { let savedProfile = try await profileEditor.save(to: profileManager) do { - try iapManager.verify(savedProfile.activeModules) + try iapManager.verify(savedProfile) } catch AppError.ineligibleProfile(let requiredFeatures) { self.requiredFeatures = requiredFeatures requiresPurchase = true diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift b/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift index fd085671..5433d5de 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift @@ -60,6 +60,11 @@ public final class ProfileManager: ObservableObject { private var allProfiles: [Profile.ID: Profile] { didSet { reloadFilteredProfiles(with: searchSubject.value) + if let processor { + requiredFeatures = allProfiles.reduce(into: [:]) { + $0[$1.key] = processor.verify($1.value) + } + } } } @@ -67,6 +72,9 @@ public final class ProfileManager: ObservableObject { private var filteredProfiles: [Profile] + @Published + private var requiredFeatures: [Profile.ID: Set] + @Published public private(set) var isRemoteImportingEnabled: Bool @@ -101,6 +109,7 @@ public final class ProfileManager: ObservableObject { } allRemoteProfiles = [:] filteredProfiles = [] + requiredFeatures = [:] isRemoteImportingEnabled = false waitingObservers = [] @@ -127,6 +136,7 @@ public final class ProfileManager: ObservableObject { allProfiles = [:] allRemoteProfiles = [:] filteredProfiles = [] + requiredFeatures = [:] isRemoteImportingEnabled = false if remoteRepositoryBlock != nil { waitingObservers = [.local, .remote] @@ -168,6 +178,10 @@ extension ProfileManager { } } + public func requiredFeatures(forProfileWithId profileId: Profile.ID) -> Set? { + requiredFeatures[profileId] + } + public func search(byName name: String) { searchSubject.send(name) } diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/ProfileProcessor.swift b/Passepartout/Library/Sources/CommonLibrary/Business/ProfileProcessor.swift index f4d41dc4..acb435cb 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Business/ProfileProcessor.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Business/ProfileProcessor.swift @@ -38,18 +38,22 @@ public final class ProfileProcessor: ObservableObject, Sendable { private nonisolated let _willConnect: (IAPManager, Profile) throws -> Profile + private nonisolated let _verify: (IAPManager, Profile) -> Set? + public init( iapManager: IAPManager, title: @escaping (Profile) -> String, isIncluded: @escaping (IAPManager, Profile) -> Bool, willSave: @escaping (IAPManager, Profile.Builder) throws -> Profile.Builder, - willConnect: @escaping (IAPManager, Profile) throws -> Profile + willConnect: @escaping (IAPManager, Profile) throws -> Profile, + verify: @escaping (IAPManager, Profile) -> Set? ) { self.iapManager = iapManager self.title = title _isIncluded = isIncluded _willSave = willSave _willConnect = willConnect + _verify = verify } public func isIncluded(_ profile: Profile) -> Bool { @@ -63,4 +67,8 @@ public final class ProfileProcessor: ObservableObject, Sendable { public func willConnect(_ profile: Profile) throws -> Profile { try _willConnect(iapManager, profile) } + + public func verify(_ profile: Profile) -> Set? { + _verify(iapManager, profile) + } } diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeatureRequiring.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeatureRequiring.swift index 462217ea..c53ec26d 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeatureRequiring.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeatureRequiring.swift @@ -30,6 +30,32 @@ public protocol AppFeatureRequiring { var features: Set { get } } +// MARK: - Profile + +extension Profile: AppFeatureRequiring { + public var features: Set { + let builders = activeModules.compactMap { module in + guard let builder = module.moduleBuilder() else { + fatalError("Cannot produce ModuleBuilder from Module: \(module)") + } + return builder + } + return builders.features + } +} + +extension Array: AppFeatureRequiring where Element == any ModuleBuilder { + public var features: Set { + let requirements = compactMap { builder in + guard let requiring = builder as? AppFeatureRequiring else { + fatalError("ModuleBuilder does not implement AppFeatureRequiring: \(builder)") + } + return requiring + } + return Set(requirements.flatMap(\.features)) + } +} + // MARK: - Modules extension DNSModule.Builder: AppFeatureRequiring { diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager+Verify.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager+Verify.swift index 83fd2d62..afdbede0 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager+Verify.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager+Verify.swift @@ -27,36 +27,23 @@ import Foundation import PassepartoutKit extension IAPManager { - public func verify(_ modules: [Module]) throws { - let builders = modules.map { - guard let builder = $0.moduleBuilder() else { - fatalError("Cannot produce ModuleBuilder from Module for IAPManager.verify(): \($0)") - } - return builder - } - try verify(builders) + public func verify(_ profile: Profile) throws { + try verify(profile.features) } public func verify(_ modulesBuilders: [any ModuleBuilder]) throws { + try verify(modulesBuilders.features) + } + + public func verify(_ features: Set) throws { #if os(tvOS) guard isEligible(for: .appleTV) else { throw AppError.ineligibleProfile([.appleTV]) } #endif - let requirements: [(UUID, Set)] = modulesBuilders - .compactMap { builder in - guard let requiring = builder as? AppFeatureRequiring else { - return nil - } - return (builder.id, requiring.features) - } - - let requiredFeatures = Set(requirements - .flatMap(\.1) - .filter { - !isEligible(for: $0) - }) - + let requiredFeatures = features.filter { + !isEligible(for: $0) + } guard requiredFeatures.isEmpty else { throw AppError.ineligibleProfile(requiredFeatures) } diff --git a/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift b/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift index d21dd644..3ce98774 100644 --- a/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift +++ b/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift @@ -57,6 +57,9 @@ extension AppContext { }, willConnect: { _, profile in try profile.withProviderModules() + }, + verify: { _, _ in + nil } ) let profileManager = { diff --git a/Passepartout/Shared/Shared+App.swift b/Passepartout/Shared/Shared+App.swift index e21d0e76..19747ac2 100644 --- a/Passepartout/Shared/Shared+App.swift +++ b/Passepartout/Shared/Shared+App.swift @@ -48,7 +48,7 @@ extension IAPManager { builder }, willConnect: { iap, profile in - try iap.verify(profile.activeModules) + try iap.verify(profile) // validate provider modules do { @@ -58,6 +58,16 @@ extension IAPManager { pp_log(.app, .error, "Unable to inject provider modules: \(error)") throw error } + }, + verify: { iap, profile in + do { + try iap.verify(profile) + return nil + } catch AppError.ineligibleProfile(let requiredFeatures) { + return requiredFeatures + } catch { + return nil + } } ) } diff --git a/Passepartout/Tunnel/PacketTunnelProvider.swift b/Passepartout/Tunnel/PacketTunnelProvider.swift index 7b7c1b75..e61258e0 100644 --- a/Passepartout/Tunnel/PacketTunnelProvider.swift +++ b/Passepartout/Tunnel/PacketTunnelProvider.swift @@ -87,7 +87,7 @@ private extension PacketTunnelProvider { func checkEligibility(of profile: Profile, environment: TunnelEnvironment) async throws { await iapManager.reloadReceipt() do { - try iapManager.verify(profile.activeModules) + try iapManager.verify(profile) } catch { let error = PassepartoutError(.App.ineligibleProfile) environment.setEnvironmentValue(error.code, forKey: TunnelEnvironmentKeys.lastErrorCode)