Skip to content

Commit

Permalink
Show upgrade icon in profiles list (#891)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
keeshux authored Nov 19, 2024
1 parent 1536551 commit d78456b
Show file tree
Hide file tree
Showing 9 changed files with 91 additions and 30 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -111,7 +119,8 @@ private extension ProfileRowView {
MarkerView(
headerId: header.id,
nextProfileId: nextProfileId,
tunnel: tunnel
tunnel: tunnel,
requiredFeatures: requiredFeatures
)
}

Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,21 @@ 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)
}
}
}
}

private var allRemoteProfiles: [Profile.ID: Profile]

private var filteredProfiles: [Profile]

@Published
private var requiredFeatures: [Profile.ID: Set<AppFeature>]

@Published
public private(set) var isRemoteImportingEnabled: Bool

Expand Down Expand Up @@ -101,6 +109,7 @@ public final class ProfileManager: ObservableObject {
}
allRemoteProfiles = [:]
filteredProfiles = []
requiredFeatures = [:]
isRemoteImportingEnabled = false
waitingObservers = []

Expand All @@ -127,6 +136,7 @@ public final class ProfileManager: ObservableObject {
allProfiles = [:]
allRemoteProfiles = [:]
filteredProfiles = []
requiredFeatures = [:]
isRemoteImportingEnabled = false
if remoteRepositoryBlock != nil {
waitingObservers = [.local, .remote]
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ extension AppContext {
},
willConnect: { _, profile in
try profile.withProviderModules()
},
verify: { _, _ in
nil
}
)
let profileManager = {
Expand Down
12 changes: 11 additions & 1 deletion Passepartout/Shared/Shared+App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ extension IAPManager {
builder
},
willConnect: { iap, profile in
try iap.verify(profile.activeModules)
try iap.verify(profile)

// validate provider modules
do {
Expand All @@ -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
}
}
)
}
Expand Down
2 changes: 1 addition & 1 deletion Passepartout/Tunnel/PacketTunnelProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit d78456b

Please sign in to comment.