mirror of
https://github.com/passepartoutvpn/passepartout-apple.git
synced 2025-02-23 08:12:37 +00:00
#1070 is very tricky. When the device boots, StoreKit operations seem to be severely affected by on-demand VPN profiles. Slowdowns are huge and unpredictable, as per my [report on the Apple forums](https://developer.apple.com/forums/thread/773723). I found no easy way to work around the chicken-and-egg situation where the VPN requires StoreKit validation to start, but StoreKit requires network access. On the other hand, without StoreKit validations, the on-demand tunnel starts on boot just fine, and so does the app. No eternal activity indicators. StoreKit is clearly the culprit here. Therefore, below is the strategy that this PR implements for a decent trade-off: - Configure a graceful period for the VPN to start without limitations. This is initially set to 2 minutes in production, and 10 minutes in TestFlight. Postpone StoreKit validation until then. - After the graceful period, StoreKit validation is more likely to complete fast - At this point, paying users have their receipts validated and the connection will silently keep going - Non-paying users, instead, will see their connection hit the "Purchase required" message On the UI side, adjust the app accordingly: - Drop the "Purchase required" icon from the list/grid of profiles - The paywall informs that the connection will start, but it will disconnect after the graceful period if the receipt is not valid - Add a note that receipt validation may take a while if the device has just started This PR also introduces changes in TestFlight behavior: - Profiles can be saved without limitations - Profiles using free features work as usual - Profiles using paid features work for 10 minutes - Eligibility based on local receipt is ignored (deprecated in iOS 18) Beta users may therefore test all paid features on iOS/macOS/tvOS for 10 minutes. Until now, paid features were only available to paying iOS users and unavailable on macOS/tvOS. The tvOS beta was, in fact, completely useless. The downside is that paying iOS users will see beta builds restricted like anybody else. I'll see if I can find a better solution later.
228 lines
6.9 KiB
Swift
228 lines
6.9 KiB
Swift
//
|
|
// AppContext.swift
|
|
// Passepartout
|
|
//
|
|
// Created by Davide De Rosa on 8/29/24.
|
|
// Copyright (c) 2025 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 CommonLibrary
|
|
import CommonUtils
|
|
import Foundation
|
|
import PassepartoutKit
|
|
import UIAccessibility
|
|
|
|
@MainActor
|
|
public final class AppContext: ObservableObject, Sendable {
|
|
public let apiManager: APIManager
|
|
|
|
public let iapManager: IAPManager
|
|
|
|
public let migrationManager: MigrationManager
|
|
|
|
public let preferencesManager: PreferencesManager
|
|
|
|
public let profileManager: ProfileManager
|
|
|
|
public let registry: Registry
|
|
|
|
public let tunnel: ExtendedTunnel
|
|
|
|
private let onEligibleFeaturesBlock: ((Set<AppFeature>) async -> Void)?
|
|
|
|
private var launchTask: Task<Void, Error>?
|
|
|
|
private var pendingTask: Task<Void, Never>?
|
|
|
|
private var subscriptions: Set<AnyCancellable>
|
|
|
|
public init(
|
|
apiManager: APIManager,
|
|
iapManager: IAPManager,
|
|
migrationManager: MigrationManager,
|
|
preferencesManager: PreferencesManager,
|
|
profileManager: ProfileManager,
|
|
registry: Registry,
|
|
tunnel: ExtendedTunnel,
|
|
onEligibleFeaturesBlock: ((Set<AppFeature>) async -> Void)? = nil
|
|
) {
|
|
self.apiManager = apiManager
|
|
self.iapManager = iapManager
|
|
self.migrationManager = migrationManager
|
|
self.preferencesManager = preferencesManager
|
|
self.profileManager = profileManager
|
|
self.registry = registry
|
|
self.tunnel = tunnel
|
|
self.onEligibleFeaturesBlock = onEligibleFeaturesBlock
|
|
subscriptions = []
|
|
}
|
|
}
|
|
|
|
// MARK: - Observation
|
|
|
|
// invoked by AppDelegate
|
|
extension AppContext {
|
|
public func onApplicationActive() {
|
|
Task {
|
|
// TODO: ###, should handle AppError.couldNotLaunch (although extremely rare)
|
|
try await onForeground()
|
|
}
|
|
}
|
|
}
|
|
|
|
// invoked on internal events
|
|
private extension AppContext {
|
|
func onLaunch() async throws {
|
|
pp_log(.app, .notice, "Application did launch")
|
|
|
|
pp_log(.App.profiles, .info, "\tRead and observe local profiles...")
|
|
try await profileManager.observeLocal()
|
|
|
|
pp_log(.App.profiles, .info, "\tObserve in-app events...")
|
|
iapManager.observeObjects(withProducts: true)
|
|
|
|
// defer load receipt
|
|
Task {
|
|
await iapManager.reloadReceipt()
|
|
}
|
|
|
|
pp_log(.App.profiles, .info, "\tObserve eligible features...")
|
|
iapManager
|
|
.$eligibleFeatures
|
|
.dropFirst()
|
|
.removeDuplicates()
|
|
.sink { [weak self] eligible in
|
|
Task {
|
|
try await self?.onEligibleFeatures(eligible)
|
|
}
|
|
}
|
|
.store(in: &subscriptions)
|
|
|
|
pp_log(.App.profiles, .info, "\tObserve changes in ProfileManager...")
|
|
profileManager
|
|
.didChange
|
|
.sink { [weak self] event in
|
|
switch event {
|
|
case .save(let profile):
|
|
Task {
|
|
try await self?.onSaveProfile(profile)
|
|
}
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
.store(in: &subscriptions)
|
|
|
|
do {
|
|
pp_log(.app, .info, "\tFetch providers index...")
|
|
try await apiManager.fetchIndex(from: API.shared)
|
|
} catch {
|
|
pp_log(.app, .error, "\tUnable to fetch providers index: \(error)")
|
|
}
|
|
}
|
|
|
|
func onForeground() async throws {
|
|
let didLaunch = try await waitForTasks()
|
|
guard !didLaunch else {
|
|
return // foreground is redundant after launch
|
|
}
|
|
|
|
pp_log(.app, .notice, "Application did enter foreground")
|
|
pendingTask = Task {
|
|
await iapManager.reloadReceipt()
|
|
}
|
|
await pendingTask?.value
|
|
pendingTask = nil
|
|
}
|
|
|
|
func onEligibleFeatures(_ features: Set<AppFeature>) async throws {
|
|
try await waitForTasks()
|
|
|
|
pp_log(.app, .notice, "Application did update eligible features")
|
|
pendingTask = Task {
|
|
await onEligibleFeaturesBlock?(features)
|
|
}
|
|
await pendingTask?.value
|
|
pendingTask = nil
|
|
}
|
|
|
|
func onSaveProfile(_ profile: Profile) async throws {
|
|
try await waitForTasks()
|
|
|
|
pp_log(.app, .notice, "Application did save profile (\(profile.id))")
|
|
guard profile.id == tunnel.currentProfile?.id else {
|
|
pp_log(.app, .debug, "\tProfile \(profile.id) is not current, do nothing")
|
|
return
|
|
}
|
|
guard [.active, .activating].contains(tunnel.status) else {
|
|
pp_log(.app, .debug, "\tConnection is not active (\(tunnel.status)), do nothing")
|
|
return
|
|
}
|
|
pendingTask = Task {
|
|
do {
|
|
do {
|
|
pp_log(.app, .info, "\tReconnect profile \(profile.id)")
|
|
try await tunnel.connect(with: profile)
|
|
} catch AppError.interactiveLogin {
|
|
pp_log(.app, .info, "\tProfile \(profile.id) is interactive, disconnect")
|
|
try await tunnel.disconnect()
|
|
} catch {
|
|
pp_log(.app, .error, "\tUnable to reconnect profile \(profile.id), disconnect: \(error)")
|
|
try await tunnel.disconnect()
|
|
}
|
|
} catch {
|
|
pp_log(.app, .error, "\tUnable to reinstate connection on save profile \(profile.id): \(error)")
|
|
}
|
|
}
|
|
await pendingTask?.value
|
|
pendingTask = nil
|
|
}
|
|
|
|
@discardableResult
|
|
func waitForTasks() async throws -> Bool {
|
|
var didLaunch = false
|
|
|
|
// must launch once before anything else
|
|
if launchTask == nil {
|
|
launchTask = Task {
|
|
do {
|
|
try await onLaunch()
|
|
} catch {
|
|
launchTask = nil // redo launch
|
|
throw AppError.couldNotLaunch(reason: error)
|
|
}
|
|
}
|
|
didLaunch = true
|
|
}
|
|
|
|
// will throw on .couldNotLaunch
|
|
// next wait will re-attempt launch (launchTask == nil)
|
|
try await launchTask?.value
|
|
|
|
// wait for pending task if any
|
|
await pendingTask?.value
|
|
pendingTask = nil
|
|
|
|
return didLaunch
|
|
}
|
|
}
|