From f6361ebf06aecdb9253a58fe2c5dcdba2dd990bf Mon Sep 17 00:00:00 2001 From: Davide Date: Thu, 14 Nov 2024 19:12:51 +0100 Subject: [PATCH] Fix "Purchase required" in TestFlight (#870) - Define separate IAPManager instances for app and tunnel (different receipt URLs) - Copy app receipt URL over to tunnel before install/connect - Use AppTransaction to get original build number so that FallbackReceiptReader is also much simpler now Fixes #869 --- Passepartout.xcodeproj/project.pbxproj | 8 ++ .../Domain/BundleConfiguration+AppGroup.swift | 4 + .../CommonLibrary/Domain/Constants.swift | 2 + .../IAP/FallbackReceiptReader.swift | 59 ++------ .../CommonLibrary/Resources/Constants.json | 3 +- .../IAP/StoreKitReceiptReader.swift | 15 +- Passepartout/Shared/AppContext+Shared.swift | 2 +- Passepartout/Shared/Shared+App.swift | 135 ++++++++++++++++++ Passepartout/Shared/Shared+Tunnel.swift | 52 +++++++ Passepartout/Shared/Shared.swift | 90 +----------- .../Tunnel/PacketTunnelProvider.swift | 2 +- 11 files changed, 236 insertions(+), 136 deletions(-) create mode 100644 Passepartout/Shared/Shared+App.swift create mode 100644 Passepartout/Shared/Shared+Tunnel.swift diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index de94885d..76da3df9 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 0E3E22982CE53510005135DF /* AppUITV in Frameworks */ = {isa = PBXBuildFile; platformFilters = (tvos, ); productRef = 0E3E22972CE53510005135DF /* AppUITV */; }; 0E3FF4BA2CE3AFBC00BFF640 /* Profiles.sqlite in Resources */ = {isa = PBXBuildFile; fileRef = 0E3FF4B72CE3AFBC00BFF640 /* Profiles.sqlite */; }; 0E3FF4BB2CE3AFBC00BFF640 /* MigrationManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E3FF4B92CE3AFBC00BFF640 /* MigrationManagerTests.swift */; }; + 0E483E812CE64D6B00584B32 /* Shared+Tunnel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E483E7F2CE64D6B00584B32 /* Shared+Tunnel.swift */; }; + 0E483E842CE6501100584B32 /* Shared+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E483E822CE6501100584B32 /* Shared+App.swift */; }; 0E60512C2CE5393C00F763D4 /* PassepartoutImplementations in Frameworks */ = {isa = PBXBuildFile; productRef = 0E60512B2CE5393C00F763D4 /* PassepartoutImplementations */; }; 0E757F132CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E757F122CD0CFFC006E13E1 /* PassepartoutLoginItemApp.swift */; }; 0E757F202CD0D22B006E13E1 /* PassepartoutLoginItem.app in Embed Login Item */ = {isa = PBXBuildFile; fileRef = 0E757F102CD0CFFC006E13E1 /* PassepartoutLoginItem.app */; platformFilters = (macos, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -109,6 +111,8 @@ 0E3FF4AE2CE3AF6F00BFF640 /* PassepartoutTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PassepartoutTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 0E3FF4B72CE3AFBC00BFF640 /* Profiles.sqlite */ = {isa = PBXFileReference; lastKnownFileType = file; path = Profiles.sqlite; sourceTree = ""; }; 0E3FF4B92CE3AFBC00BFF640 /* MigrationManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MigrationManagerTests.swift; sourceTree = ""; }; + 0E483E7F2CE64D6B00584B32 /* Shared+Tunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Shared+Tunnel.swift"; sourceTree = ""; }; + 0E483E822CE6501100584B32 /* Shared+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Shared+App.swift"; sourceTree = ""; }; 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 = ""; }; @@ -279,6 +283,8 @@ children = ( 0EC797402B9378E000C093B7 /* AppContext+Shared.swift */, 0EC797412B9378E000C093B7 /* Shared.swift */, + 0E483E822CE6501100584B32 /* Shared+App.swift */, + 0E483E7F2CE64D6B00584B32 /* Shared+Tunnel.swift */, ); path = Shared; sourceTree = ""; @@ -546,6 +552,7 @@ 0E7E3D6B2B9345FD002BBDB4 /* PassepartoutApp.swift in Sources */, 0EC797422B9378E000C093B7 /* AppContext+Shared.swift in Sources */, 0EE8D7E12CD112C200F6600C /* App+tvOS.swift in Sources */, + 0E483E842CE6501100584B32 /* Shared+App.swift in Sources */, 0EC797432B9378E000C093B7 /* Shared.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -572,6 +579,7 @@ buildActionMask = 2147483647; files = ( 0E94EE582B93554B00588243 /* PacketTunnelProvider.swift in Sources */, + 0E483E812CE64D6B00584B32 /* Shared+Tunnel.swift in Sources */, 0EC797442B93790600C093B7 /* Shared.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+AppGroup.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+AppGroup.swift index 2b09b372..98c332b8 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+AppGroup.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/BundleConfiguration+AppGroup.swift @@ -36,6 +36,10 @@ extension BundleConfiguration { public static var urlForTunnelLog: URL { cachesURL.appending(path: Constants.shared.log.tunnelPath) } + + public static var urlForAppGroupReceipt: URL { + cachesURL.appending(path: Constants.shared.tunnel.appGroupReceiptPath) + } } private extension BundleConfiguration { diff --git a/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift b/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift index 090e8325..71dd109a 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Domain/Constants.swift @@ -97,6 +97,8 @@ public struct Constants: Decodable, Sendable { public let profileTitleFormat: String public let refreshInterval: TimeInterval + + public let appGroupReceiptPath: String } public struct API: Decodable, Sendable { diff --git a/Passepartout/Library/Sources/CommonLibrary/IAP/FallbackReceiptReader.swift b/Passepartout/Library/Sources/CommonLibrary/IAP/FallbackReceiptReader.swift index 11016ed6..02ef5219 100644 --- a/Passepartout/Library/Sources/CommonLibrary/IAP/FallbackReceiptReader.swift +++ b/Passepartout/Library/Sources/CommonLibrary/IAP/FallbackReceiptReader.swift @@ -29,18 +29,18 @@ import Kvitto import PassepartoutKit public actor FallbackReceiptReader: AppReceiptReader { - private let reader: InAppReceiptReader? + private let mainReader: InAppReceiptReader - private nonisolated let localReader: (URL) -> InAppReceiptReader? + private nonisolated let betaReader: InAppReceiptReader? private var pendingTask: Task? public init( - reader: (InAppReceiptReader & Sendable)?, - localReader: @escaping @Sendable (URL) -> InAppReceiptReader & Sendable + main mainReader: InAppReceiptReader & Sendable, + beta betaReader: (InAppReceiptReader & Sendable)? ) { - self.reader = reader - self.localReader = localReader + self.mainReader = mainReader + self.betaReader = betaReader } public func receipt(at userLevel: AppUserLevel) async -> InAppReceipt? { @@ -58,49 +58,12 @@ public actor FallbackReceiptReader: AppReceiptReader { private extension FallbackReceiptReader { func asyncReceipt(at userLevel: AppUserLevel) async -> InAppReceipt? { - let localURL = Bundle.main.appStoreReceiptURL - pp_log(.App.iap, .debug, "\tParse receipt for user level \(userLevel)") - - // 1. TestFlight, look for release receipt - let releaseReceipt: InAppReceipt? = await { - guard userLevel == .beta, let localURL else { - return nil - } - pp_log(.App.iap, .debug, "\tTestFlight, look for release receipt") - let releaseURL = localURL - .deletingLastPathComponent() - .appendingPathComponent("receipt") - - let release = localReader(releaseURL) - return await release?.receipt() - }() - - if let releaseReceipt { - pp_log(.App.iap, .debug, "\tTestFlight, return release receipt") - return releaseReceipt + if userLevel == .beta, let betaReader { + pp_log(.App.iap, .debug, "\tTestFlight, read beta receipt") + return await betaReader.receipt() } - - let localReceiptBlock: () async -> InAppReceipt? = { [weak self] in - guard let localURL, let local = self?.localReader(localURL) else { - return nil - } - return await local.receipt() - } - - // 2. primary receipt + build from local receipt - pp_log(.App.iap, .debug, "\tNo release receipt, read primary receipt") - if let receipt = await reader?.receipt() { - if let build = await localReceiptBlock()?.originalBuildNumber { - pp_log(.App.iap, .debug, "\tReturn primary receipt with local build: \(build)") - return receipt.withBuildNumber(build) - } - pp_log(.App.iap, .debug, "\tReturn primary receipt without local build") - return receipt - } - - // 3. fall back to local receipt - pp_log(.App.iap, .debug, "\tReturn local receipt") - return await localReceiptBlock() + pp_log(.App.iap, .debug, "\tProduction, read main receipt") + return await mainReader.receipt() } } diff --git a/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json b/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json index 3def3bdc..d2cae28b 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json +++ b/Passepartout/Library/Sources/CommonLibrary/Resources/Constants.json @@ -23,7 +23,8 @@ }, "tunnel": { "profileTitleFormat": "Passepartout: %@", - "refreshInterval": 3.0 + "refreshInterval": 3.0, + "appGroupReceiptPath": "app-group-receipt" }, "api": { "timeoutInterval": 5.0 diff --git a/Passepartout/Library/Sources/CommonUtils/IAP/StoreKitReceiptReader.swift b/Passepartout/Library/Sources/CommonUtils/IAP/StoreKitReceiptReader.swift index f4eced1e..b0fe556b 100644 --- a/Passepartout/Library/Sources/CommonUtils/IAP/StoreKitReceiptReader.swift +++ b/Passepartout/Library/Sources/CommonUtils/IAP/StoreKitReceiptReader.swift @@ -31,6 +31,19 @@ public final class StoreKitReceiptReader: InAppReceiptReader, Sendable { } public func receipt() async -> InAppReceipt? { + let originalBuildNumber: Int? + do { + switch try await AppTransaction.shared { + case .verified(let tx): + originalBuildNumber = Int(tx.originalAppVersion) + + default: + originalBuildNumber = nil + } + } catch { + originalBuildNumber = nil + } + var transactions: [Transaction] = [] for await entitlement in Transaction.currentEntitlements { switch entitlement { @@ -51,6 +64,6 @@ public final class StoreKitReceiptReader: InAppReceiptReader, Sendable { ) } - return InAppReceipt(originalBuildNumber: nil, purchaseReceipts: purchaseReceipts) + return InAppReceipt(originalBuildNumber: originalBuildNumber, purchaseReceipts: purchaseReceipts) } } diff --git a/Passepartout/Shared/AppContext+Shared.swift b/Passepartout/Shared/AppContext+Shared.swift index 4b7f304b..de5bdb7d 100644 --- a/Passepartout/Shared/AppContext+Shared.swift +++ b/Passepartout/Shared/AppContext+Shared.swift @@ -112,7 +112,7 @@ extension AppContext { #endif return AppContext( - iapManager: .shared, + iapManager: .sharedForApp, migrationManager: migrationManager, profileManager: profileManager, providerManager: providerManager, diff --git a/Passepartout/Shared/Shared+App.swift b/Passepartout/Shared/Shared+App.swift new file mode 100644 index 00000000..1c39ad02 --- /dev/null +++ b/Passepartout/Shared/Shared+App.swift @@ -0,0 +1,135 @@ +// +// Shared+App.swift +// Passepartout +// +// Created by Davide De Rosa on 11/14/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 CommonLibrary +import CommonUtils +import Foundation +import PassepartoutKit + +extension IAPManager { + static let sharedForApp = IAPManager( + customUserLevel: Configuration.Environment.userLevel, + inAppHelper: Configuration.IAPManager.inAppHelper, + receiptReader: Configuration.IAPManager.appReceiptReader, + productsAtBuild: Configuration.IAPManager.productsAtBuild + ) + + static let sharedProcessor = ProfileProcessor( + iapManager: sharedForApp, + title: { + Configuration.ProfileManager.sharedTitle($0) + }, + isIncluded: { + Configuration.ProfileManager.isIncluded($0, $1) + }, + willSave: { + $1 + }, + willConnect: { iap, profile in + var builder = profile.builder() + + // copy app receipt URL to tunnel for beta eligibility + if let appReceiptURL = Bundle.main.appStoreReceiptURL { + let tunnelReceiptURL = BundleConfiguration.urlForAppGroupReceipt + do { + try FileManager.default.removeItem(at: tunnelReceiptURL) + try FileManager.default.copyItem(at: appReceiptURL, to: tunnelReceiptURL) + } catch { + pp_log(.App.iap, .error, "Unable to copy receipt URL to tunnel: \(error)") + } + } + + // ineligible, suppress on-demand rules + if !iap.isEligible(for: .onDemand) { + pp_log(.App.iap, .notice, "Ineligible, suppress on-demand rules") + + if let onDemandModuleIndex = builder.modules.firstIndex(where: { $0 is OnDemandModule }), + let onDemandModule = builder.modules[onDemandModuleIndex] as? OnDemandModule { + + var onDemandBuilder = onDemandModule.builder() + onDemandBuilder.policy = .any + builder.modules[onDemandModuleIndex] = onDemandBuilder.tryBuild() + } + } + + // validate provider modules + let profile = try builder.tryBuild() + do { + _ = try profile.withProviderModules() + return profile + } catch { + pp_log(.app, .error, "Unable to inject provider modules: \(error)") + throw error + } + } + ) +} + +// MARK: - Configuration + +private extension Configuration.Environment { + 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: .userLevel), + let testAppType = AppUserLevel(rawValue: infoValue) { + + return testAppType + } + return nil + } +} + +private extension Configuration.IAPManager { + + @MainActor + static var appReceiptReader: AppReceiptReader { + guard !Configuration.Environment.isFakeIAP else { + guard let mockHelper = inAppHelper as? MockAppProductHelper else { + fatalError("When .isFakeIAP, productHelper is expected to be MockAppProductHelper") + } + return mockHelper.receiptReader + } + return FallbackReceiptReader( + main: StoreKitReceiptReader(), + beta: releaseReceiptURL.map { + KvittoReceiptReader(url: $0) + } + ) + } + + static var releaseReceiptURL: URL? { + guard let url = Bundle.main.appStoreReceiptURL else { + return nil + } + return url + .deletingLastPathComponent() + .appendingPathComponent("receipt") // release receipt + } +} diff --git a/Passepartout/Shared/Shared+Tunnel.swift b/Passepartout/Shared/Shared+Tunnel.swift new file mode 100644 index 00000000..bdf60a92 --- /dev/null +++ b/Passepartout/Shared/Shared+Tunnel.swift @@ -0,0 +1,52 @@ +// +// Shared+Tunnel.swift +// Passepartout +// +// Created by Davide De Rosa on 11/14/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 CommonLibrary +import CommonUtils +import Foundation +import PassepartoutKit + +extension IAPManager { + static let sharedForTunnel = IAPManager( + inAppHelper: Configuration.IAPManager.inAppHelper, + receiptReader: Configuration.IAPManager.tunnelReceiptReader, + productsAtBuild: Configuration.IAPManager.productsAtBuild + ) +} + +private extension Configuration.IAPManager { + + @MainActor + static var tunnelReceiptReader: AppReceiptReader { + FallbackReceiptReader( + main: StoreKitReceiptReader(), + beta: KvittoReceiptReader(url: appGroupReceiptURL) + ) + } + + static var appGroupReceiptURL: URL { + BundleConfiguration.urlForAppGroupReceipt // copied by ProfileProcessor + } +} diff --git a/Passepartout/Shared/Shared.swift b/Passepartout/Shared/Shared.swift index 73236c50..6045f9c8 100644 --- a/Passepartout/Shared/Shared.swift +++ b/Passepartout/Shared/Shared.swift @@ -89,59 +89,6 @@ extension TunnelEnvironment where Self == AppGroupEnvironment { } } -// MARK: IAPManager - -extension IAPManager { - static let shared: IAPManager = { - let iapHelpers = Configuration.IAPManager.helpers - return IAPManager( - customUserLevel: Configuration.Environment.userLevel, - inAppHelper: iapHelpers.productHelper, - receiptReader: iapHelpers.receiptReader, - productsAtBuild: Configuration.IAPManager.productsAtBuild - ) - }() - - static let sharedProcessor = ProfileProcessor( - iapManager: shared, - title: { - Configuration.ProfileManager.sharedTitle($0) - }, - isIncluded: { - Configuration.ProfileManager.isIncluded($0, $1) - }, - willSave: { - $1 - }, - willConnect: { iap, profile in - var builder = profile.builder() - - // ineligible, suppress on-demand rules - if !iap.isEligible(for: .onDemand) { - pp_log(.app, .notice, "Ineligible, suppress on-demand rules") - - if let onDemandModuleIndex = builder.modules.firstIndex(where: { $0 is OnDemandModule }), - let onDemandModule = builder.modules[onDemandModuleIndex] as? OnDemandModule { - - var onDemandBuilder = onDemandModule.builder() - onDemandBuilder.policy = .any - builder.modules[onDemandModuleIndex] = onDemandBuilder.tryBuild() - } - } - - // validate provider modules - let profile = try builder.tryBuild() - do { - _ = try profile.withProviderModules() - return profile - } catch { - pp_log(.app, .error, "Unable to inject provider modules: \(error)") - throw error - } - } - ) -} - // MARK: - Configuration enum Configuration { @@ -155,27 +102,10 @@ enum Configuration { } } -// MARK: Environment - -private extension Configuration.Environment { +extension Configuration.Environment { static var isFakeIAP: Bool { ProcessInfo.processInfo.environment["PP_FAKE_IAP"] == "1" } - - 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: .userLevel), - let testAppType = AppUserLevel(rawValue: infoValue) { - - return testAppType - } - return nil - } } // MARK: ProfileManager @@ -202,29 +132,21 @@ extension Configuration.ProfileManager { // MARK: IAPManager -private extension Configuration.IAPManager { +extension Configuration.IAPManager { @MainActor - static var helpers: (productHelper: any AppProductHelper, receiptReader: AppReceiptReader) { + static let inAppHelper: any AppProductHelper = { guard !Configuration.Environment.isFakeIAP else { - let mockHelper = MockAppProductHelper() - return (mockHelper, mockHelper.receiptReader) + return MockAppProductHelper() } - let productHelper = StoreKitHelper( + return 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) diff --git a/Passepartout/Tunnel/PacketTunnelProvider.swift b/Passepartout/Tunnel/PacketTunnelProvider.swift index fb83b3e4..b72ebbc1 100644 --- a/Passepartout/Tunnel/PacketTunnelProvider.swift +++ b/Passepartout/Tunnel/PacketTunnelProvider.swift @@ -81,7 +81,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { @MainActor private extension PacketTunnelProvider { var iapManager: IAPManager { - .shared + .sharedForTunnel } var isEligibleForPlatform: Bool {