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:
parent
1536551922
commit
d78456bb90
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -57,6 +57,9 @@ extension AppContext {
|
|||
},
|
||||
willConnect: { _, profile in
|
||||
try profile.withProviderModules()
|
||||
},
|
||||
verify: { _, _ in
|
||||
nil
|
||||
}
|
||||
)
|
||||
let profileManager = {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue