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
var tunnel: ExtendedTunnel
let requiredFeatures: Set<AppFeature>?
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<AppFeature>? {
profileManager.requiredFeatures(forProfileWithId: header.id)
}
var isShared: Bool {
profileManager.isRemotelyShared(profileWithId: header.id)
}

View File

@ -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

View File

@ -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<AppFeature>]
@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<AppFeature>? {
requiredFeatures[profileId]
}
public func search(byName name: String) {
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 _verify: (IAPManager, Profile) -> Set<AppFeature>?
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<AppFeature>?
) {
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<AppFeature>? {
_verify(iapManager, profile)
}
}

View File

@ -30,6 +30,32 @@ public protocol AppFeatureRequiring {
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
extension DNSModule.Builder: AppFeatureRequiring {

View File

@ -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<AppFeature>) throws {
#if os(tvOS)
guard isEligible(for: .appleTV) else {
throw AppError.ineligibleProfile([.appleTV])
}
#endif
let requirements: [(UUID, Set<AppFeature>)] = 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)
}

View File

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

View File

@ -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
}
}
)
}

View File

@ -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)