Refactor in-app entities for StoreKit/Kvitto integration (#820)

Refactoring:

- Get receipts from StoreKit Transaction.currentEntitlements
- Search for the originally purchased build in the local receipt anyway
(Kvitto)
- Fall back to release receipt (Kvitto), if any, for feature eligibility
in TestFlight builds
- Parse and verify expiration date in subscriptions
- Decouple in-app identifier composition from BundleConfiguration
- Fix user level features only applied when a receipt was not found

Testing:

- Add StoreKit configuration
- Fake purchases with PP_FAKE_IAP
- Fake user level with PP_USER_LEVEL

Then for reactive receipt reload, detect app activation differently:

- iOS/tvOS on .scenePhase
- macOS on launch and NSWorkspace.didActivateApplicationNotification

As to features:

- Credit former "Full version" purchasers with all current AND future
features, except the Apple TV
This commit is contained in:
Davide 2024-11-06 13:20:12 +01:00 committed by GitHub
parent d5ac785bb8
commit d8c4e87239
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 652 additions and 419 deletions

1
.gitignore vendored
View File

@ -18,4 +18,5 @@ default.profraw
.build
.bundle
.env.secret*
*.storekit
tmp

View File

@ -93,6 +93,7 @@
/* Begin PBXFileReference section */
0E06D18F2B87629100176E1D /* Passepartout.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Passepartout.app; sourceTree = BUILT_PRODUCTS_DIR; };
0E5DFDDC2CDB8F9100F2DE70 /* Passepartout.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = Passepartout.storekit; sourceTree = "<group>"; };
0E757F102CD0CFFC006E13E1 /* PassepartoutLoginItem.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PassepartoutLoginItem.app; sourceTree = BUILT_PRODUCTS_DIR; };
0E757F122CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PassepartoutLoginItemApp.swift; sourceTree = "<group>"; };
0E757F182CD0CFFD006E13E1 /* LoginItem.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoginItem.entitlements; sourceTree = "<group>"; };
@ -201,6 +202,7 @@
children = (
0E8D852F2C328CA1005493DE /* Config.xcconfig */,
0E7D0EAD2CAEA47700A2F28D /* Passepartout.xctestplan */,
0E5DFDDC2CDB8F9100F2DE70 /* Passepartout.storekit */,
0E7E3D5A2B9345FD002BBDB4 /* App */,
0EDE56E82CABE40D0082D21C /* Intents */,
0E757F112CD0CFFC006E13E1 /* LoginItem */,

View File

@ -41,7 +41,7 @@
"kind" : "remoteSourceControl",
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
"state" : {
"revision" : "2b639620b371181fa56594296918b09acf528058"
"revision" : "caf31aff2e2641356de0d01f3c2c2d0d635d6a2b"
}
},
{

View File

@ -89,11 +89,19 @@
isEnabled = "NO">
</EnvironmentVariable>
<EnvironmentVariable
key = "CUSTOM_USER_LEVEL"
key = "PP_USER_LEVEL"
value = "2"
isEnabled = "YES">
isEnabled = "NO">
</EnvironmentVariable>
<EnvironmentVariable
key = "PP_FAKE_IAP"
value = "1"
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables>
<StoreKitConfigurationFileReference
identifier = "../../Passepartout/Passepartout.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View File

@ -49,7 +49,7 @@ struct PassepartoutApp: App {
#endif
@Environment(\.scenePhase)
private var scenePhase
var scenePhase
@StateObject
var theme = Theme()
@ -70,14 +70,5 @@ extension PassepartoutApp {
tunnel: context.tunnel,
registry: context.registry
)
.onChange(of: scenePhase) {
switch $0 {
case .active:
context.onApplicationActive()
default:
break
}
}
}
}

View File

@ -39,6 +39,11 @@ extension PassepartoutApp {
var body: some Scene {
WindowGroup {
contentView()
.task(id: scenePhase) {
if scenePhase == .active {
context.onApplicationActive()
}
}
.onOpenURL { url in
ImporterPipe.shared.send([url])
}

View File

@ -26,6 +26,7 @@
#if os(macOS)
import AppUIMain
import Combine
import CommonLibrary
import PassepartoutKit
import SwiftUI
@ -74,12 +75,15 @@ extension PassepartoutApp {
Window(appName, id: appName) {
contentView()
.withEnvironment(from: context, theme: theme)
.onReceive(didActivateNotificationPublisher) {
context.onApplicationActive()
}
}
.defaultSize(width: 600, height: 400)
Settings {
SettingsView(profileManager: context.profileManager)
.frame(minWidth: 300, minHeight: 200)
.frame(minWidth: 300, minHeight: 300)
.withEnvironment(from: context, theme: theme)
}
MenuBarExtra {
@ -95,4 +99,21 @@ extension PassepartoutApp {
}
}
private extension PassepartoutApp {
var didActivateNotificationPublisher: AnyPublisher<Void, Never> {
NSWorkspace.shared.notificationCenter
.publisher(for: NSWorkspace.didActivateApplicationNotification)
.map {
guard let app = $0.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else {
return false
}
return app.bundleIdentifier == Bundle.main.bundleIdentifier
}
.removeDuplicates()
.filter { $0 }
.map { _ in }
.eraseToAnyPublisher()
}
}
#endif

View File

@ -39,6 +39,11 @@ extension PassepartoutApp {
var body: some Scene {
WindowGroup {
contentView()
.task(id: scenePhase) {
if scenePhase == .active {
context.onApplicationActive()
}
}
.withEnvironment(from: context, theme: theme)
}
}

View File

@ -41,7 +41,7 @@
"kind" : "remoteSourceControl",
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
"state" : {
"revision" : "b32b63ab8e09883f965737bb6214dfb81e38283a"
"revision" : "caf31aff2e2641356de0d01f3c2c2d0d635d6a2b"
}
},
{

View File

@ -40,7 +40,7 @@ let package = Package(
],
dependencies: [
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", from: "0.9.0"),
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "2b639620b371181fa56594296918b09acf528058"),
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source", revision: "caf31aff2e2641356de0d01f3c2c2d0d635d6a2b"),
// .package(path: "../../../passepartoutkit-source"),
.package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", from: "0.9.1"),
// .package(url: "git@github.com:passepartoutvpn/passepartoutkit-source-openvpn-openssl", revision: "031863a1cd683962a7dfe68e20b91fa820a1ecce"),
@ -109,7 +109,6 @@ let package = Package(
name: "CommonLibrary",
dependencies: [
"CommonUtils",
"Kvitto",
.product(name: "PassepartoutKit", package: "passepartoutkit-source"),
.product(name: "PassepartoutOpenVPNOpenSSL", package: "passepartoutkit-source-openvpn-openssl"),
.product(name: "PassepartoutWireGuardGo", package: "passepartoutkit-source-wireguard-go")
@ -119,7 +118,8 @@ let package = Package(
]
),
.target(
name: "CommonUtils"
name: "CommonUtils",
dependencies: ["Kvitto"]
),
.target(
name: "LegacyV2",

View File

@ -35,11 +35,7 @@ public final class AppUIMain: UILibraryConfiguring {
public func configure(with context: AppContext) {
assertMissingImplementations()
// keep this for login item because scenePhase is not triggered
if isStartedFromLoginItem {
context.onApplicationActive()
}
context.onApplicationActive()
}
}

View File

@ -25,7 +25,7 @@
import SwiftUI
// FIXME: #585, donations
// FIXME: #819, donations
struct DonateView: View {
var body: some View {

View File

@ -33,7 +33,7 @@ extension AboutView {
List {
SettingsSectionGroup(profileManager: profileManager)
Group {
// FIXME: #585, donations
// FIXME: #819, donations
// donateLink
linksLink
creditsLink

View File

@ -32,7 +32,7 @@ extension AboutView {
var listView: some View {
List(selection: $navigationRoute) {
Section {
// FIXME: #585, donations
// FIXME: #819, donations
// donateLink
linksLink
creditsLink

View File

@ -367,7 +367,7 @@ private extension ProfileManager {
pp_log(.app, .info, "Start importing remote profiles...")
var idsToRemove: [Profile.ID] = []
if !remotelyDeletedIds.isEmpty {
pp_log(.app, .info, "\tWill \(deletingRemotely ? "delete" : "retain") local profiles not present in remote repository: \(remotelyDeletedIds)")
pp_log(.app, .info, "Will \(deletingRemotely ? "delete" : "retain") local profiles not present in remote repository: \(remotelyDeletedIds)")
if deletingRemotely {
idsToRemove.append(contentsOf: remotelyDeletedIds)
@ -376,20 +376,20 @@ private extension ProfileManager {
for remoteProfile in profilesToImport {
do {
guard processor?.isIncluded(remoteProfile) ?? true else {
pp_log(.app, .info, "\tWill delete non-included remote profile \(remoteProfile.id)")
pp_log(.app, .info, "Will delete non-included remote profile \(remoteProfile.id)")
idsToRemove.append(remoteProfile.id)
continue
}
if let localFingerprint = allFingerprints[remoteProfile.id] {
guard remoteProfile.attributes.fingerprint != localFingerprint else {
pp_log(.app, .info, "\tSkip re-importing local profile \(remoteProfile.id)")
pp_log(.app, .info, "Skip re-importing local profile \(remoteProfile.id)")
continue
}
}
pp_log(.app, .notice, "\tImport remote profile \(remoteProfile.id)...")
pp_log(.app, .notice, "Import remote profile \(remoteProfile.id)...")
try await save(remoteProfile)
} catch {
pp_log(.app, .error, "\tUnable to import remote profile: \(error)")
pp_log(.app, .error, "Unable to import remote profile: \(error)")
}
}
pp_log(.app, .notice, "Finished importing remote profiles, delete stale profiles: \(idsToRemove)")

View File

@ -34,7 +34,7 @@ extension BundleConfiguration {
case cloudKitId
case customUserLevel
case userLevel
case groupId

View File

@ -25,7 +25,7 @@
import Foundation
public enum AppFeature: String {
public enum AppFeature: String, CaseIterable {
case appleTV
case dns
@ -42,14 +42,9 @@ public enum AppFeature: String {
case siri
public static let fullVersionFeaturesV2: [AppFeature] = [
.dns,
.httpProxy,
.onDemand,
.providers,
.routing,
.siri
]
public static let allButAppleTV: [AppFeature] = allCases.filter {
$0 != .appleTV
}
}
extension AppFeature: Identifiable {

View File

@ -33,12 +33,10 @@ extension AppUserLevel: AppFeatureProviding {
var features: [AppFeature] {
switch self {
case .fullVersion:
return AppFeature.fullVersionFeaturesV2
return AppFeature.allButAppleTV
case .fullVersionPlusTV:
var list = AppFeature.fullVersionFeaturesV2
list.append(.appleTV)
return list
return AppFeature.allCases
default:
return []
@ -65,18 +63,18 @@ extension AppProduct: AppFeatureProviding {
return [.onDemand]
case .Full.allPlatforms:
return AppFeature.fullVersionFeaturesV2
return AppFeature.allButAppleTV
case .Full.iOS:
#if os(iOS)
return AppFeature.fullVersionFeaturesV2
return AppFeature.allButAppleTV
#else
return []
#endif
case .Full.macOS:
#if os(macOS)
return AppFeature.fullVersionFeaturesV2
return AppFeature.allButAppleTV
#else
return []
#endif

View File

@ -49,11 +49,13 @@ extension AppProduct {
]
}
static let donationPrefix = "donations."
private init(donationId: String) {
self.init(rawValue: "donations.\(donationId)")!
self.init(rawValue: "\(Self.donationPrefix)\(donationId)")!
}
var isDonation: Bool {
rawValue.hasPrefix("donations")
rawValue.hasPrefix(Self.donationPrefix)
}
}

View File

@ -31,11 +31,13 @@ extension AppProduct {
public static let appleTV = AppProduct(featureId: "appletv")
// FIXME: #585, add in-app product
// FIXME: #585, add in-app product for interactive login
public static let interactiveLogin = AppProduct(featureId: "interactive_login")
public static let networkSettings = AppProduct(featureId: "network_settings")
// FIXME: #585, restore .sharing in-app product and restrictions
public static let siriShortcuts = AppProduct(featureId: "siri")
public static let trustedNetworks = AppProduct(featureId: "trusted_networks")

View File

@ -31,7 +31,7 @@ public struct AppProduct: RawRepresentable, Hashable, Sendable {
public let rawValue: String
public init?(rawValue: String) {
if let range = rawValue.range(of: Self.featurePrefix) ?? rawValue.range(of: Self.providerPrefix) {
if let range = rawValue.range(of: Self.featurePrefix) ?? rawValue.range(of: Self.providerPrefix) ?? rawValue.range(of: Self.donationPrefix) {
self.rawValue = String(rawValue[range.lowerBound..<rawValue.endIndex])
} else {
self.rawValue = rawValue
@ -39,15 +39,6 @@ public struct AppProduct: RawRepresentable, Hashable, Sendable {
}
}
extension AppProduct: InAppIdentifierProviding {
public var inAppIdentifier: String {
[
BundleConfiguration.mainString(for: .iapBundlePrefix),
rawValue
].joined(separator: ".")
}
}
extension AppProduct {
public static var all: [Self] {
Features.all + Full.all + Donations.all

View File

@ -26,8 +26,8 @@
import CommonUtils
import Foundation
public protocol AppProductHelper: InAppHelper where ProductIdentifier == AppProduct {
public protocol AppProductHelper: InAppHelper where ProductType == AppProduct {
}
extension StoreKitHelper: AppProductHelper where ProductIdentifier == AppProduct {
extension StoreKitHelper: AppProductHelper where ProductType == AppProduct {
}

View File

@ -2,7 +2,7 @@
// AppReceiptReader.swift
// Passepartout
//
// Created by Davide De Rosa on 9/10/24.
// Created by Davide De Rosa on 11/6/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
@ -26,5 +26,6 @@
import CommonUtils
import Foundation
public protocol AppReceiptReader: InAppReceiptReader where UserLevel == AppUserLevel {
public protocol AppReceiptReader {
func receipt(at userLevel: AppUserLevel) async -> InAppReceipt?
}

View File

@ -25,7 +25,7 @@
import Foundation
public enum AppUserLevel: Int {
public enum AppUserLevel: Int, Sendable {
case undefined = -1
case freemium = 0

View File

@ -0,0 +1,90 @@
//
// FallbackReceiptReader.swift
// Passepartout
//
// Created by Davide De Rosa on 11/6/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonUtils
import Foundation
import Kvitto
import PassepartoutKit
public actor FallbackReceiptReader: AppReceiptReader {
private let reader: InAppReceiptReader?
private let localReader: (URL) -> InAppReceiptReader?
public init(
reader: (InAppReceiptReader & Sendable)?,
localReader: @escaping @Sendable (URL) -> InAppReceiptReader & Sendable
) {
self.reader = reader
self.localReader = localReader
}
public func receipt(at userLevel: AppUserLevel) async -> InAppReceipt? {
let localURL = Bundle.main.appStoreReceiptURL
if let receipt = await reader?.receipt() {
// fetch build number from local receipt
if let localURL,
let local = localReader(localURL),
let localReceipt = await local.receipt(),
let build = localReceipt.originalBuildNumber {
return receipt.withBuildNumber(build)
}
return receipt
}
// fall back to release/sandbox receipt
guard let localURL else {
return nil
}
// attempt fallback from primary to local receipt
pp_log(.app, .error, "Primary receipt not found, falling back to local receipt")
if let local = localReader(localURL), let localReceipt = await local.receipt() {
return localReceipt
}
// in TestFlight, attempt fallback from sandbox to release receipt
if userLevel == .beta {
let releaseURL = localURL
.deletingLastPathComponent()
.appendingPathComponent("receipt")
guard releaseURL != localURL else {
#if !os(macOS) && !targetEnvironment(simulator)
assertionFailure("How can release URL be equal to sandbox URL in TestFlight?")
#endif
return nil
}
pp_log(.app, .error, "Sandbox receipt not found, falling back to Release receipt")
let release = localReader(releaseURL)
return await release?.receipt()
}
return nil
}
}

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Combine
import CommonUtils
import Foundation
import PassepartoutKit
@ -33,7 +34,7 @@ public final class IAPManager: ObservableObject {
private let inAppHelper: any AppProductHelper
private let receiptReader: any AppReceiptReader
private let receiptReader: AppReceiptReader
private let unrestrictedFeatures: Set<AppFeature>
@ -47,10 +48,12 @@ public final class IAPManager: ObservableObject {
private var eligibleFeatures: Set<AppFeature>
private var subscriptions: Set<AnyCancellable>
public init(
customUserLevel: AppUserLevel? = nil,
inAppHelper: any AppProductHelper,
receiptReader: any AppReceiptReader,
receiptReader: AppReceiptReader,
unrestrictedFeatures: Set<AppFeature> = [],
productsAtBuild: BuildProducts<AppProduct>? = nil
) {
@ -62,34 +65,29 @@ public final class IAPManager: ObservableObject {
userLevel = .undefined
purchasedProducts = []
eligibleFeatures = []
subscriptions = []
Task {
do {
try await inAppHelper.fetchProducts()
} catch {
pp_log(.app, .error, "Unable to fetch in-app products: \(error)")
observeObjects()
}
}
// MARK: - Actions
extension IAPManager {
public func purchasableProducts(for products: [AppProduct]) async -> [InAppProduct] {
do {
let inAppProducts = try await inAppHelper.fetchProducts()
return products.compactMap {
inAppProducts[$0]
}
} catch {
pp_log(.app, .error, "Unable to fetch in-app products: \(error)")
return []
}
}
public func products(for identifiers: Set<AppProduct>) async -> [InAppProduct] {
let raw = identifiers.map(\.rawValue)
return await inAppHelper.products
.values
.filter {
raw.contains($0.productIdentifier)
}
}
public func purchase(_ inAppProduct: InAppProduct) async throws -> InAppPurchaseResult {
guard let product = AppProduct(rawValue: inAppProduct.productIdentifier) else {
return .notFound
}
return try await purchase(product)
}
public func purchase(_ product: AppProduct) async throws -> InAppPurchaseResult {
let result = try await inAppHelper.purchase(productWithIdentifier: product)
public func purchase(_ purchasableProduct: InAppProduct) async throws -> InAppPurchaseResult {
let result = try await inAppHelper.purchase(purchasableProduct)
if result == .done {
await reloadReceipt()
}
@ -102,13 +100,14 @@ public final class IAPManager: ObservableObject {
}
public func reloadReceipt() async {
await fetchLevelIfNeeded()
purchasedAppBuild = nil
purchasedProducts.removeAll()
eligibleFeatures.removeAll()
if let receipt = await receiptReader.receipt(for: userLevel) {
if let receipt = await receiptReader.receipt(at: userLevel) {
if let originalBuildNumber = receipt.originalBuildNumber {
purchasedAppBuild = originalBuildNumber
}
purchasedProducts.removeAll()
if let purchasedAppBuild {
pp_log(.app, .info, "Original purchased build: \(purchasedAppBuild)")
@ -127,6 +126,14 @@ public final class IAPManager: ObservableObject {
guard let pid = $0.productIdentifier, let product = AppProduct(rawValue: pid) else {
return
}
if let expirationDate = $0.expirationDate {
let now = Date()
pp_log(.app, .debug, "\t\(pid) [expiration date: \(expirationDate), now: \(now)]")
if now >= expirationDate {
pp_log(.app, .info, "\t\(pid) [expired on: \(expirationDate)]")
return
}
}
if let cancellationDate = $0.cancellationDate {
pp_log(.app, .info, "\t\(pid) [cancelled on: \(cancellationDate)]")
return
@ -145,37 +152,25 @@ public final class IAPManager: ObservableObject {
}
} else {
pp_log(.app, .error, "Could not parse App Store receipt!")
eligibleFeatures = Set(userLevel.features)
}
userLevel.features.forEach {
eligibleFeatures.insert($0)
}
unrestrictedFeatures.forEach {
eligibleFeatures.insert($0)
}
pp_log(.app, .notice, "Purchased products: \(purchasedProducts.map(\.rawValue))")
pp_log(.app, .notice, "Eligible features: \(eligibleFeatures)")
pp_log(.app, .notice, "Reloaded IAP receipt:")
pp_log(.app, .notice, "\tPurchased build number: \(purchasedAppBuild?.description ?? "unknown")")
pp_log(.app, .notice, "\tPurchased products: \(purchasedProducts.map(\.rawValue))")
pp_log(.app, .notice, "\tEligible features: \(eligibleFeatures)")
objectWillChange.send()
}
}
private extension IAPManager {
func fetchLevelIfNeeded() async {
guard userLevel == .undefined else {
return
}
if let customUserLevel {
userLevel = customUserLevel
pp_log(.app, .info, "App level (custom): \(userLevel)")
} else {
let isBeta = await SandboxChecker().isBeta
userLevel = isBeta ? .beta : .freemium
pp_log(.app, .info, "App level: \(userLevel)")
}
}
}
// MARK: In-app eligibility
// MARK: - Eligibility
extension IAPManager {
public var isRestricted: Bool {
@ -216,3 +211,44 @@ extension IAPManager {
!purchasedProducts.isEmpty
}
}
// MARK: - Observation
private extension IAPManager {
func observeObjects() {
Task {
await fetchLevelIfNeeded()
do {
let products = try await inAppHelper.fetchProducts()
pp_log(.app, .info, "Available in-app products: \(products.map(\.key))")
inAppHelper
.didUpdate
.receive(on: DispatchQueue.main)
.sink { [weak self] in
Task {
await self?.reloadReceipt()
}
}
.store(in: &subscriptions)
} catch {
pp_log(.app, .error, "Unable to fetch in-app products: \(error)")
}
}
}
func fetchLevelIfNeeded() async {
guard userLevel == .undefined else {
return
}
if let customUserLevel {
userLevel = customUserLevel
pp_log(.app, .info, "App level (custom): \(userLevel)")
} else {
let isBeta = await SandboxChecker().isBeta
userLevel = isBeta ? .beta : .freemium
pp_log(.app, .info, "App level: \(userLevel)")
}
}
}

View File

@ -1,85 +0,0 @@
//
// KvittoReceiptReader.swift
// Passepartout
//
// Created by Davide De Rosa on 12/20/23.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonUtils
import Foundation
import Kvitto
import PassepartoutKit
public final class KvittoReceiptReader: AppReceiptReader {
public init() {
}
public func receipt(for userLevel: AppUserLevel) -> InAppReceipt? {
guard let url = Bundle.main.appStoreReceiptURL else {
pp_log(.app, .error, "No App Store receipt found!")
return nil
}
let receipt = Receipt(contentsOfURL: url)
let fallbackReceipt: Receipt? = {
// in TestFlight, attempt fallback to existing release receipt
if userLevel == .beta {
guard let receipt else {
let releaseUrl = url.deletingLastPathComponent().appendingPathComponent("receipt")
guard releaseUrl != url else {
#if !os(macOS) && !targetEnvironment(simulator)
assertionFailure("How can release URL be equal to sandbox URL in TestFlight?")
#endif
return nil
}
pp_log(.app, .error, "Sandbox receipt not found, falling back to Release receipt")
return Receipt(contentsOfURL: releaseUrl)
}
return receipt
}
return receipt
}()
return fallbackReceipt?.asInAppReceipt
}
}
private extension Receipt {
var asInAppReceipt: InAppReceipt {
var originalBuildNumber: Int?
var purchaseReceipts: [InAppReceipt.PurchaseReceipt]?
if let originalAppVersion, let buildNumber = Int(originalAppVersion) {
originalBuildNumber = buildNumber
}
if let inAppPurchaseReceipts {
purchaseReceipts = inAppPurchaseReceipts
.map {
InAppReceipt.PurchaseReceipt(productIdentifier: $0.productIdentifier,
cancellationDate: $0.cancellationDate,
originalPurchaseDate: $0.originalPurchaseDate)
}
}
return InAppReceipt(originalBuildNumber: originalBuildNumber,
purchaseReceipts: purchaseReceipts)
}
}

View File

@ -23,6 +23,7 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Combine
import CommonUtils
import Foundation
@ -33,18 +34,25 @@ public actor MockAppProductHelper: AppProductHelper {
public nonisolated let receiptReader: MockAppReceiptReader
private let didUpdateSubject: PassthroughSubject<Void, Never>
// set .max to skip entitled products
public init(build: Int = .max) {
self.build = build
products = [:]
receiptReader = MockAppReceiptReader()
didUpdateSubject = PassthroughSubject()
}
public nonisolated var canMakePurchases: Bool {
true
}
public func fetchProducts() async throws {
public nonisolated var didUpdate: AnyPublisher<Void, Never> {
didUpdateSubject.eraseToAnyPublisher()
}
public func fetchProducts() async throws -> [AppProduct: InAppProduct] {
products = AppProduct.all.reduce(into: [:]) {
$0[$1] = InAppProduct(
productIdentifier: $1.rawValue,
@ -53,14 +61,18 @@ public actor MockAppProductHelper: AppProductHelper {
native: $1
)
}
await receiptReader.setReceipt(withBuild: build, products: [])
await receiptReader.setReceipt(withBuild: build, identifiers: [])
didUpdateSubject.send()
return products
}
public func purchase(productWithIdentifier productIdentifier: AppProduct) async throws -> InAppPurchaseResult {
await receiptReader.addPurchase(with: productIdentifier)
public func purchase(_ inAppProduct: InAppProduct) async throws -> InAppPurchaseResult {
await receiptReader.addPurchase(with: inAppProduct.productIdentifier)
didUpdateSubject.send()
return .done
}
public func restorePurchases() async throws {
didUpdateSubject.send()
}
}

View File

@ -27,46 +27,50 @@ import CommonUtils
import Foundation
public actor MockAppReceiptReader: AppReceiptReader {
private var receipt: InAppReceipt?
private var localReceipt: InAppReceipt?
public init(receipt: InAppReceipt? = nil) {
self.receipt = receipt
public init(receipt localReceipt: InAppReceipt? = nil) {
self.localReceipt = localReceipt
}
public func setReceipt(withBuild build: Int, products: [AppProduct], cancelledProducts: Set<AppProduct> = []) {
receipt = InAppReceipt(originalBuildNumber: build, purchaseReceipts: products.map {
public func setReceipt(withBuild build: Int, products: Set<AppProduct>, cancelledProducts: Set<AppProduct> = []) {
setReceipt(
withBuild: build,
identifiers: Set(products.map(\.rawValue)),
cancelledIdentifiers: Set(cancelledProducts.map(\.rawValue))
)
}
public func setReceipt(withBuild build: Int, identifiers: Set<String>, cancelledIdentifiers: Set<String> = []) {
localReceipt = InAppReceipt(originalBuildNumber: build, purchaseReceipts: identifiers.map {
.init(
productIdentifier: $0.rawValue,
cancellationDate: cancelledProducts.contains($0) ? Date() : nil,
productIdentifier: $0,
expirationDate: nil,
cancellationDate: cancelledIdentifiers.contains($0) ? Date() : nil,
originalPurchaseDate: nil
)
})
}
public func receipt(for userLevel: AppUserLevel) -> InAppReceipt? {
receipt
public func receipt(at userLevel: AppUserLevel) async -> InAppReceipt? {
localReceipt
}
public func addPurchase(with product: AppProduct) {
guard let receipt else {
public func addPurchase(with identifier: String) {
guard let localReceipt else {
fatalError("Call setReceipt() first")
}
var purchaseReceipts = receipt.purchaseReceipts ?? []
purchaseReceipts.append(product.purchaseReceipt)
let newReceipt = InAppReceipt(
originalBuildNumber: receipt.originalBuildNumber,
purchaseReceipts: purchaseReceipts
)
self.receipt = newReceipt
}
}
private extension AppProduct {
var purchaseReceipt: InAppReceipt.PurchaseReceipt {
.init(
productIdentifier: rawValue,
var purchaseReceipts = localReceipt.purchaseReceipts ?? []
purchaseReceipts.append(.init(
productIdentifier: identifier,
expirationDate: nil,
cancellationDate: nil,
originalPurchaseDate: nil
))
let newReceipt = InAppReceipt(
originalBuildNumber: localReceipt.originalBuildNumber,
purchaseReceipts: purchaseReceipts
)
self.localReceipt = newReceipt
}
}

View File

@ -1,134 +0,0 @@
//
// StoreKitHelper.swift
// Passepartout
//
// Created by Davide De Rosa on 9/9/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Combine
import Foundation
import StoreKit
@MainActor
public final class StoreKitHelper<PID>: InAppHelper where PID: RawRepresentable & Hashable & InAppIdentifierProviding,
PID.RawValue == String {
private let identifiers: [PID]
@Published
public private(set) var products: [PID: InAppProduct]
@Published
public private(set) var purchasedIdentifiers: Set<String>
private var activeTransactions: Set<Transaction>
private var observer: Task<Void, Never>?
public init(identifiers: [PID]) {
self.identifiers = identifiers
products = [:]
purchasedIdentifiers = []
activeTransactions = []
observer = transactionsObserverTask()
}
deinit {
observer?.cancel()
}
public nonisolated var canMakePurchases: Bool {
AppStore.canMakePayments
}
public func fetchProducts() async throws {
guard products.isEmpty else {
return
}
do {
let skProducts = try await Product.products(for: identifiers.map(\.rawValue))
products = skProducts.reduce(into: [:]) {
guard let pid = PID(rawValue: $1.id) else {
return
}
$0[pid] = InAppProduct(
productIdentifier: $1.id,
localizedTitle: $1.displayName,
localizedPrice: $1.displayPrice,
native: $1
)
}
} catch {
products = [:]
throw error
}
}
// FIXME: #585, implement purchase
public func purchase(productWithIdentifier productIdentifier: ProductIdentifier) async throws -> InAppPurchaseResult {
fatalError("purchase")
}
// FIXME: #585, implement restore purchases
public func restorePurchases() async throws {
fatalError("restorePurchases")
}
}
private extension StoreKitHelper {
nonisolated func transactionsObserverTask() -> Task<Void, Never> {
Task {
await refreshTransactions()
for await update in Transaction.updates {
guard let transaction = try? update.payloadValue else {
continue
}
await fetchActiveTransactions()
await transaction.finish()
}
}
}
func refreshTransactions() async {
for await result in Transaction.currentEntitlements {
guard case .verified(let transaction) = result else {
continue
}
guard transaction.revocationDate == nil else {
purchasedIdentifiers.remove(transaction.productID)
continue
}
purchasedIdentifiers.insert(transaction.productID)
}
}
func fetchActiveTransactions() async {
var activeTransactions: Set<Transaction> = []
for await entitlement in Transaction.currentEntitlements {
if let transaction = try? entitlement.payloadValue {
activeTransactions.insert(transaction)
}
}
self.activeTransactions = activeTransactions
}
}

View File

@ -23,11 +23,14 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Combine
import Foundation
public enum InAppPurchaseResult: Sendable {
case done
case pending
case notFound
case cancelled
@ -54,20 +57,16 @@ public struct InAppProduct: Sendable {
}
}
public protocol InAppIdentifierProviding {
var inAppIdentifier: String { get }
}
public protocol InAppHelper {
associatedtype ProductIdentifier: Hashable & InAppIdentifierProviding
associatedtype ProductType: Hashable
var canMakePurchases: Bool { get }
var products: [ProductIdentifier: InAppProduct] { get async }
var didUpdate: AnyPublisher<Void, Never> { get }
func fetchProducts() async throws
func fetchProducts() async throws -> [ProductType: InAppProduct]
func purchase(productWithIdentifier productIdentifier: ProductIdentifier) async throws -> InAppPurchaseResult
func purchase(_ inAppProduct: InAppProduct) async throws -> InAppPurchaseResult
func restorePurchases() async throws
}
@ -76,12 +75,15 @@ public struct InAppReceipt: Sendable {
public struct PurchaseReceipt: Sendable {
public let productIdentifier: String?
public let expirationDate: Date?
public let cancellationDate: Date?
public let originalPurchaseDate: Date?
public init(productIdentifier: String?, cancellationDate: Date?, originalPurchaseDate: Date?) {
public init(productIdentifier: String?, expirationDate: Date?, cancellationDate: Date?, originalPurchaseDate: Date?) {
self.productIdentifier = productIdentifier
self.expirationDate = expirationDate
self.cancellationDate = cancellationDate
self.originalPurchaseDate = originalPurchaseDate
}
@ -95,10 +97,12 @@ public struct InAppReceipt: Sendable {
self.originalBuildNumber = originalBuildNumber
self.purchaseReceipts = purchaseReceipts
}
public func withBuildNumber(_ buildNumber: Int) -> Self {
.init(originalBuildNumber: buildNumber, purchaseReceipts: purchaseReceipts)
}
}
public protocol InAppReceiptReader {
associatedtype UserLevel
func receipt(for userLevel: UserLevel) async -> InAppReceipt?
func receipt() async -> InAppReceipt?
}

View File

@ -0,0 +1,64 @@
//
// KvittoReceiptReader.swift
// Passepartout
//
// Created by Davide De Rosa on 12/20/23.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Foundation
import Kvitto
public final class KvittoReceiptReader: InAppReceiptReader, Sendable {
private let url: URL
public init(url: URL) {
self.url = url
}
public func receipt() -> InAppReceipt? {
Receipt(contentsOfURL: url)?.asInAppReceipt
}
}
private extension Receipt {
var asInAppReceipt: InAppReceipt {
var originalBuildNumber: Int?
var purchaseReceipts: [InAppReceipt.PurchaseReceipt]?
if let originalAppVersion, let buildNumber = Int(originalAppVersion) {
originalBuildNumber = buildNumber
}
if let inAppPurchaseReceipts {
purchaseReceipts = inAppPurchaseReceipts
.map {
InAppReceipt.PurchaseReceipt(
productIdentifier: $0.productIdentifier,
expirationDate: $0.subscriptionExpirationDate,
cancellationDate: $0.cancellationDate,
originalPurchaseDate: $0.originalPurchaseDate
)
}
}
return InAppReceipt(
originalBuildNumber: originalBuildNumber,
purchaseReceipts: purchaseReceipts
)
}
}

View File

@ -0,0 +1,147 @@
//
// StoreKitHelper.swift
// Passepartout
//
// Created by Davide De Rosa on 9/9/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Combine
import Foundation
import StoreKit
@MainActor
public final class StoreKitHelper<ProductType>: InAppHelper where ProductType: RawRepresentable & Hashable,
ProductType.RawValue == String {
private let products: [ProductType]
private let inAppIdentifier: (ProductType) -> String
private var nativeProducts: [ProductType: InAppProduct]
private var activeTransactions: Set<Transaction> {
didSet {
didUpdateSubject.send()
}
}
private let didUpdateSubject: PassthroughSubject<Void, Never>
private var observer: Task<Void, Never>?
public init(products: [ProductType], inAppIdentifier: @escaping (ProductType) -> String) {
self.products = products
self.inAppIdentifier = inAppIdentifier
nativeProducts = [:]
activeTransactions = []
didUpdateSubject = PassthroughSubject()
observer = transactionsObserverTask()
}
deinit {
observer?.cancel()
}
}
extension StoreKitHelper {
public nonisolated var canMakePurchases: Bool {
AppStore.canMakePayments
}
public nonisolated var didUpdate: AnyPublisher<Void, Never> {
didUpdateSubject.eraseToAnyPublisher()
}
public func fetchProducts() async throws -> [ProductType: InAppProduct] {
if !nativeProducts.isEmpty {
return nativeProducts
}
let skProducts = try await Product.products(for: products.map(inAppIdentifier))
nativeProducts = skProducts.reduce(into: [:]) {
guard let pid = ProductType(rawValue: $1.id) else {
return
}
$0[pid] = InAppProduct(
productIdentifier: $1.id,
localizedTitle: $1.displayName,
localizedPrice: $1.displayPrice,
native: $1
)
}
return nativeProducts
}
public func purchase(_ inAppProduct: InAppProduct) async throws -> InAppPurchaseResult {
guard let skProduct = inAppProduct.native as? Product else {
return .notFound
}
switch try await skProduct.purchase() {
case .success(let verificationResult):
if let transaction = try? verificationResult.payloadValue {
activeTransactions.insert(transaction)
await transaction.finish()
return .done
}
case .pending:
return .pending
case .userCancelled:
break
@unknown default:
break
}
return .cancelled
}
public func restorePurchases() async throws {
try await AppStore.sync()
}
}
private extension StoreKitHelper {
nonisolated func transactionsObserverTask() -> Task<Void, Never> {
Task {
for await update in Transaction.updates {
guard let transaction = try? update.payloadValue else {
continue
}
await fetchActiveTransactions()
await transaction.finish()
guard !Task.isCancelled else {
break
}
}
}
}
func fetchActiveTransactions() async {
var activeTransactions: Set<Transaction> = []
for await entitlement in Transaction.currentEntitlements {
if let transaction = try? entitlement.payloadValue {
activeTransactions.insert(transaction)
}
}
self.activeTransactions = activeTransactions
}
}

View File

@ -0,0 +1,56 @@
//
// StoreKitReceiptReader.swift
// Passepartout
//
// Created by Davide De Rosa on 11/5/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Foundation
import StoreKit
public final class StoreKitReceiptReader: InAppReceiptReader, Sendable {
public init() {
}
public func receipt() async -> InAppReceipt? {
var transactions: [Transaction] = []
for await entitlement in Transaction.currentEntitlements {
switch entitlement {
case .verified(let tx):
transactions.append(tx)
default:
break
}
}
let purchaseReceipts = transactions
.compactMap {
InAppReceipt.PurchaseReceipt(
productIdentifier: $0.productID,
expirationDate: $0.expirationDate,
cancellationDate: $0.revocationDate,
originalPurchaseDate: $0.originalPurchaseDate
)
}
return InAppReceipt(originalBuildNumber: nil, purchaseReceipts: purchaseReceipts)
}
}

View File

@ -41,7 +41,7 @@ final class CDProfileRepositoryV2 {
self.context = context
}
// FIXME: #586, migrate profiles properly
// FIXME: #642, migrate profiles properly
func migratedProfiles() async throws -> [Profile] {
try await context.perform { [weak self] in
guard let self else {

View File

@ -57,17 +57,16 @@ public final class AppContext: ObservableObject {
self.providerManager = providerManager
subscriptions = []
Task {
await iapManager.reloadReceipt()
profileManager.observeObjects()
observeObjects()
}
observeObjects()
}
public func onApplicationActive() {
Task {
do {
pp_log(.app, .notice, "Prepare tunnel and purge stale data")
pp_log(.app, .notice, "Application became active")
pp_log(.app, .notice, "Reload IAP receipt...")
await iapManager.reloadReceipt()
pp_log(.app, .notice, "Prepare tunnel and purge stale data...")
try await tunnel.prepare(purge: true)
} catch {
pp_log(.app, .fault, "Unable to prepare tunnel: \(error)")
@ -80,6 +79,8 @@ public final class AppContext: ObservableObject {
private extension AppContext {
func observeObjects() {
profileManager.observeObjects()
profileManager
.didChange
.sink { [weak self] event in

View File

@ -34,7 +34,6 @@ extension AppContext {
public static func mock(withRegistry registry: Registry) -> AppContext {
let iapManager = IAPManager(
customUserLevel: nil,
inAppHelper: MockAppProductHelper(),
receiptReader: MockAppReceiptReader(),
unrestrictedFeatures: [

View File

@ -45,7 +45,7 @@ extension IAPManagerTests {
func test_givenBuildProducts_whenOlder_thenFullVersion() async {
let reader = MockAppReceiptReader()
await reader.setReceipt(withBuild: olderBuildNumber, products: [])
await reader.setReceipt(withBuild: olderBuildNumber, identifiers: [])
let sut = IAPManager(receiptReader: reader) { build in
if build <= self.defaultBuildNumber {
return [.Full.allPlatforms]
@ -53,7 +53,7 @@ extension IAPManagerTests {
return []
}
await sut.reloadReceipt()
XCTAssertTrue(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
XCTAssertTrue(sut.isEligible(for: AppFeature.allButAppleTV))
}
func test_givenBuildProducts_whenNewer_thenFreeVersion() async {
@ -66,7 +66,7 @@ extension IAPManagerTests {
return []
}
await sut.reloadReceipt()
XCTAssertFalse(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
XCTAssertFalse(sut.isEligible(for: AppFeature.allButAppleTV))
}
// MARK: Eligibility
@ -75,13 +75,13 @@ extension IAPManagerTests {
let reader = MockAppReceiptReader()
let sut = IAPManager(receiptReader: reader)
XCTAssertFalse(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
XCTAssertFalse(sut.isEligible(for: AppFeature.allButAppleTV))
await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.allPlatforms])
XCTAssertFalse(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
XCTAssertFalse(sut.isEligible(for: AppFeature.allButAppleTV))
await sut.reloadReceipt()
XCTAssertTrue(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
XCTAssertTrue(sut.isEligible(for: AppFeature.allButAppleTV))
}
func test_givenPurchasedFeatures_thenIsOnlyEligibleForFeatures() async {
@ -98,7 +98,7 @@ extension IAPManagerTests {
XCTAssertFalse(sut.isEligible(for: .onDemand))
XCTAssertTrue(sut.isEligible(for: .routing))
XCTAssertTrue(sut.isEligible(for: .siri))
XCTAssertFalse(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
XCTAssertFalse(sut.isEligible(for: AppFeature.allButAppleTV))
}
func test_givenPurchasedAndCancelledFeature_thenIsNotEligible() async {
@ -111,7 +111,7 @@ extension IAPManagerTests {
let sut = IAPManager(receiptReader: reader)
await sut.reloadReceipt()
XCTAssertFalse(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
XCTAssertFalse(sut.isEligible(for: AppFeature.allButAppleTV))
}
func test_givenFreeVersion_thenIsNotEligibleForAnyFeature() async {
@ -121,7 +121,7 @@ extension IAPManagerTests {
await sut.reloadReceipt()
XCTAssertFalse(sut.userLevel.isFullVersion)
AppFeature.fullVersionFeaturesV2.forEach {
AppFeature.allButAppleTV.forEach {
XCTAssertFalse(sut.isEligible(for: $0))
}
}
@ -141,7 +141,7 @@ extension IAPManagerTests {
let sut = IAPManager(receiptReader: reader)
await sut.reloadReceipt()
AppFeature.fullVersionFeaturesV2.forEach {
AppFeature.allButAppleTV.forEach {
XCTAssertTrue(sut.isEligible(for: $0))
}
XCTAssertFalse(sut.isEligible(for: .appleTV))
@ -163,11 +163,11 @@ extension IAPManagerTests {
#if os(macOS)
await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.macOS, .Features.networkSettings])
await sut.reloadReceipt()
XCTAssertTrue(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
XCTAssertTrue(sut.isEligible(for: AppFeature.allButAppleTV))
#else
await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.iOS, .Features.networkSettings])
await sut.reloadReceipt()
XCTAssertTrue(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
XCTAssertTrue(sut.isEligible(for: AppFeature.allButAppleTV))
#endif
}
@ -178,11 +178,11 @@ extension IAPManagerTests {
#if os(macOS)
await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.iOS, .Features.networkSettings])
await sut.reloadReceipt()
XCTAssertFalse(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
XCTAssertFalse(sut.isEligible(for: AppFeature.allButAppleTV))
#else
await reader.setReceipt(withBuild: defaultBuildNumber, products: [.Full.macOS, .Features.networkSettings])
await sut.reloadReceipt()
XCTAssertFalse(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
XCTAssertFalse(sut.isEligible(for: AppFeature.allButAppleTV))
#endif
}
@ -205,7 +205,7 @@ extension IAPManagerTests {
let sut = IAPManager(customUserLevel: .beta, receiptReader: reader)
await sut.reloadReceipt()
XCTAssertFalse(sut.isEligible(for: AppFeature.fullVersionFeaturesV2))
XCTAssertFalse(sut.isEligible(for: AppFeature.allButAppleTV))
}
func test_givenBetaApp_thenIsEligibleForUnrestrictedFeature() async {
@ -213,7 +213,7 @@ extension IAPManagerTests {
let sut = IAPManager(customUserLevel: .beta, receiptReader: reader, unrestrictedFeatures: [.onDemand])
await sut.reloadReceipt()
AppFeature.fullVersionFeaturesV2.forEach {
AppFeature.allButAppleTV.forEach {
if $0 == .onDemand {
XCTAssertTrue(sut.isEligible(for: $0))
} else {
@ -243,7 +243,7 @@ extension IAPManagerTests {
let sut = IAPManager(customUserLevel: .fullVersion, receiptReader: reader)
await sut.reloadReceipt()
AppFeature.fullVersionFeaturesV2.forEach {
AppFeature.allButAppleTV.forEach {
XCTAssertTrue(sut.isEligible(for: $0))
}
XCTAssertFalse(sut.isEligible(for: .appleTV))
@ -254,7 +254,7 @@ extension IAPManagerTests {
let sut = IAPManager(customUserLevel: .fullVersionPlusTV, receiptReader: reader)
await sut.reloadReceipt()
AppFeature.fullVersionFeaturesV2.forEach {
AppFeature.allButAppleTV.forEach {
XCTAssertTrue(sut.isEligible(for: $0))
}
XCTAssertTrue(sut.isEligible(for: .appleTV))
@ -264,7 +264,7 @@ extension IAPManagerTests {
private extension IAPManager {
convenience init(
customUserLevel: AppUserLevel? = nil,
receiptReader: any AppReceiptReader,
receiptReader: AppReceiptReader,
unrestrictedFeatures: Set<AppFeature> = [],
productsAtBuild: BuildProducts<AppProduct>? = nil
) {

View File

@ -37,18 +37,11 @@ extension AppContext {
let tunnelEnvironment: TunnelEnvironment = .shared
let registry: Registry = .shared
#if DEBUG
let inAppHelper = MockAppProductHelper()
let receiptReader = inAppHelper.receiptReader
#else
let inAppHelper = StoreKitHelper(identifiers: AppProduct.all)
let receiptReader = KvittoReceiptReader()
#endif
let iapHelpers = Configuration.IAPManager.helpers
let iapManager = IAPManager(
customUserLevel: Configuration.IAPManager.customUserLevel,
inAppHelper: inAppHelper,
receiptReader: receiptReader,
customUserLevel: Configuration.Environment.userLevel,
inAppHelper: iapHelpers.productHelper,
receiptReader: iapHelpers.receiptReader,
// FIXME: #662, omit unrestrictedFeatures on release!
unrestrictedFeatures: [.interactiveLogin],
productsAtBuild: Configuration.IAPManager.productsAtBuild
@ -167,24 +160,52 @@ extension AppContext {
// MARK: - Configuration
private enum Configuration {
}
enum Environment {
static var isFakeIAP: Bool {
ProcessInfo.processInfo.environment["PP_FAKE_IAP"] == "1"
}
extension Configuration {
enum IAPManager {
static var customUserLevel: AppUserLevel? {
if let envString = ProcessInfo.processInfo.environment["CUSTOM_USER_LEVEL"],
static var userLevel: AppUserLevel? {
if let envString = ProcessInfo.processInfo.environment["PP_USER_LEVEL"],
let envValue = Int(envString),
let testAppType = AppUserLevel(rawValue: envValue) {
return testAppType
}
if let infoValue = BundleConfiguration.mainIntegerIfPresent(for: .customUserLevel),
if let infoValue = BundleConfiguration.mainIntegerIfPresent(for: .userLevel),
let testAppType = AppUserLevel(rawValue: infoValue) {
return testAppType
}
return nil
}
}
}
extension Configuration {
enum IAPManager {
@MainActor
static var helpers: (productHelper: any AppProductHelper, receiptReader: AppReceiptReader) {
guard !Environment.isFakeIAP else {
let mockHelper = MockAppProductHelper()
return (mockHelper, mockHelper.receiptReader)
}
let productHelper = StoreKitHelper(
products: AppProduct.all,
inAppIdentifier: {
let prefix = BundleConfiguration.mainString(for: .iapBundlePrefix)
return "\(prefix).\($0.rawValue)"
}
)
let receiptReader = FallbackReceiptReader(
reader: StoreKitReceiptReader(),
localReader: {
KvittoReceiptReader(url: $0)
}
)
return (productHelper, receiptReader)
}
static let productsAtBuild: BuildProducts<AppProduct> = {
#if os(iOS)