Show upgrade icon in profiles list (#891)

Visually clarify that a profile requires a purchase to be enabled.

- Implement AppFeatureRequiring in Profile
- Refactor IAPManager.verify() accordingly
- Pre-compute required features in ProfileManager via ProfileProcessor
This commit is contained in:
Davide 2024-11-19 08:55:41 +01:00 committed by GitHub
parent 1536551922
commit d78456bb90
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 91 additions and 30 deletions

View File

@ -86,10 +86,18 @@ private struct MarkerView: View {
@ObservedObject @ObservedObject
var tunnel: ExtendedTunnel var tunnel: ExtendedTunnel
let requiredFeatures: Set<AppFeature>?
var body: some View { var body: some View {
ThemeImage(headerId == nextProfileId ? .pending : statusImage) ZStack {
.opaque(headerId == nextProfileId || headerId == tunnel.currentProfile?.id) ThemeImage(headerId == nextProfileId ? .pending : statusImage)
.frame(width: 24.0) .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 { var statusImage: Theme.ImageName {
@ -111,7 +119,8 @@ private extension ProfileRowView {
MarkerView( MarkerView(
headerId: header.id, headerId: header.id,
nextProfileId: nextProfileId, nextProfileId: nextProfileId,
tunnel: tunnel tunnel: tunnel,
requiredFeatures: requiredFeatures
) )
} }
@ -149,6 +158,10 @@ private extension ProfileRowView {
return [] return []
} }
var requiredFeatures: Set<AppFeature>? {
profileManager.requiredFeatures(forProfileWithId: header.id)
}
var isShared: Bool { var isShared: Bool {
profileManager.isRemotelyShared(profileWithId: header.id) profileManager.isRemotelyShared(profileWithId: header.id)
} }

View File

@ -151,7 +151,7 @@ private extension ProfileCoordinator {
func onCommitEditingStandard() async throws { func onCommitEditingStandard() async throws {
let savedProfile = try await profileEditor.save(to: profileManager) let savedProfile = try await profileEditor.save(to: profileManager)
do { do {
try iapManager.verify(savedProfile.activeModules) try iapManager.verify(savedProfile)
} catch AppError.ineligibleProfile(let requiredFeatures) { } catch AppError.ineligibleProfile(let requiredFeatures) {
self.requiredFeatures = requiredFeatures self.requiredFeatures = requiredFeatures
requiresPurchase = true requiresPurchase = true

View File

@ -60,6 +60,11 @@ public final class ProfileManager: ObservableObject {
private var allProfiles: [Profile.ID: Profile] { private var allProfiles: [Profile.ID: Profile] {
didSet { didSet {
reloadFilteredProfiles(with: searchSubject.value) 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] private var filteredProfiles: [Profile]
@Published
private var requiredFeatures: [Profile.ID: Set<AppFeature>]
@Published @Published
public private(set) var isRemoteImportingEnabled: Bool public private(set) var isRemoteImportingEnabled: Bool
@ -101,6 +109,7 @@ public final class ProfileManager: ObservableObject {
} }
allRemoteProfiles = [:] allRemoteProfiles = [:]
filteredProfiles = [] filteredProfiles = []
requiredFeatures = [:]
isRemoteImportingEnabled = false isRemoteImportingEnabled = false
waitingObservers = [] waitingObservers = []
@ -127,6 +136,7 @@ public final class ProfileManager: ObservableObject {
allProfiles = [:] allProfiles = [:]
allRemoteProfiles = [:] allRemoteProfiles = [:]
filteredProfiles = [] filteredProfiles = []
requiredFeatures = [:]
isRemoteImportingEnabled = false isRemoteImportingEnabled = false
if remoteRepositoryBlock != nil { if remoteRepositoryBlock != nil {
waitingObservers = [.local, .remote] waitingObservers = [.local, .remote]
@ -168,6 +178,10 @@ extension ProfileManager {
} }
} }
public func requiredFeatures(forProfileWithId profileId: Profile.ID) -> Set<AppFeature>? {
requiredFeatures[profileId]
}
public func search(byName name: String) { public func search(byName name: String) {
searchSubject.send(name) searchSubject.send(name)
} }

View File

@ -38,18 +38,22 @@ public final class ProfileProcessor: ObservableObject, Sendable {
private nonisolated let _willConnect: (IAPManager, Profile) throws -> Profile private nonisolated let _willConnect: (IAPManager, Profile) throws -> Profile
private nonisolated let _verify: (IAPManager, Profile) -> Set<AppFeature>?
public init( public init(
iapManager: IAPManager, iapManager: IAPManager,
title: @escaping (Profile) -> String, title: @escaping (Profile) -> String,
isIncluded: @escaping (IAPManager, Profile) -> Bool, isIncluded: @escaping (IAPManager, Profile) -> Bool,
willSave: @escaping (IAPManager, Profile.Builder) throws -> Profile.Builder, 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<AppFeature>?
) { ) {
self.iapManager = iapManager self.iapManager = iapManager
self.title = title self.title = title
_isIncluded = isIncluded _isIncluded = isIncluded
_willSave = willSave _willSave = willSave
_willConnect = willConnect _willConnect = willConnect
_verify = verify
} }
public func isIncluded(_ profile: Profile) -> Bool { public func isIncluded(_ profile: Profile) -> Bool {
@ -63,4 +67,8 @@ public final class ProfileProcessor: ObservableObject, Sendable {
public func willConnect(_ profile: Profile) throws -> Profile { public func willConnect(_ profile: Profile) throws -> Profile {
try _willConnect(iapManager, profile) try _willConnect(iapManager, profile)
} }
public func verify(_ profile: Profile) -> Set<AppFeature>? {
_verify(iapManager, profile)
}
} }

View File

@ -30,6 +30,32 @@ public protocol AppFeatureRequiring {
var features: Set<AppFeature> { get } var features: Set<AppFeature> { get }
} }
// MARK: - Profile
extension Profile: AppFeatureRequiring {
public var features: Set<AppFeature> {
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<AppFeature> {
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 // MARK: - Modules
extension DNSModule.Builder: AppFeatureRequiring { extension DNSModule.Builder: AppFeatureRequiring {

View File

@ -27,36 +27,23 @@ import Foundation
import PassepartoutKit import PassepartoutKit
extension IAPManager { extension IAPManager {
public func verify(_ modules: [Module]) throws { public func verify(_ profile: Profile) throws {
let builders = modules.map { try verify(profile.features)
guard let builder = $0.moduleBuilder() else {
fatalError("Cannot produce ModuleBuilder from Module for IAPManager.verify(): \($0)")
}
return builder
}
try verify(builders)
} }
public func verify(_ modulesBuilders: [any ModuleBuilder]) throws { public func verify(_ modulesBuilders: [any ModuleBuilder]) throws {
try verify(modulesBuilders.features)
}
public func verify(_ features: Set<AppFeature>) throws {
#if os(tvOS) #if os(tvOS)
guard isEligible(for: .appleTV) else { guard isEligible(for: .appleTV) else {
throw AppError.ineligibleProfile([.appleTV]) throw AppError.ineligibleProfile([.appleTV])
} }
#endif #endif
let requirements: [(UUID, Set<AppFeature>)] = modulesBuilders let requiredFeatures = features.filter {
.compactMap { builder in !isEligible(for: $0)
guard let requiring = builder as? AppFeatureRequiring else { }
return nil
}
return (builder.id, requiring.features)
}
let requiredFeatures = Set(requirements
.flatMap(\.1)
.filter {
!isEligible(for: $0)
})
guard requiredFeatures.isEmpty else { guard requiredFeatures.isEmpty else {
throw AppError.ineligibleProfile(requiredFeatures) throw AppError.ineligibleProfile(requiredFeatures)
} }

View File

@ -57,6 +57,9 @@ extension AppContext {
}, },
willConnect: { _, profile in willConnect: { _, profile in
try profile.withProviderModules() try profile.withProviderModules()
},
verify: { _, _ in
nil
} }
) )
let profileManager = { let profileManager = {

View File

@ -48,7 +48,7 @@ extension IAPManager {
builder builder
}, },
willConnect: { iap, profile in willConnect: { iap, profile in
try iap.verify(profile.activeModules) try iap.verify(profile)
// validate provider modules // validate provider modules
do { do {
@ -58,6 +58,16 @@ extension IAPManager {
pp_log(.app, .error, "Unable to inject provider modules: \(error)") pp_log(.app, .error, "Unable to inject provider modules: \(error)")
throw error throw error
} }
},
verify: { iap, profile in
do {
try iap.verify(profile)
return nil
} catch AppError.ineligibleProfile(let requiredFeatures) {
return requiredFeatures
} catch {
return nil
}
} }
) )
} }

View File

@ -87,7 +87,7 @@ private extension PacketTunnelProvider {
func checkEligibility(of profile: Profile, environment: TunnelEnvironment) async throws { func checkEligibility(of profile: Profile, environment: TunnelEnvironment) async throws {
await iapManager.reloadReceipt() await iapManager.reloadReceipt()
do { do {
try iapManager.verify(profile.activeModules) try iapManager.verify(profile)
} catch { } catch {
let error = PassepartoutError(.App.ineligibleProfile) let error = PassepartoutError(.App.ineligibleProfile)
environment.setEnvironmentValue(error.code, forKey: TunnelEnvironmentKeys.lastErrorCode) environment.setEnvironmentValue(error.code, forKey: TunnelEnvironmentKeys.lastErrorCode)