// // VerifyAppPledgeOperation.swift // AltStore // // Created by Riley Testut on 12/6/23. // Copyright © 2023 Riley Testut. All rights reserved. // import Combine import AltStoreCore class VerifyAppPledgeOperation: ResultOperation { @AsyncManaged private(set) var storeApp: StoreApp private let presentingViewController: UIViewController? private var openPatreonPageContinuation: CheckedContinuation? private var cancellable: AnyCancellable? init(storeApp: StoreApp, presentingViewController: UIViewController?) { self.storeApp = storeApp self.presentingViewController = presentingViewController } override func main() { super.main() // _Don't_ rethrow earlier errors, or else user will only be taken to Patreon post if connected to same Wi-Fi as AltServer. // if let error = self.context.error // { // self.finish(.failure(error)) // return // } Task.detached(priority: .medium) { do { guard await self.$storeApp.isPledgeRequired else { return self.finish(.success(())) } if let presentingViewController = self.presentingViewController { // Ask user to connect Patreon account if they are signed-in to Patreon inside WebViewController, but haven't yet signed in through AltStore settings. // This is most likely because the user joined a Patreon campaign directly through WebViewController before connecting Patreon account in settings. try await self.connectPatreonAccountIfNeeded(presentingViewController: presentingViewController) } do { try await self.verifyPledge() } catch let error as OperationError where error.code == .pledgeRequired || error.code == .pledgeInactive { guard let presentingViewController = self.presentingViewController, let source = await self.$storeApp.source, let patreonURL = await self.$storeApp.perform({ _ in source.patreonURL }) else { throw error } let components = URLComponents(url: patreonURL, resolvingAgainstBaseURL: false) let lastPathComponent = components?.path.components(separatedBy: "/").last let username = lastPathComponent ?? patreonURL.lastPathComponent let checkoutURL: URL if await self.$storeApp.prefersCustomPledge, let customPledgeURL = URL(string: "https://www.patreon.com/checkout/" + username + "?rid=0&custom=1") { checkoutURL = customPledgeURL let action = await UIAlertAction(title: NSLocalizedString("Continue", comment: ""), style: .default) try await presentingViewController.presentConfirmationAlert(title: NSLocalizedString("Custom Pledge", comment: ""), message: NSLocalizedString("This app supports custom pledges. Pledge any amount on Patreon to receive access.", comment: ""), primaryAction: action) } else if !username.isEmpty, let url = URL(string: "https://www.patreon.com/join/" + username) { // Prefer /join URL over campaign homepage. // URL format from https://support.patreon.com/hc/en-us/articles/360044376211-Managing-members-with-custom-pledges checkoutURL = url } else { checkoutURL = patreonURL } // Direct user to Patreon page if they're not already pledged. await self.openPatreonPage(checkoutURL, presentingViewController: presentingViewController) let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() if let patreonAccount = await context.performAsync({ DatabaseManager.shared.patreonAccount(in: context) }) { // Patreon account is connected, so we'll update it via API to see if pledges changed. // If so, we'll re-fetch the source to update pledge statuses. try await self.updatePledges(for: source, account: patreonAccount) } else { // Patreon account is not connected, so prompt user to connect it. try await self.connectPatreonAccountIfNeeded(presentingViewController: presentingViewController) } do { try await self.verifyPledge() } catch { // Ignore error, but cancel remainder of operation. throw CancellationError() } } self.finish(.success(())) } catch { self.finish(.failure(error)) } } } } private extension VerifyAppPledgeOperation { func verifyPledge() async throws { let (appName, isPledged) = await self.$storeApp.perform { ($0.name, $0.isPledged) } if !PatreonAPI.shared.isAuthenticated || !isPledged { let isInstalled = await self.$storeApp.installedApp != nil if isInstalled { // Assume if there is an InstalledApp, the user had previously pledged to this app. throw OperationError.pledgeInactive(appName: appName) } else { throw OperationError.pledgeRequired(appName: appName) } } } func connectPatreonAccountIfNeeded(presentingViewController: UIViewController) async throws { guard !PatreonAPI.shared.isAuthenticated, let authCookie = PatreonAPI.shared.authCookies.first(where: { $0.name.lowercased() == "session_id" }) else { return } Logger.sideload.debug("Patreon Auth cookie: \(authCookie.name)=\(authCookie.value)") let message = NSLocalizedString("You're signed into Patreon but haven't connected your account with AltStore.\n\nPlease connect your account to download Patreon-exclusive apps.", comment: "") let action = await UIAlertAction(title: NSLocalizedString("Connect Patreon Account", comment: ""), style: .default) do { _ = try await presentingViewController.presentConfirmationAlert(title: NSLocalizedString("Patreon Account Detected", comment: ""), message: message, actions: [action]) } catch { // Ignore and continue return } try await withCheckedThrowingContinuation { continuation in PatreonAPI.shared.authenticate(presentingViewController: presentingViewController) { result in do { let account = try result.get() try account.managedObjectContext?.save() continuation.resume() } catch { continuation.resume(throwing: error) } } } if let source = await self.$storeApp.source { // Fetch source to update pledge status now that account is connected. try await self.update(source) } } func updatePledges(@AsyncManaged for source: Source, @AsyncManaged account: PatreonAccount) async throws { guard PatreonAPI.shared.isAuthenticated else { return } let previousPledgeIDs = Set(await $account.perform { $0.pledges.map(\.identifier) }) let updatedPledgeIDs = try await withCheckedThrowingContinuation { continuation in PatreonAPI.shared.fetchAccount { (result: Result) in do { let account = try result.get() let pledgeIDs = Set(account.pledges.map(\.identifier)) try account.managedObjectContext?.save() continuation.resume(returning: pledgeIDs) } catch { Logger.sideload.error("Failed to update Patreon account. \(error.localizedDescription, privacy: .public)") continuation.resume(throwing: error) } } } if updatedPledgeIDs != previousPledgeIDs { // Active pledges changed, so fetch source to update pledge status. try await self.update(source) } } func update(@AsyncManaged _ source: Source) async throws { let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() _ = try await AppManager.shared.fetchSource(sourceURL: $source.sourceURL, managedObjectContext: context) try await context.performAsync { try context.save() } } @MainActor func openPatreonPage(_ patreonURL: URL, presentingViewController: UIViewController) async { let webViewController = WebViewController(url: patreonURL) webViewController.delegate = self let navigationController = UINavigationController(rootViewController: webViewController) presentingViewController.present(navigationController, animated: true) // Automatically dismiss if user completes checkout flow. self.cancellable = webViewController.webView.publisher(for: \.url, options: [.new]) .compactMap { $0 } .compactMap { URLComponents(url: $0, resolvingAgainstBaseURL: false) } .compactMap { components in let lastPathComponent = components.path.components(separatedBy: "/").last return lastPathComponent?.lowercased() } .filter { $0 == "membership" } .receive(on: RunLoop.main) .sink { [weak self] url in guard let continuation = self?.openPatreonPageContinuation else { return } self?.openPatreonPageContinuation = nil continuation.resume() } await withCheckedContinuation { continuation in self.openPatreonPageContinuation = continuation } // Cache auth cookies just in case user signed in. await PatreonAPI.shared.saveAuthCookies() navigationController.dismiss(animated: true) self.cancellable = nil } } extension VerifyAppPledgeOperation: WebViewControllerDelegate { func webViewControllerDidFinish(_ webViewController: WebViewController) { guard let continuation = self.openPatreonPageContinuation else { return } self.openPatreonPageContinuation = nil continuation.resume() } }