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:
parent
d5ac785bb8
commit
d8c4e87239
|
@ -18,4 +18,5 @@ default.profraw
|
|||
.build
|
||||
.bundle
|
||||
.env.secret*
|
||||
*.storekit
|
||||
tmp
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||
"state" : {
|
||||
"revision" : "2b639620b371181fa56594296918b09acf528058"
|
||||
"revision" : "caf31aff2e2641356de0d01f3c2c2d0d635d6a2b"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "git@github.com:passepartoutvpn/passepartoutkit-source",
|
||||
"state" : {
|
||||
"revision" : "b32b63ab8e09883f965737bb6214dfb81e38283a"
|
||||
"revision" : "caf31aff2e2641356de0d01f3c2c2d0d635d6a2b"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
|
||||
import SwiftUI
|
||||
|
||||
// FIXME: #585, donations
|
||||
// FIXME: #819, donations
|
||||
|
||||
struct DonateView: View {
|
||||
var body: some View {
|
||||
|
|
|
@ -33,7 +33,7 @@ extension AboutView {
|
|||
List {
|
||||
SettingsSectionGroup(profileManager: profileManager)
|
||||
Group {
|
||||
// FIXME: #585, donations
|
||||
// FIXME: #819, donations
|
||||
// donateLink
|
||||
linksLink
|
||||
creditsLink
|
||||
|
|
|
@ -32,7 +32,7 @@ extension AboutView {
|
|||
var listView: some View {
|
||||
List(selection: $navigationRoute) {
|
||||
Section {
|
||||
// FIXME: #585, donations
|
||||
// FIXME: #819, donations
|
||||
// donateLink
|
||||
linksLink
|
||||
creditsLink
|
||||
|
|
|
@ -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)")
|
||||
|
|
|
@ -34,7 +34,7 @@ extension BundleConfiguration {
|
|||
|
||||
case cloudKitId
|
||||
|
||||
case customUserLevel
|
||||
case userLevel
|
||||
|
||||
case groupId
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
}
|
||||
|
|
|
@ -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?
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
public enum AppUserLevel: Int {
|
||||
public enum AppUserLevel: Int, Sendable {
|
||||
case undefined = -1
|
||||
|
||||
case freemium = 0
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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?
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -34,7 +34,6 @@ extension AppContext {
|
|||
|
||||
public static func mock(withRegistry registry: Registry) -> AppContext {
|
||||
let iapManager = IAPManager(
|
||||
customUserLevel: nil,
|
||||
inAppHelper: MockAppProductHelper(),
|
||||
receiptReader: MockAppReceiptReader(),
|
||||
unrestrictedFeatures: [
|
||||
|
|
|
@ -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
|
||||
) {
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue