diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 9336d440..2fc11849 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -383,6 +383,7 @@ D557A4812AE85BB0007D0DCF /* Pledge.swift in Sources */ = {isa = PBXBuildFile; fileRef = D557A4802AE85BB0007D0DCF /* Pledge.swift */; }; D557A4832AE85DB7007D0DCF /* PledgeReward.swift in Sources */ = {isa = PBXBuildFile; fileRef = D557A4822AE85DB7007D0DCF /* PledgeReward.swift */; }; D557A4852AE88227007D0DCF /* PledgeTier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D557A4842AE88227007D0DCF /* PledgeTier.swift */; }; + D561AF822B21669400BF59C6 /* VerifyAppPledgeOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D561AF812B21669400BF59C6 /* VerifyAppPledgeOperation.swift */; }; D561B2EB28EF5A4F006752E4 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; }; D561B2ED28EF5A4F006752E4 /* AltSign-Dynamic in Embed Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; D56915072AD5E91B00A2B747 /* Regex+Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */; }; @@ -1009,6 +1010,7 @@ D557A4802AE85BB0007D0DCF /* Pledge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pledge.swift; sourceTree = ""; }; D557A4822AE85DB7007D0DCF /* PledgeReward.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PledgeReward.swift; sourceTree = ""; }; D557A4842AE88227007D0DCF /* PledgeTier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PledgeTier.swift; sourceTree = ""; }; + D561AF812B21669400BF59C6 /* VerifyAppPledgeOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerifyAppPledgeOperation.swift; sourceTree = ""; }; D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Regex+Permissions.swift"; sourceTree = ""; }; D56915082AD5F3E800A2B747 /* AltTests+Sources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AltTests+Sources.swift"; sourceTree = ""; }; D569A5032AF9BC5F00A4CB8B /* ReviewPermissionsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewPermissionsViewController.swift; sourceTree = ""; }; @@ -2028,6 +2030,7 @@ D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */, D5E1E7C028077DE90016FC96 /* UpdateKnownSourcesOperation.swift */, D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */, + D561AF812B21669400BF59C6 /* VerifyAppPledgeOperation.swift */, BF7B44062725A4B8005288A4 /* Patch App */, ); path = Operations; @@ -3292,6 +3295,7 @@ BFF00D342501BDCF00746320 /* IntentHandler.swift in Sources */, BFDBBD80246CB84F004ED2F3 /* RemoveAppBackupOperation.swift in Sources */, BFF0B6942321CB85007A79E1 /* AuthenticationViewController.swift in Sources */, + D561AF822B21669400BF59C6 /* VerifyAppPledgeOperation.swift in Sources */, BF3432FB246B894F0052F4A1 /* BackupAppOperation.swift in Sources */, BF9ABA4922DD0742008935CF /* ScreenshotCollectionViewCell.swift in Sources */, BF9ABA4D22DD16DE008935CF /* PillButton.swift in Sources */, diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index c4753c68..d52677f8 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -1177,9 +1177,21 @@ private extension AppManager } } - let downloadedAppURL = context.temporaryDirectory.appendingPathComponent("Cached.app") + var verifyPledgeOperation: VerifyAppPledgeOperation? + if let storeApp = app.storeApp + { + verifyPledgeOperation = VerifyAppPledgeOperation(storeApp: storeApp, presentingViewController: context.presentingViewController) + verifyPledgeOperation?.resultHandler = { result in + switch result + { + case .failure(let error): context.error = error + case .success: break + } + } + } /* Download */ + let downloadedAppURL = context.temporaryDirectory.appendingPathComponent("Cached.app") let downloadOperation = DownloadAppOperation(app: downloadingApp, destinationURL: downloadedAppURL, context: context) downloadOperation.resultHandler = { (result) in do @@ -1194,6 +1206,11 @@ private extension AppManager } progress.addChild(downloadOperation.progress, withPendingUnitCount: 25) + if let verifyPledgeOperation + { + downloadOperation.addDependency(verifyPledgeOperation) + } + /* Verify App */ let permissionsMode = UserDefaults.shared.permissionCheckingDisabled ? .none : permissionReviewMode @@ -1404,7 +1421,7 @@ private extension AppManager progress.addChild(installOperation.progress, withPendingUnitCount: 30) installOperation.addDependency(sendAppOperation) - var operations = [downloadOperation, verifyOperation, deactivateAppsOperation, patchAppOperation, refreshAnisetteDataOperation, fetchProvisioningProfilesOperation, resignAppOperation, sendAppOperation, installOperation] + var operations = [verifyPledgeOperation, downloadOperation, verifyOperation, deactivateAppsOperation, patchAppOperation, refreshAnisetteDataOperation, fetchProvisioningProfilesOperation, resignAppOperation, sendAppOperation, installOperation].compactMap { $0 } group.add(operations) if let storeApp = downloadingApp.storeApp, storeApp.isPledgeRequired @@ -1997,7 +2014,7 @@ private extension AppManager switch operation { case _ where requiresSerialQueue: fallthrough - case is InstallAppOperation, is RefreshAppOperation, is BackupAppOperation: + case is InstallAppOperation, is RefreshAppOperation, is BackupAppOperation, is VerifyAppPledgeOperation: if let installAltStoreOperation = operation as? InstallAppOperation, installAltStoreOperation.context.bundleIdentifier == StoreApp.altstoreAppID { // Add dependencies on previous serial operations in `context` to ensure re-installing AltStore goes last. diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index 39dccba5..d43f3017 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -217,16 +217,6 @@ private extension DownloadAppOperation fileURL = sourceURL self.progress.completedUnitCount += 3 } - else if let (isPledged, isPledgeRequired) = await self.context.$appVersion.perform({ $0?.app.map { ($0.isPledged, $0.isPledgeRequired) } }), isPledgeRequired && !isPledged - { - // Not pledged, so just show Patreon page. - guard let presentingViewController = self.context.presentingViewController, - let patreonURL = await self.context.$appVersion.perform({ $0?.app?.source?.patreonURL }) - else { throw OperationError.pledgeRequired(appName: self.appName) } - - // Intercept downloads just in case they are in fact pledged. - fileURL = try await self.downloadFromPatreon(patreonURL, presentingViewController: presentingViewController) - } else if let host = sourceURL.host, host.lowercased().hasSuffix("patreon.com") && sourceURL.path.lowercased() == "/file" { // Patreon app diff --git a/AltStore/Operations/VerifyAppPledgeOperation.swift b/AltStore/Operations/VerifyAppPledgeOperation.swift new file mode 100644 index 00000000..32be9681 --- /dev/null +++ b/AltStore/Operations/VerifyAppPledgeOperation.swift @@ -0,0 +1,238 @@ +// +// VerifyAppPledgeOperation.swift +// AltStore +// +// Created by Riley Testut on 12/6/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import AltStoreCore + +class VerifyAppPledgeOperation: ResultOperation +{ + @AsyncManaged + private(set) var storeApp: StoreApp + + private let presentingViewController: UIViewController? + private var openPatreonPageContinuation: CheckedContinuation? + + init(storeApp: StoreApp, presentingViewController: UIViewController?) + { + self.storeApp = storeApp + self.presentingViewController = presentingViewController + } + + override func main() + { + super.main() + + 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 checkoutURL: URL + + let username = patreonURL.lastPathComponent + if !username.isEmpty, let url = URL(string: "https://www.patreon.com/join/" + username) + { + // Prefer /join URL over campaign homepage. + 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.main.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.main.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) + + await withCheckedContinuation { continuation in + self.openPatreonPageContinuation = continuation + } + + // Cache auth cookies just in case user signed in. + await PatreonAPI.shared.saveAuthCookies() + + navigationController.dismiss(animated: true) + } +} + +extension VerifyAppPledgeOperation: WebViewControllerDelegate +{ + func webViewControllerDidFinish(_ webViewController: WebViewController) + { + guard let continuation = self.openPatreonPageContinuation else { return } + self.openPatreonPageContinuation = nil + + continuation.resume() + } +} diff --git a/AltStoreCore/Patreon/PatreonAPI.swift b/AltStoreCore/Patreon/PatreonAPI.swift index ae9744b0..7d394f2c 100644 --- a/AltStoreCore/Patreon/PatreonAPI.swift +++ b/AltStoreCore/Patreon/PatreonAPI.swift @@ -283,7 +283,12 @@ public extension PatreonAPI extension PatreonAPI { - private func saveAuthCookies() async + public var authCookies: [HTTPCookie] { + let cookies = HTTPCookieStorage.shared.cookies(for: URL(string: "https://www.patreon.com")!) ?? [] + return cookies + } + + public func saveAuthCookies() async { let cookieStore = await MainActor.run { WKWebsiteDataStore.default().httpCookieStore } // Must access from main actor @@ -301,22 +306,15 @@ extension PatreonAPI let cookieStore = await MainActor.run { WKWebsiteDataStore.default().httpCookieStore } // Must access from main actor - if let cookies = HTTPCookieStorage.shared.cookies(for: URL(string: "https://www.patreon.com")!) + for cookie in self.authCookies { - for cookie in cookies - { - Logger.main.debug("Deleting Patreon cookie \(cookie.name, privacy: .public) (Expires: \(cookie.expiresDate?.description ?? "nil", privacy: .public))") - - await cookieStore.deleteCookie(cookie) - HTTPCookieStorage.shared.deleteCookie(cookie) - } + Logger.main.debug("Deleting Patreon cookie \(cookie.name, privacy: .public) (Expires: \(cookie.expiresDate?.description ?? "nil", privacy: .public))") - Logger.main.info("Cleared Patreon cookie cache!") - } - else - { - Logger.main.info("No Patreon cookies to clear.") + await cookieStore.deleteCookie(cookie) + HTTPCookieStorage.shared.deleteCookie(cookie) } + + Logger.main.info("Cleared Patreon cookie cache!") } }