2023-12-07 15:14:55 -06:00
//
// V e r i f y A p p P l e d g e O p e r a t i o n . s w i f t
// A l t S t o r e
//
// C r e a t e d b y R i l e y T e s t u t o n 1 2 / 6 / 2 3 .
// C o p y r i g h t © 2 0 2 3 R i l e y T e s t u t . A l l r i g h t s r e s e r v e d .
//
2023-12-08 18:20:25 -06:00
import Combine
2023-12-07 15:14:55 -06:00
import AltStoreCore
class VerifyAppPledgeOperation : ResultOperation < Void >
{
@ AsyncManaged
private ( set ) var storeApp : StoreApp
private let presentingViewController : UIViewController ?
private var openPatreonPageContinuation : CheckedContinuation < Void , Never > ?
2023-12-08 18:20:25 -06:00
private var cancellable : AnyCancellable ?
2023-12-07 15:14:55 -06:00
init ( storeApp : StoreApp , presentingViewController : UIViewController ? )
{
self . storeApp = storeApp
self . presentingViewController = presentingViewController
}
override func main ( )
{
super . main ( )
2024-01-23 14:21:37 -06:00
// _ D o n ' t _ r e t h r o w e a r l i e r e r r o r s , o r e l s e u s e r w i l l o n l y b e t a k e n t o P a t r e o n p o s t i f c o n n e c t e d t o s a m e W i - F i a s A l t S e r v e r .
2023-12-08 18:17:22 -06:00
// i f l e t e r r o r = s e l f . c o n t e x t . e r r o r
// {
// s e l f . f i n i s h ( . f a i l u r e ( e r r o r ) )
// r e t u r n
// }
2023-12-07 15:14:55 -06:00
Task < Void , Never > . detached ( priority : . medium ) {
do
{
guard await self . $ storeApp . isPledgeRequired else { return self . finish ( . success ( ( ) ) ) }
if let presentingViewController = self . presentingViewController
{
// A s k u s e r t o c o n n e c t P a t r e o n a c c o u n t i f t h e y a r e s i g n e d - i n t o P a t r e o n i n s i d e W e b V i e w C o n t r o l l e r , b u t h a v e n ' t y e t s i g n e d i n t h r o u g h A l t S t o r e s e t t i n g s .
// T h i s i s m o s t l i k e l y b e c a u s e t h e u s e r j o i n e d a P a t r e o n c a m p a i g n d i r e c t l y t h r o u g h W e b V i e w C o n t r o l l e r b e f o r e c o n n e c t i n g P a t r e o n a c c o u n t i n s e t t i n g s .
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 }
2023-12-08 18:20:25 -06:00
let components = URLComponents ( url : patreonURL , resolvingAgainstBaseURL : false )
let lastPathComponent = components ? . path . components ( separatedBy : " / " ) . last
2023-12-07 15:14:55 -06:00
2023-12-08 18:20:25 -06:00
let username = lastPathComponent ? ? patreonURL . lastPathComponent
2024-02-16 14:21:06 -06:00
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 )
2023-12-07 15:14:55 -06:00
{
// P r e f e r / j o i n U R L o v e r c a m p a i g n h o m e p a g e .
2024-02-16 14:21:06 -06:00
// U R L f o r m a t f r o m h t t p s : / / s u p p o r t . p a t r e o n . c o m / h c / e n - u s / a r t i c l e s / 3 6 0 0 4 4 3 7 6 2 1 1 - M a n a g i n g - m e m b e r s - w i t h - c u s t o m - p l e d g e s
2023-12-07 15:14:55 -06:00
checkoutURL = url
}
else
{
checkoutURL = patreonURL
}
// D i r e c t u s e r t o P a t r e o n p a g e i f t h e y ' r e n o t a l r e a d y p l e d g e d .
await self . openPatreonPage ( checkoutURL , presentingViewController : presentingViewController )
let context = DatabaseManager . shared . persistentContainer . newBackgroundContext ( )
if let patreonAccount = await context . performAsync ( { DatabaseManager . shared . patreonAccount ( in : context ) } )
{
// P a t r e o n a c c o u n t i s c o n n e c t e d , s o w e ' l l u p d a t e i t v i a A P I t o s e e i f p l e d g e s c h a n g e d .
// I f s o , w e ' l l r e - f e t c h t h e s o u r c e t o u p d a t e p l e d g e s t a t u s e s .
try await self . updatePledges ( for : source , account : patreonAccount )
}
else
{
// P a t r e o n a c c o u n t i s n o t c o n n e c t e d , s o p r o m p t u s e r t o c o n n e c t i t .
try await self . connectPatreonAccountIfNeeded ( presentingViewController : presentingViewController )
}
do
{
try await self . verifyPledge ( )
}
catch
{
// I g n o r e e r r o r , b u t c a n c e l r e m a i n d e r o f o p e r a t i o n .
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
{
// A s s u m e i f t h e r e i s a n I n s t a l l e d A p p , t h e u s e r h a d p r e v i o u s l y p l e d g e d t o t h i s a p p .
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 }
2023-12-08 18:22:17 -06:00
Logger . sideload . debug ( " Patreon Auth cookie: \( authCookie . name ) = \( authCookie . value ) " )
2023-12-07 15:14:55 -06:00
let message = NSLocalizedString ( " You're signed into Patreon but haven't connected your account with AltStore. \n \n Please 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
{
// I g n o r e a n d c o n t i n u e
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
{
// F e t c h s o u r c e t o u p d a t e p l e d g e s t a t u s n o w t h a t a c c o u n t i s c o n n e c t e d .
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
{
2023-12-08 18:22:17 -06:00
Logger . sideload . error ( " Failed to update Patreon account. \( error . localizedDescription , privacy : . public ) " )
2023-12-07 15:14:55 -06:00
continuation . resume ( throwing : error )
}
}
}
if updatedPledgeIDs != previousPledgeIDs
{
// A c t i v e p l e d g e s c h a n g e d , s o f e t c h s o u r c e t o u p d a t e p l e d g e s t a t u s .
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 )
2023-12-08 18:20:25 -06:00
// A u t o m a t i c a l l y d i s m i s s i f u s e r c o m p l e t e s c h e c k o u t f l o w .
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 ( )
}
2023-12-07 15:14:55 -06:00
await withCheckedContinuation { continuation in
self . openPatreonPageContinuation = continuation
}
// C a c h e a u t h c o o k i e s j u s t i n c a s e u s e r s i g n e d i n .
await PatreonAPI . shared . saveAuthCookies ( )
navigationController . dismiss ( animated : true )
2023-12-08 18:20:25 -06:00
self . cancellable = nil
2023-12-07 15:14:55 -06:00
}
}
extension VerifyAppPledgeOperation : WebViewControllerDelegate
{
func webViewControllerDidFinish ( _ webViewController : WebViewController )
{
guard let continuation = self . openPatreonPageContinuation else { return }
self . openPatreonPageContinuation = nil
continuation . resume ( )
}
}