diff --git a/.gitignore b/.gitignore index eaf5cd24..b72852fe 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ default.profraw .build .bundle .env.secret* +*.storekit tmp diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index 1c334780..d8f4bc34 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 = ""; }; 0E757F182CD0CFFD006E13E1 /* LoginItem.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LoginItem.entitlements; sourceTree = ""; }; @@ -201,6 +202,7 @@ children = ( 0E8D852F2C328CA1005493DE /* Config.xcconfig */, 0E7D0EAD2CAEA47700A2F28D /* Passepartout.xctestplan */, + 0E5DFDDC2CDB8F9100F2DE70 /* Passepartout.storekit */, 0E7E3D5A2B9345FD002BBDB4 /* App */, 0EDE56E82CABE40D0082D21C /* Intents */, 0E757F112CD0CFFC006E13E1 /* LoginItem */, diff --git a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0711e1d9..7301b1cc 100644 --- a/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Passepartout.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -41,7 +41,7 @@ "kind" : "remoteSourceControl", "location" : "git@github.com:passepartoutvpn/passepartoutkit-source", "state" : { - "revision" : "2b639620b371181fa56594296918b09acf528058" + "revision" : "caf31aff2e2641356de0d01f3c2c2d0d635d6a2b" } }, { diff --git a/Passepartout.xcodeproj/xcshareddata/xcschemes/Passepartout.xcscheme b/Passepartout.xcodeproj/xcshareddata/xcschemes/Passepartout.xcscheme index 68dce74f..a53f0c62 100644 --- a/Passepartout.xcodeproj/xcshareddata/xcschemes/Passepartout.xcscheme +++ b/Passepartout.xcodeproj/xcshareddata/xcschemes/Passepartout.xcscheme @@ -89,11 +89,19 @@ isEnabled = "NO"> + isEnabled = "NO"> + + + + { + 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 diff --git a/Passepartout/App/Platforms/App+tvOS.swift b/Passepartout/App/Platforms/App+tvOS.swift index 5997d643..0c9066ca 100644 --- a/Passepartout/App/Platforms/App+tvOS.swift +++ b/Passepartout/App/Platforms/App+tvOS.swift @@ -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) } } diff --git a/Passepartout/Library/Package.resolved b/Passepartout/Library/Package.resolved index a94953c7..0a545053 100644 --- a/Passepartout/Library/Package.resolved +++ b/Passepartout/Library/Package.resolved @@ -41,7 +41,7 @@ "kind" : "remoteSourceControl", "location" : "git@github.com:passepartoutvpn/passepartoutkit-source", "state" : { - "revision" : "b32b63ab8e09883f965737bb6214dfb81e38283a" + "revision" : "caf31aff2e2641356de0d01f3c2c2d0d635d6a2b" } }, { diff --git a/Passepartout/Library/Package.swift b/Passepartout/Library/Package.swift index f39bc32b..c1e9745a 100644 --- a/Passepartout/Library/Package.swift +++ b/Passepartout/Library/Package.swift @@ -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", diff --git a/Passepartout/Library/Sources/AppUIMain/AppUIMain.swift b/Passepartout/Library/Sources/AppUIMain/AppUIMain.swift index f44498b6..e13f3432 100644 --- a/Passepartout/Library/Sources/AppUIMain/AppUIMain.swift +++ b/Passepartout/Library/Sources/AppUIMain/AppUIMain.swift @@ -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() } } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/DonateView.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/DonateView.swift index 124239fb..8d1907c3 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/DonateView.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/DonateView.swift @@ -25,7 +25,7 @@ import SwiftUI -// FIXME: #585, donations +// FIXME: #819, donations struct DonateView: View { var body: some View { diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutView+iOS.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutView+iOS.swift index 5377b114..5cc19ab1 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutView+iOS.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/iOS/AboutView+iOS.swift @@ -33,7 +33,7 @@ extension AboutView { List { SettingsSectionGroup(profileManager: profileManager) Group { - // FIXME: #585, donations + // FIXME: #819, donations // donateLink linksLink creditsLink diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutView+macOS.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutView+macOS.swift index fd8095fc..97a009c9 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutView+macOS.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/macOS/AboutView+macOS.swift @@ -32,7 +32,7 @@ extension AboutView { var listView: some View { List(selection: $navigationRoute) { Section { - // FIXME: #585, donations + // FIXME: #819, donations // donateLink linksLink creditsLink diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift b/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift index 546b150b..c1c7ed28 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Business/ProfileManager.swift @@ -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)") diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift index fae8881a..ee66121f 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+Main.swift @@ -34,7 +34,7 @@ extension BundleConfiguration { case cloudKitId - case customUserLevel + case userLevel case groupId diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeature.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeature.swift index 19f210aa..90bbca72 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeature.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeature.swift @@ -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 { diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeatureProviding.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeatureProviding.swift index 4733883d..6f5f216d 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeatureProviding.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/AppFeatureProviding.swift @@ -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 diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Donations.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Donations.swift index a8b3f92c..b91b48ed 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Donations.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Donations.swift @@ -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) } } diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Features.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Features.swift index df89471f..6dbbf9c7 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Features.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct+Features.swift @@ -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") diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct.swift index 7d557add..293c67e6 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/AppProduct.swift @@ -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.. InAppReceipt? } diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/AppUserLevel.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/AppUserLevel.swift index ae56edf9..6d0c7cc6 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/AppUserLevel.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/AppUserLevel.swift @@ -25,7 +25,7 @@ import Foundation -public enum AppUserLevel: Int { +public enum AppUserLevel: Int, Sendable { case undefined = -1 case freemium = 0 diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/FallbackReceiptReader.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/FallbackReceiptReader.swift new file mode 100644 index 00000000..c1b46254 --- /dev/null +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/FallbackReceiptReader.swift @@ -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 . +// + +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 + } +} diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift index ff7e344f..ce898414 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/IAPManager.swift @@ -23,6 +23,7 @@ // along with Passepartout. If not, see . // +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 @@ -47,10 +48,12 @@ public final class IAPManager: ObservableObject { private var eligibleFeatures: Set + private var subscriptions: Set + public init( customUserLevel: AppUserLevel? = nil, inAppHelper: any AppProductHelper, - receiptReader: any AppReceiptReader, + receiptReader: AppReceiptReader, unrestrictedFeatures: Set = [], productsAtBuild: BuildProducts? = 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) 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)") + } + } +} diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/KvittoReceiptReader.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/KvittoReceiptReader.swift deleted file mode 100644 index 1b60ae3b..00000000 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/KvittoReceiptReader.swift +++ /dev/null @@ -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 . -// - -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) - } -} diff --git a/Passepartout/Library/Sources/CommonLibrary/Mock/MockAppProductHelper.swift b/Passepartout/Library/Sources/CommonLibrary/Mock/MockAppProductHelper.swift index 9d165c5f..7bace863 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Mock/MockAppProductHelper.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Mock/MockAppProductHelper.swift @@ -23,6 +23,7 @@ // along with Passepartout. If not, see . // +import Combine import CommonUtils import Foundation @@ -33,18 +34,25 @@ public actor MockAppProductHelper: AppProductHelper { public nonisolated let receiptReader: MockAppReceiptReader + private let didUpdateSubject: PassthroughSubject + // 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 { + 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() } } diff --git a/Passepartout/Library/Sources/CommonLibrary/Mock/MockAppReceiptReader.swift b/Passepartout/Library/Sources/CommonLibrary/Mock/MockAppReceiptReader.swift index f49bf08f..4e162975 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Mock/MockAppReceiptReader.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Mock/MockAppReceiptReader.swift @@ -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 = []) { - receipt = InAppReceipt(originalBuildNumber: build, purchaseReceipts: products.map { + public func setReceipt(withBuild build: Int, products: Set, cancelledProducts: Set = []) { + setReceipt( + withBuild: build, + identifiers: Set(products.map(\.rawValue)), + cancelledIdentifiers: Set(cancelledProducts.map(\.rawValue)) + ) + } + + public func setReceipt(withBuild build: Int, identifiers: Set, cancelledIdentifiers: Set = []) { + 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 } } diff --git a/Passepartout/Library/Sources/CommonUtils/Business/StoreKitHelper.swift b/Passepartout/Library/Sources/CommonUtils/Business/StoreKitHelper.swift deleted file mode 100644 index c49ae1bc..00000000 --- a/Passepartout/Library/Sources/CommonUtils/Business/StoreKitHelper.swift +++ /dev/null @@ -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 . -// - -import Combine -import Foundation -import StoreKit - -@MainActor -public final class StoreKitHelper: 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 - - private var activeTransactions: Set - - private var observer: Task? - - 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 { - 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 = [] - for await entitlement in Transaction.currentEntitlements { - if let transaction = try? entitlement.payloadValue { - activeTransactions.insert(transaction) - } - } - self.activeTransactions = activeTransactions - } -} diff --git a/Passepartout/Library/Sources/CommonUtils/Business/BuildProducts.swift b/Passepartout/Library/Sources/CommonUtils/IAP/BuildProducts.swift similarity index 100% rename from Passepartout/Library/Sources/CommonUtils/Business/BuildProducts.swift rename to Passepartout/Library/Sources/CommonUtils/IAP/BuildProducts.swift diff --git a/Passepartout/Library/Sources/CommonUtils/Business/InApp.swift b/Passepartout/Library/Sources/CommonUtils/IAP/InApp.swift similarity index 77% rename from Passepartout/Library/Sources/CommonUtils/Business/InApp.swift rename to Passepartout/Library/Sources/CommonUtils/IAP/InApp.swift index 0d795621..cbdd87a3 100644 --- a/Passepartout/Library/Sources/CommonUtils/Business/InApp.swift +++ b/Passepartout/Library/Sources/CommonUtils/IAP/InApp.swift @@ -23,11 +23,14 @@ // along with Passepartout. If not, see . // +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 { 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? } diff --git a/Passepartout/Library/Sources/CommonUtils/IAP/KvittoReceiptReader.swift b/Passepartout/Library/Sources/CommonUtils/IAP/KvittoReceiptReader.swift new file mode 100644 index 00000000..65c3b0ad --- /dev/null +++ b/Passepartout/Library/Sources/CommonUtils/IAP/KvittoReceiptReader.swift @@ -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 . +// + +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 + ) + } +} diff --git a/Passepartout/Library/Sources/CommonUtils/IAP/StoreKitHelper.swift b/Passepartout/Library/Sources/CommonUtils/IAP/StoreKitHelper.swift new file mode 100644 index 00000000..d5b98d77 --- /dev/null +++ b/Passepartout/Library/Sources/CommonUtils/IAP/StoreKitHelper.swift @@ -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 . +// + +import Combine +import Foundation +import StoreKit + +@MainActor +public final class StoreKitHelper: 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 { + didSet { + didUpdateSubject.send() + } + } + + private let didUpdateSubject: PassthroughSubject + + private var observer: Task? + + 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 { + 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 { + 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 = [] + for await entitlement in Transaction.currentEntitlements { + if let transaction = try? entitlement.payloadValue { + activeTransactions.insert(transaction) + } + } + self.activeTransactions = activeTransactions + } +} diff --git a/Passepartout/Library/Sources/CommonUtils/IAP/StoreKitReceiptReader.swift b/Passepartout/Library/Sources/CommonUtils/IAP/StoreKitReceiptReader.swift new file mode 100644 index 00000000..f4eced1e --- /dev/null +++ b/Passepartout/Library/Sources/CommonUtils/IAP/StoreKitReceiptReader.swift @@ -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 . +// + +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) + } +} diff --git a/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift b/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift index 677ec528..1f867f12 100644 --- a/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift +++ b/Passepartout/Library/Sources/LegacyV2/CDProfileRepositoryV2.swift @@ -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 { diff --git a/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift b/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift index ab944c77..44a31273 100644 --- a/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift +++ b/Passepartout/Library/Sources/UILibrary/Business/AppContext.swift @@ -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 diff --git a/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift b/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift index 9055a52e..2e1220b6 100644 --- a/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift +++ b/Passepartout/Library/Sources/UILibrary/Mock/AppContext+Mock.swift @@ -34,7 +34,6 @@ extension AppContext { public static func mock(withRegistry registry: Registry) -> AppContext { let iapManager = IAPManager( - customUserLevel: nil, inAppHelper: MockAppProductHelper(), receiptReader: MockAppReceiptReader(), unrestrictedFeatures: [ diff --git a/Passepartout/Library/Tests/CommonLibraryTests/IAPManagerTests.swift b/Passepartout/Library/Tests/CommonLibraryTests/IAPManagerTests.swift index 29a99986..65ce4c46 100644 --- a/Passepartout/Library/Tests/CommonLibraryTests/IAPManagerTests.swift +++ b/Passepartout/Library/Tests/CommonLibraryTests/IAPManagerTests.swift @@ -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 = [], productsAtBuild: BuildProducts? = nil ) { diff --git a/Passepartout/Shared/Shared+App.swift b/Passepartout/Shared/Shared+App.swift index 8a295d1c..ab4954b8 100644 --- a/Passepartout/Shared/Shared+App.swift +++ b/Passepartout/Shared/Shared+App.swift @@ -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 = { #if os(iOS)