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
|
@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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in New Issue