mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-09 06:43:25 +01:00
272 lines
11 KiB
Swift
272 lines
11 KiB
Swift
//
|
|
// 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<Void>
|
|
{
|
|
@AsyncManaged
|
|
private(set) var storeApp: StoreApp
|
|
|
|
private let presentingViewController: UIViewController?
|
|
private var openPatreonPageContinuation: CheckedContinuation<Void, Never>?
|
|
|
|
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<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 components = URLComponents(url: patreonURL, resolvingAgainstBaseURL: false)
|
|
let lastPathComponent = components?.path.components(separatedBy: "/").last
|
|
|
|
let checkoutURL: URL
|
|
|
|
let username = lastPathComponent ?? 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.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<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.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()
|
|
}
|
|
}
|