Skip to content
Merged
55 changes: 43 additions & 12 deletions app-apple/Package/Sources/AppLibrary/Business/AppContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,16 @@ public final class AppContext: ObservableObject, Sendable {

public let webReceiverManager: WebReceiverManager

private let receiptInvalidationInterval: TimeInterval

private let onEligibleFeaturesBlock: ((Set<AppFeature>) async -> Void)?

private var launchTask: Task<Void, Error>?

private var pendingTask: Task<Void, Never>?

private var didLoadReceiptDate: Date?

private var subscriptions: Set<AnyCancellable>

public init(
Expand All @@ -66,6 +70,7 @@ public final class AppContext: ObservableObject, Sendable {
tunnel: ExtendedTunnel,
versionChecker: VersionChecker? = nil,
webReceiverManager: WebReceiverManager,
receiptInvalidationInterval: TimeInterval = 30.0,
onEligibleFeaturesBlock: ((Set<AppFeature>) async -> Void)? = nil
) {
self.apiManager = apiManager
Expand All @@ -84,7 +89,9 @@ public final class AppContext: ObservableObject, Sendable {
self.tunnel = tunnel
self.versionChecker = versionChecker ?? VersionChecker()
self.webReceiverManager = webReceiverManager
self.receiptInvalidationInterval = receiptInvalidationInterval
self.onEligibleFeaturesBlock = onEligibleFeaturesBlock
didLoadReceiptDate = nil
subscriptions = []
}
}
Expand All @@ -95,13 +102,13 @@ public final class AppContext: ObservableObject, Sendable {
extension AppContext {
public func onApplicationActive() {
Task {
// XXX: should handle AppError.couldNotLaunch (although extremely rare)
// XXX: Should handle AppError.couldNotLaunch (although extremely rare)
try await onForeground()

await configManager.refreshBundle()
await versionChecker.checkLatestRelease()

// use NESocket in tunnel
// Use NESocket in tunnel if .neSocket ConfigFlag is active
let shouldUseNESocket = configManager.isActive(.neSocket)
kvManager.set(shouldUseNESocket, forKey: AppPreference.usesNESocket.key)
}
Expand All @@ -119,9 +126,10 @@ private extension AppContext {
pp_log_g(.App.profiles, .info, "\tObserve in-app events...")
iapManager.observeObjects(withProducts: true)

// defer loads
// Defer loads to not block app launch
Task {
await iapManager.reloadReceipt()
didLoadReceiptDate = Date()
}
Task {
await reloadSystemExtension()
Expand All @@ -136,6 +144,7 @@ private extension AppContext {
self?.kvManager.set(!$0, forKey: AppPreference.skipsPurchases.key)
Task {
await self?.iapManager.reloadReceipt()
self?.didLoadReceiptDate = Date()
}
}
.store(in: &subscriptions)
Expand Down Expand Up @@ -177,15 +186,22 @@ private extension AppContext {
}

func onForeground() async throws {

// onForeground() is redundant after launch
let didLaunch = try await waitForTasks()
guard !didLaunch else {
return // foreground is redundant after launch
return
}

pp_log_g(.app, .notice, "Application did enter foreground")
pendingTask = Task {
await reloadSystemExtension()
await iapManager.reloadReceipt()

// Do not reload the receipt unconditionally
if shouldInvalidateReceipt {
await iapManager.reloadReceipt()
self.didLoadReceiptDate = Date()
}
}
await pendingTask?.value
pendingTask = nil
Expand Down Expand Up @@ -248,24 +264,24 @@ private extension AppContext {
func waitForTasks() async throws -> Bool {
var didLaunch = false

// must launch once before anything else
// Require launch task to complete before performing anything else
if launchTask == nil {
launchTask = Task {
do {
try await onLaunch()
} catch {
launchTask = nil // redo launch
launchTask = nil // Redo the launch task
throw AppError.couldNotLaunch(reason: error)
}
}
didLaunch = true
}

// will throw on .couldNotLaunch
// next wait will re-attempt launch (launchTask == nil)
// Will throw on .couldNotLaunch, and the next await
// will re-attempt launch because launchTask == nil
try await launchTask?.value

// wait for pending task if any
// Wait for pending task if any
await pendingTask?.value
pendingTask = nil

Expand All @@ -284,17 +300,32 @@ private extension AppContext {
pp_log_g(.app, .error, "System Extension: load error: \(error)")
}
}

var shouldInvalidateReceipt: Bool {
// Receipt never loaded, force load
guard let didLoadReceiptDate else {
return true
}
// Always force a reload if purchased products are
// empty, because StoreKit may fail silently at times
if iapManager.purchasedProducts.isEmpty {
return true
}
// Must have elapsed more than invalidation period
let elapsed = -didLoadReceiptDate.timeIntervalSinceNow
return elapsed >= receiptInvalidationInterval
}
}

extension Collection where Element == Profile.DiffResult {
func isRelevantForReconnecting(to profile: Profile) -> Bool {
contains {
switch $0 {
case .changedName:
// profile renamed
// Do not reconnect on profile rename
return false
case .changedModules(let ids):
// only changed on-demand module
// Do not reconnect if only an on-demand module was changed
if ids.count == 1, let onlyID = ids.first,
profile.module(withId: onlyID) is OnDemandModule {
return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public actor FakeAppProductHelper: AppProductHelper {
didUpdateSubject.eraseToAnyPublisher()
}

public func fetchProducts(timeout: Int) async throws -> [AppProduct: InAppProduct] {
public func fetchProducts(timeout: TimeInterval) async throws -> [AppProduct: InAppProduct] {
products = AppProduct.all.reduce(into: [:]) {
$0[$1] = $1.asFakeIAP
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ public struct Constants: Decodable, Sendable {
public let delay: TimeInterval

public let interval: TimeInterval

public let attempts: Int

public let retryInterval: TimeInterval
}

public let production: Parameters
Expand Down Expand Up @@ -149,7 +153,9 @@ public struct Constants: Decodable, Sendable {
}

public struct IAP: Decodable, Sendable {
public let productsTimeoutInterval: Int
public let productsTimeoutInterval: TimeInterval

public let receiptInvalidationInterval: TimeInterval
}

public struct WebReceiver: Decodable, Sendable {
Expand Down
11 changes: 8 additions & 3 deletions app-apple/Package/Sources/CommonLibrary/Resources/Constants.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,15 @@
"verification": {
"production": {
"delay": 120.0,
"interval": 3600.0
"interval": 21600.0,
"attempts": 3,
"retryInterval": 300.0
},
"beta": {
"delay": 600.0,
"interval": 600.0
"interval": 600.0,
"attempts": 3,
"retryInterval": 10.0
}
}
},
Expand All @@ -57,7 +61,8 @@
"refreshInfrastructureRateLimit": 86400.0
},
"iap": {
"productsTimeoutInterval": 10.0
"productsTimeoutInterval": 10.0,
"receiptInvalidationInterval": 600.0
},
"webReceiver": {
"port": 10000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Foundation
public struct TaskTimeoutError: Error {
}

public func performTask<T>(withTimeout timeout: Int, taskBlock: @escaping () async throws -> T) async throws -> T {
public func performTask<T>(withTimeout timeout: TimeInterval, taskBlock: @escaping () async throws -> T) async throws -> T {
let task = Task {
let taskResult = try await taskBlock()
try Task.checkCancellation()
Expand Down
2 changes: 1 addition & 1 deletion app-apple/Package/Sources/CommonUtils/IAP/InApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public protocol InAppHelper {

var didUpdate: AnyPublisher<Void, Never> { get }

func fetchProducts(timeout: Int) async throws -> [ProductType: InAppProduct]
func fetchProducts(timeout: TimeInterval) async throws -> [ProductType: InAppProduct]

func purchase(_ inAppProduct: InAppProduct) async throws -> InAppPurchaseResult

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ extension StoreKitHelper {
didUpdateSubject.eraseToAnyPublisher()
}

public func fetchProducts(timeout: Int) async throws -> [ProductType: InAppProduct] {
public func fetchProducts(timeout: TimeInterval) async throws -> [ProductType: InAppProduct] {
let skProducts = try await performTask(withTimeout: timeout) {
try await Product.products(for: self.products.map(self.inAppIdentifier))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ extension AppContext {
tunnel: tunnel,
versionChecker: versionChecker,
webReceiverManager: webReceiverManager,
receiptInvalidationInterval: constants.iap.receiptInvalidationInterval,
onEligibleFeaturesBlock: onEligibleFeaturesBlock
)
}
Expand Down
27 changes: 22 additions & 5 deletions app-apple/Passepartout/Tunnel/PacketTunnelProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
of: originalProfile,
iapManager: iapManager,
environment: environment,
interval: params.interval
params: params
)
}
} catch {
Expand Down Expand Up @@ -261,24 +261,38 @@ private extension PacketTunnelProvider {
// MARK: - Eligibility

private extension PacketTunnelProvider {

@MainActor
func verifyEligibility(
of profile: Profile,
iapManager: IAPManager,
environment: TunnelEnvironment,
interval: TimeInterval
params: Constants.Tunnel.Verification.Parameters
) async {
guard let ctx else {
fatalError("Forgot to set ctx?")
}
var attempts = params.attempts
while true {
guard !Task.isCancelled else {
return
}
do {
pp_log(ctx, .app, .info, "Verify profile, requires: \(profile.features)")
await iapManager.reloadReceipt()
try await iapManager.verify(profile)
try iapManager.verify(profile)
} catch {

// mitigate the StoreKit inability to report errors, sometimes it
// would just return empty products, e.g. on network failure. in those
// cases, retry a few times before failing
if attempts > 0 {
attempts -= 1
pp_log(ctx, .app, .error, "Verification failed for profile \(profile.id), next attempt in \(params.retryInterval) seconds... (remaining: \(attempts), products: \(iapManager.purchasedProducts))")
try? await Task.sleep(interval: params.retryInterval)
continue
}

let error = PartoutError(.App.ineligibleProfile)
environment.setEnvironmentValue(error.code, forKey: TunnelEnvironmentKeys.lastErrorCode)
pp_log(ctx, .app, .fault, "Verification failed for profile \(profile.id), shutting down: \(error)")
Expand All @@ -289,8 +303,11 @@ private extension PacketTunnelProvider {
return
}

pp_log(ctx, .app, .info, "Will verify profile again in \(interval) seconds...")
try? await Task.sleep(interval: interval)
pp_log(ctx, .app, .info, "Will verify profile again in \(params.interval) seconds...")
try? await Task.sleep(interval: params.interval)

// reset attempts for next verification
attempts = params.attempts
}
}
}
Expand Down