mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 06:43:25 +01:00
Supports joining Patreon via web view + downloading app in single flow
Asks user to connect Patreon account if they are signed-in inside WebViewController but not in AltStore settings.
This commit is contained in:
@@ -1315,9 +1315,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
|
||||
@@ -1332,6 +1344,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
|
||||
@@ -1604,7 +1621,7 @@ private extension AppManager
|
||||
progress.addChild(installOperation.progress, withPendingUnitCount: 30)
|
||||
installOperation.addDependency(sendAppOperation)
|
||||
|
||||
let operations = [downloadOperation, verifyOperation, deactivateAppsOperation, removeAppExtensionsOperation, 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
|
||||
@@ -2208,7 +2225,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.
|
||||
|
||||
@@ -219,16 +219,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
|
||||
|
||||
238
AltStore/Operations/VerifyAppPledgeOperation.swift
Normal file
238
AltStore/Operations/VerifyAppPledgeOperation.swift
Normal file
@@ -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<Void>
|
||||
{
|
||||
@AsyncManaged
|
||||
private(set) var storeApp: StoreApp
|
||||
|
||||
private let presentingViewController: UIViewController?
|
||||
private var openPatreonPageContinuation: CheckedContinuation<Void, Never>?
|
||||
|
||||
init(storeApp: StoreApp, presentingViewController: UIViewController?)
|
||||
{
|
||||
self.storeApp = storeApp
|
||||
self.presentingViewController = presentingViewController
|
||||
}
|
||||
|
||||
override func main()
|
||||
{
|
||||
super.main()
|
||||
|
||||
Task<Void, Never>.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<PatreonAccount, Swift.Error>) 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user