mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-28 07:57:38 +01:00
debloat: remove patreon stuff carried over from altstore 2.0...not required by sidestore in-app since sidestore manages in web + remove old tests from altstore
This commit is contained in:
@@ -348,16 +348,6 @@ public extension DatabaseManager
|
||||
let activeTeam = Team.first(satisfying: predicate, in: context)
|
||||
return activeTeam
|
||||
}
|
||||
|
||||
func patreonAccount(in context: NSManagedObjectContext = DatabaseManager.shared.viewContext) -> PatreonAccount?
|
||||
{
|
||||
guard let patreonAccountID = Keychain.shared.patreonAccountID else { return nil }
|
||||
|
||||
let predicate = NSPredicate(format: "%K == %@", #keyPath(PatreonAccount.identifier), patreonAccountID)
|
||||
|
||||
let patreonAccount = PatreonAccount.first(satisfying: predicate, in: context, requestProperties: [\.relationshipKeyPathsForPrefetching: [#keyPath(PatreonAccount._pledges)]])
|
||||
return patreonAccount
|
||||
}
|
||||
}
|
||||
|
||||
private extension DatabaseManager
|
||||
|
||||
@@ -306,35 +306,6 @@ extension MergePolicy{
|
||||
conflictedObject.featuredSortID = featuredSortID
|
||||
}
|
||||
|
||||
case let databasePledge as Pledge:
|
||||
guard let contextPledge = conflict.conflictingObjects.first as? Pledge else { break }
|
||||
|
||||
// Tiers
|
||||
let contextTierIDs = Set(contextPledge._tiers.lazy.compactMap { $0 as? PledgeTier }.map { $0.identifier })
|
||||
for case let databaseTier as PledgeTier in databasePledge._tiers where !contextTierIDs.contains(databaseTier.identifier)
|
||||
{
|
||||
// Tier ID does NOT exist in context, so delete existing databaseTier.
|
||||
databaseTier.managedObjectContext?.delete(databaseTier)
|
||||
}
|
||||
|
||||
// Rewards
|
||||
let contextRewardIDs = Set(contextPledge._rewards.lazy.compactMap { $0 as? PledgeReward }.map { $0.identifier })
|
||||
for case let databaseReward as PledgeReward in databasePledge._rewards where !contextRewardIDs.contains(databaseReward.identifier)
|
||||
{
|
||||
// Reward ID does NOT exist in context, so delete existing databaseReward.
|
||||
databaseReward.managedObjectContext?.delete(databaseReward)
|
||||
}
|
||||
|
||||
case let databaseAccount as PatreonAccount:
|
||||
guard let contextAccount = conflict.conflictingObjects.first as? PatreonAccount else { break }
|
||||
|
||||
let contextPledgeIDs = Set(contextAccount._pledges.lazy.compactMap { $0 as? Pledge }.map { $0.identifier })
|
||||
for case let databasePledge as Pledge in databaseAccount._pledges where !contextPledgeIDs.contains(databasePledge.identifier)
|
||||
{
|
||||
// Pledge ID does NOT exist in context, so delete existing databasePledge.
|
||||
databasePledge.managedObjectContext?.delete(databasePledge)
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
//
|
||||
// ManagedPatron.swift
|
||||
// AltStoreCore
|
||||
//
|
||||
// Created by Riley Testut on 4/18/22.
|
||||
// Copyright © 2022 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
|
||||
@objc(ManagedPatron)
|
||||
public class ManagedPatron: BaseEntity
|
||||
{
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var identifier: String
|
||||
|
||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
||||
{
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
public init?(patron: PatreonAPI.Patron, context: NSManagedObjectContext)
|
||||
{
|
||||
// Only cache Patrons with non-nil names.
|
||||
guard let name = patron.name else { return nil }
|
||||
|
||||
super.init(entity: ManagedPatron.entity(), insertInto: context)
|
||||
|
||||
self.name = name
|
||||
self.identifier = patron.identifier
|
||||
}
|
||||
}
|
||||
|
||||
public extension ManagedPatron
|
||||
{
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<ManagedPatron>
|
||||
{
|
||||
return NSFetchRequest<ManagedPatron>(entityName: "Patron")
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
//
|
||||
// PatreonAccount.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/20/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import CoreData
|
||||
|
||||
@objc(PatreonAccount)
|
||||
public class PatreonAccount: NSManagedObject, Fetchable
|
||||
{
|
||||
@NSManaged public var identifier: String
|
||||
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var firstName: String?
|
||||
|
||||
// Use `isPatron` for backwards compatibility.
|
||||
@NSManaged @objc(isPatron) public var isAltStorePatron: Bool
|
||||
|
||||
/* Relationships */
|
||||
@nonobjc public var pledges: Set<Pledge> { _pledges as! Set<Pledge> }
|
||||
@NSManaged @objc(pledges) internal var _pledges: NSSet
|
||||
|
||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
||||
{
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
init(account: PatreonAPI.UserAccount, context: NSManagedObjectContext)
|
||||
{
|
||||
super.init(entity: PatreonAccount.entity(), insertInto: context)
|
||||
|
||||
self.identifier = account.identifier
|
||||
self.name = account.name
|
||||
self.firstName = account.firstName
|
||||
|
||||
// if let patronResponse = response.included?.first
|
||||
// {
|
||||
// let patron = Patron(response: patronResponse)
|
||||
// self.isPatron = (patron.status == .active)
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// self.isPatron = false
|
||||
// }
|
||||
// self.isPatron = true
|
||||
self.isAltStorePatron = true
|
||||
}
|
||||
}
|
||||
|
||||
public extension PatreonAccount
|
||||
{
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<PatreonAccount>
|
||||
{
|
||||
return NSFetchRequest<PatreonAccount>(entityName: "PatreonAccount")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
//
|
||||
// Pledge.swift
|
||||
// AltStoreCore
|
||||
//
|
||||
// Created by Riley Testut on 10/24/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(Pledge)
|
||||
public class Pledge: NSManagedObject, Fetchable
|
||||
{
|
||||
/* Properties */
|
||||
@NSManaged public private(set) var identifier: String
|
||||
@NSManaged public private(set) var campaignURL: URL
|
||||
|
||||
@nonobjc public var amount: Decimal { _amount as Decimal }
|
||||
@NSManaged @objc(amount) private var _amount: NSDecimalNumber
|
||||
|
||||
/* Relationships */
|
||||
@NSManaged public private(set) var account: PatreonAccount?
|
||||
|
||||
@nonobjc public var tiers: Set<PledgeTier> { _tiers as! Set<PledgeTier> }
|
||||
@NSManaged @objc(tiers) internal var _tiers: NSSet
|
||||
|
||||
@nonobjc public var rewards: Set<PledgeReward> { _rewards as! Set<PledgeReward> }
|
||||
@NSManaged @objc(rewards) internal var _rewards: NSSet
|
||||
|
||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
||||
{
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
init?(patron: PatreonAPI.Patron, context: NSManagedObjectContext)
|
||||
{
|
||||
guard let amount = patron.pledgeAmount, let campaignURL = patron.campaign?.url else { return nil }
|
||||
|
||||
super.init(entity: Pledge.entity(), insertInto: context)
|
||||
|
||||
self.identifier = patron.identifier
|
||||
self._amount = amount as NSDecimalNumber
|
||||
self.campaignURL = campaignURL
|
||||
}
|
||||
}
|
||||
|
||||
public extension Pledge
|
||||
{
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<Pledge>
|
||||
{
|
||||
return NSFetchRequest<Pledge>(entityName: "Pledge")
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
//
|
||||
// PledgeReward.swift
|
||||
// AltStoreCore
|
||||
//
|
||||
// Created by Riley Testut on 10/24/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(PledgeReward)
|
||||
public class PledgeReward: NSManagedObject, Fetchable
|
||||
{
|
||||
/* Properties */
|
||||
@NSManaged public private(set) var name: String
|
||||
@NSManaged public private(set) var identifier: String
|
||||
|
||||
/* Relationships */
|
||||
@NSManaged public private(set) var pledge: Pledge?
|
||||
|
||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
||||
{
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
init(benefit: PatreonAPI.Benefit, context: NSManagedObjectContext)
|
||||
{
|
||||
super.init(entity: PledgeReward.entity(), insertInto: context)
|
||||
|
||||
self.name = benefit.name
|
||||
self.identifier = benefit.identifier.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
public extension PledgeReward
|
||||
{
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<PledgeReward>
|
||||
{
|
||||
return NSFetchRequest<PledgeReward>(entityName: "PledgeReward")
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//
|
||||
// PledgeTier.swift
|
||||
// AltStoreCore
|
||||
//
|
||||
// Created by Riley Testut on 10/24/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
@objc(PledgeTier)
|
||||
public class PledgeTier: NSManagedObject, Fetchable
|
||||
{
|
||||
/* Properties */
|
||||
@NSManaged public private(set) var name: String?
|
||||
@NSManaged public private(set) var identifier: String
|
||||
|
||||
@nonobjc public var amount: Decimal { _amount as Decimal } // In USD
|
||||
@NSManaged @objc(amount) private var _amount: NSDecimalNumber
|
||||
|
||||
/* Relationships */
|
||||
@NSManaged public private(set) var pledge: Pledge?
|
||||
|
||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
||||
{
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
init(tier: PatreonAPI.Tier, context: NSManagedObjectContext)
|
||||
{
|
||||
super.init(entity: PledgeTier.entity(), insertInto: context)
|
||||
|
||||
self.name = tier.name
|
||||
self.identifier = tier.identifier
|
||||
self._amount = tier.amount as NSDecimalNumber
|
||||
}
|
||||
}
|
||||
|
||||
public extension PledgeTier
|
||||
{
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<PledgeTier>
|
||||
{
|
||||
return NSFetchRequest<PledgeTier>(entityName: "PledgeTier")
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
//
|
||||
// Benefit.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/21/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
typealias BenefitResponse = DataResponse<BenefitAttributes, AnyRelationships>
|
||||
|
||||
struct BenefitAttributes: Decodable
|
||||
{
|
||||
var title: String
|
||||
}
|
||||
}
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
public struct Benefit: Hashable
|
||||
{
|
||||
public var name: String
|
||||
public var identifier: ALTPatreonBenefitID
|
||||
|
||||
internal init(response: BenefitResponse)
|
||||
{
|
||||
self.name = response.attributes.title
|
||||
self.identifier = ALTPatreonBenefitID(response.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
//
|
||||
// Campaign.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/21/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
typealias CampaignResponse = DataResponse<CampaignAttributes, AnyRelationships>
|
||||
|
||||
struct CampaignAttributes: Decodable
|
||||
{
|
||||
var url: URL
|
||||
}
|
||||
}
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
public struct Campaign
|
||||
{
|
||||
public var identifier: String
|
||||
public var url: URL
|
||||
|
||||
internal init(response: PatreonAPI.CampaignResponse)
|
||||
{
|
||||
self.identifier = response.id
|
||||
self.url = response.attributes.url
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
//
|
||||
// PatreonAPI+Responses.swift
|
||||
// AltStoreCore
|
||||
//
|
||||
// Created by Riley Testut on 11/3/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
protocol ResponseData: Decodable
|
||||
{
|
||||
}
|
||||
|
||||
// Allows us to use Arrays with Response<> despite them not conforming to `ItemResponse`
|
||||
extension Array: ResponseData where Element: ItemResponse
|
||||
{
|
||||
}
|
||||
|
||||
protocol ItemResponse: ResponseData
|
||||
{
|
||||
var id: String { get }
|
||||
var type: String { get }
|
||||
}
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
struct Response<Data: ResponseData>: Decodable
|
||||
{
|
||||
var data: Data
|
||||
|
||||
var included: IncludedResponses?
|
||||
var links: [String: URL]?
|
||||
}
|
||||
|
||||
struct AnyItemResponse: ItemResponse
|
||||
{
|
||||
var id: String
|
||||
var type: String
|
||||
}
|
||||
|
||||
struct DataResponse<Attributes: Decodable, Relationships: Decodable>: ItemResponse
|
||||
{
|
||||
var id: String
|
||||
var type: String
|
||||
|
||||
var attributes: Attributes
|
||||
var relationships: Relationships?
|
||||
}
|
||||
|
||||
// `Never` only conforms to Decodable from iOS 17 onwards,
|
||||
// so use our own "Empty" type for DataResponses without relationships.
|
||||
struct AnyRelationships: Decodable
|
||||
{
|
||||
}
|
||||
|
||||
struct IncludedResponses: Decodable
|
||||
{
|
||||
var items: [IncludedItem]
|
||||
|
||||
var campaigns: [String: CampaignResponse]
|
||||
var patrons: [String: PatronResponse]
|
||||
var tiers: [String: TierResponse]
|
||||
var benefits: [String: BenefitResponse]
|
||||
|
||||
init(from decoder: Decoder) throws
|
||||
{
|
||||
let container = try decoder.singleValueContainer()
|
||||
self.items = try container.decode([IncludedItem].self)
|
||||
|
||||
var campaignsByID = [String: PatreonAPI.CampaignResponse]()
|
||||
var patronsByID = [String: PatreonAPI.PatronResponse]()
|
||||
var tiersByID = [String: PatreonAPI.TierResponse]()
|
||||
var benefitsByID = [String: PatreonAPI.BenefitResponse]()
|
||||
|
||||
for response in self.items
|
||||
{
|
||||
switch response
|
||||
{
|
||||
case .campaign(let response): campaignsByID[response.id] = response
|
||||
case .patron(let response): patronsByID[response.id] = response
|
||||
case .tier(let response): tiersByID[response.id] = response
|
||||
case .benefit(let response): benefitsByID[response.id] = response
|
||||
case .unknown: break // Ignore
|
||||
}
|
||||
}
|
||||
|
||||
self.campaigns = campaignsByID
|
||||
self.patrons = patronsByID
|
||||
self.tiers = tiersByID
|
||||
self.benefits = benefitsByID
|
||||
}
|
||||
}
|
||||
|
||||
enum IncludedItem: ItemResponse
|
||||
{
|
||||
case tier(TierResponse)
|
||||
case benefit(BenefitResponse)
|
||||
case patron(PatronResponse)
|
||||
case campaign(CampaignResponse)
|
||||
case unknown(AnyItemResponse)
|
||||
|
||||
var id: String {
|
||||
switch self
|
||||
{
|
||||
case .tier(let response): return response.id
|
||||
case .benefit(let response): return response.id
|
||||
case .patron(let response): return response.id
|
||||
case .campaign(let response): return response.id
|
||||
case .unknown(let response): return response.id
|
||||
}
|
||||
}
|
||||
|
||||
var type: String {
|
||||
switch self
|
||||
{
|
||||
case .tier(let response): return response.type
|
||||
case .benefit(let response): return response.type
|
||||
case .patron(let response): return response.type
|
||||
case .campaign(let response): return response.type
|
||||
case .unknown(let response): return response.type
|
||||
}
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey
|
||||
{
|
||||
case type
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws
|
||||
{
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
let type = try container.decode(String.self, forKey: .type)
|
||||
switch type
|
||||
{
|
||||
case "tier":
|
||||
let response = try TierResponse(from: decoder)
|
||||
self = .tier(response)
|
||||
|
||||
case "benefit":
|
||||
let response = try BenefitResponse(from: decoder)
|
||||
self = .benefit(response)
|
||||
|
||||
case "member":
|
||||
let response = try PatronResponse(from: decoder)
|
||||
self = .patron(response)
|
||||
|
||||
case "campaign":
|
||||
let response = try CampaignResponse(from: decoder)
|
||||
self = .campaign(response)
|
||||
|
||||
default:
|
||||
Logger.main.error("Unrecognized PatreonAPI response type: \(type, privacy: .public).")
|
||||
|
||||
let response = try AnyItemResponse(from: decoder)
|
||||
self = .unknown(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,528 +0,0 @@
|
||||
//
|
||||
// PatreonAPI.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/20/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import AuthenticationServices
|
||||
import CoreData
|
||||
import WebKit
|
||||
|
||||
private let clientID = "my4hpHHG4iVRme6QALnQGlhSBQiKdB_AinrVgPpIpiC-xiHstTYiLKO5vfariFo1"
|
||||
private let clientSecret = "Zow0ggt9YgwIyd4DVLoO9Z02KuuIXW44xhx4lfL27x2u-_u4FE4rYR48bEKREPS5"
|
||||
|
||||
private let campaignID = "12794837"
|
||||
|
||||
typealias PatreonAPIError = PatreonAPIErrorCode.Error
|
||||
enum PatreonAPIErrorCode: Int, ALTErrorEnum, CaseIterable
|
||||
{
|
||||
case unknown
|
||||
case notAuthenticated
|
||||
case invalidAccessToken
|
||||
|
||||
var errorFailureReason: String {
|
||||
switch self
|
||||
{
|
||||
case .unknown: return NSLocalizedString("An unknown error occurred.", comment: "")
|
||||
case .notAuthenticated: return NSLocalizedString("No connected Patreon account.", comment: "")
|
||||
case .invalidAccessToken: return NSLocalizedString("Invalid access token.", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
static let altstoreCampaignID = "2863968"
|
||||
|
||||
typealias FetchAccountResponse = Response<UserAccountResponse>
|
||||
typealias FriendZonePatronsResponse = Response<[PatronResponse]>
|
||||
|
||||
enum AuthorizationType
|
||||
{
|
||||
case none
|
||||
case user
|
||||
case creator
|
||||
}
|
||||
}
|
||||
|
||||
public class PatreonAPI: NSObject
|
||||
{
|
||||
public static let shared = PatreonAPI()
|
||||
|
||||
public var isAuthenticated: Bool {
|
||||
return Keychain.shared.patreonAccessToken != nil
|
||||
}
|
||||
|
||||
private var authenticationSession: ASWebAuthenticationSession?
|
||||
|
||||
private let session = URLSession(configuration: .ephemeral)
|
||||
private let baseURL = URL(string: "https://www.patreon.com/")!
|
||||
|
||||
private var authHandlers = [(Result<PatreonAccount, Swift.Error>) -> Void]()
|
||||
private var authContinuation: CheckedContinuation<URL, Error>?
|
||||
private weak var webViewController: WebViewController?
|
||||
|
||||
private override init()
|
||||
{
|
||||
super.init()
|
||||
}
|
||||
}
|
||||
|
||||
public extension PatreonAPI
|
||||
{
|
||||
func authenticate(presentingViewController: UIViewController, completion: @escaping (Result<PatreonAccount, Swift.Error>) -> Void)
|
||||
{
|
||||
Task<Void, Never>.detached { @MainActor in
|
||||
guard self.authHandlers.isEmpty else {
|
||||
self.authHandlers.append(completion)
|
||||
return
|
||||
}
|
||||
|
||||
self.authHandlers.append(completion)
|
||||
|
||||
do
|
||||
{
|
||||
var components = URLComponents(string: "/oauth2/authorize")!
|
||||
components.queryItems = [URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "client_id", value: clientID),
|
||||
URLQueryItem(name: "redirect_uri", value: "https://rileytestut.com/patreon/altstore"),
|
||||
URLQueryItem(name: "scope", value: "identity identity[email] identity.memberships campaigns.posts")]
|
||||
|
||||
let requestURL = components.url(relativeTo: self.baseURL)
|
||||
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.setURLSchemeHandler(self, forURLScheme: "altstore")
|
||||
configuration.websiteDataStore = .default()
|
||||
configuration.preferences.javaScriptCanOpenWindowsAutomatically = true
|
||||
configuration.applicationNameForUserAgent = "Version/17.1.2 Mobile/15E148 Safari/604.1" // Required for "Sign-in With Google" to work in WKWebView
|
||||
|
||||
let webViewController = WebViewController(url: requestURL, configuration: configuration)
|
||||
webViewController.delegate = self
|
||||
webViewController.webView.uiDelegate = self
|
||||
self.webViewController = webViewController
|
||||
|
||||
let callbackURL = try await withCheckedThrowingContinuation { continuation in
|
||||
self.authContinuation = continuation
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: webViewController)
|
||||
presentingViewController.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
guard
|
||||
let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
|
||||
let codeQueryItem = components.queryItems?.first(where: { $0.name == "code" }),
|
||||
let code = codeQueryItem.value
|
||||
else { throw PatreonAPIError(.unknown) }
|
||||
|
||||
let (accessToken, refreshToken) = try await withCheckedThrowingContinuation { continuation in
|
||||
self.fetchAccessToken(oauthCode: code) { result in
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
Keychain.shared.patreonAccessToken = accessToken
|
||||
Keychain.shared.patreonRefreshToken = refreshToken
|
||||
|
||||
let patreonAccount = try await withCheckedThrowingContinuation { continuation in
|
||||
self.fetchAccount { result in
|
||||
let result = result.map { AsyncManaged(wrappedValue: $0) }
|
||||
continuation.resume(with: result)
|
||||
}
|
||||
}
|
||||
|
||||
await self.saveAuthCookies()
|
||||
|
||||
await patreonAccount.perform { patreonAccount in
|
||||
for callback in self.authHandlers
|
||||
{
|
||||
callback(.success(patreonAccount))
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
for callback in self.authHandlers
|
||||
{
|
||||
callback(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
self.authHandlers = []
|
||||
|
||||
await MainActor.run {
|
||||
self.webViewController?.dismiss(animated: true)
|
||||
self.webViewController = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAccount(completion: @escaping (Result<PatreonAccount, Swift.Error>) -> Void)
|
||||
{
|
||||
var components = URLComponents(string: "/api/oauth2/v2/identity")!
|
||||
components.queryItems = [URLQueryItem(name: "include", value: "memberships.campaign.tiers,memberships.currently_entitled_tiers.benefits"),
|
||||
URLQueryItem(name: "fields[user]", value: "first_name,full_name"),
|
||||
URLQueryItem(name: "fields[tier]", value: "title,amount_cents"),
|
||||
URLQueryItem(name: "fields[benefit]", value: "title"),
|
||||
URLQueryItem(name: "fields[campaign]", value: "url"),
|
||||
URLQueryItem(name: "fields[member]", value: "full_name,patron_status,currently_entitled_amount_cents")]
|
||||
|
||||
let requestURL = components.url(relativeTo: self.baseURL)!
|
||||
let request = URLRequest(url: requestURL)
|
||||
|
||||
self.send(request, authorizationType: .user) { (result: Result<FetchAccountResponse, Swift.Error>) in
|
||||
switch result
|
||||
{
|
||||
case .failure(~PatreonAPIErrorCode.notAuthenticated):
|
||||
self.signOut() { (result) in
|
||||
completion(.failure(PatreonAPIError(.notAuthenticated)))
|
||||
}
|
||||
|
||||
case .failure(let error as DecodingError):
|
||||
do
|
||||
{
|
||||
let nsError = error as NSError
|
||||
guard let codingPath = nsError.userInfo[ALTNSCodingPathKey] as? [CodingKey] else { throw error }
|
||||
|
||||
let rawComponents = codingPath.map { $0.intValue?.description ?? $0.stringValue }
|
||||
let pathDescription = rawComponents.joined(separator: " > ")
|
||||
|
||||
let localizedDescription = nsError.localizedDebugDescription ?? nsError.localizedDescription
|
||||
let debugDescription = localizedDescription + " Path: " + pathDescription
|
||||
|
||||
var userInfo = nsError.userInfo
|
||||
userInfo[NSDebugDescriptionErrorKey] = debugDescription
|
||||
throw NSError(domain: nsError.domain, code: nsError.code, userInfo: userInfo)
|
||||
}
|
||||
catch let error as NSError
|
||||
{
|
||||
Logger.main.error("Failed to fetch Patreon account. \(error.localizedDebugDescription ?? error.localizedDescription, privacy: .public)")
|
||||
completion(.failure(error))
|
||||
}
|
||||
|
||||
case .failure(let error as NSError):
|
||||
Logger.main.error("Failed to fetch Patreon account. \(error.localizedDebugDescription ?? error.localizedDescription, privacy: .public)")
|
||||
completion(.failure(error))
|
||||
|
||||
case .success(let response):
|
||||
let account = PatreonAPI.UserAccount(response: response.data, including: response.included)
|
||||
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
||||
let account = PatreonAccount(account: account, context: context)
|
||||
Keychain.shared.patreonAccountID = account.identifier
|
||||
completion(.success(account))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fetchPatrons(completion: @escaping (Result<[Patron], Swift.Error>) -> Void)
|
||||
{
|
||||
var components = URLComponents(string: "/api/oauth2/v2/campaigns/\(PatreonAPI.altstoreCampaignID)/members")!
|
||||
components.queryItems = [URLQueryItem(name: "include", value: "currently_entitled_tiers,currently_entitled_tiers.benefits"),
|
||||
URLQueryItem(name: "fields[tier]", value: "title,amount_cents"),
|
||||
URLQueryItem(name: "fields[benefit]", value: "title"),
|
||||
URLQueryItem(name: "fields[member]", value: "full_name,patron_status,currently_entitled_amount_cents"),
|
||||
URLQueryItem(name: "page[size]", value: "1000")]
|
||||
|
||||
let requestURL = components.url(relativeTo: self.baseURL)!
|
||||
|
||||
var allPatrons = [Patron]()
|
||||
|
||||
func fetchPatrons(url: URL)
|
||||
{
|
||||
let request = URLRequest(url: url)
|
||||
|
||||
self.send(request, authorizationType: .creator) { (result: Result<FriendZonePatronsResponse, Swift.Error>) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): completion(.failure(error))
|
||||
case .success(let patronsResponse):
|
||||
let patrons = patronsResponse.data.map { (response) -> Patron in
|
||||
let patron = Patron(response: response, including: patronsResponse.included)
|
||||
return patron
|
||||
}.filter { $0.benefits.contains(where: { $0.identifier == .credits }) }
|
||||
|
||||
allPatrons.append(contentsOf: patrons)
|
||||
|
||||
if let nextURL = patronsResponse.links?["next"]
|
||||
{
|
||||
fetchPatrons(url: nextURL)
|
||||
}
|
||||
else
|
||||
{
|
||||
completion(.success(allPatrons))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fetchPatrons(url: requestURL)
|
||||
}
|
||||
|
||||
func signOut(completion: @escaping (Result<Void, Swift.Error>) -> Void)
|
||||
{
|
||||
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in
|
||||
do
|
||||
{
|
||||
let accounts = PatreonAccount.all(in: context, requestProperties: [\.returnsObjectsAsFaults: true])
|
||||
accounts.forEach(context.delete(_:))
|
||||
|
||||
let pledgeRequiredApps = StoreApp.all(satisfying: NSPredicate(format: "%K == YES", #keyPath(StoreApp.isPledgeRequired)), in: context)
|
||||
pledgeRequiredApps.forEach { $0.isPledged = false }
|
||||
|
||||
try context.save()
|
||||
|
||||
Keychain.shared.patreonAccessToken = nil
|
||||
Keychain.shared.patreonRefreshToken = nil
|
||||
Keychain.shared.patreonAccountID = nil
|
||||
|
||||
Task<Void, Never>.detached {
|
||||
await self.deleteAuthCookies()
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshPatreonAccount()
|
||||
{
|
||||
guard PatreonAPI.shared.isAuthenticated else { return }
|
||||
|
||||
PatreonAPI.shared.fetchAccount { (result: Result<PatreonAccount, Swift.Error>) in
|
||||
do
|
||||
{
|
||||
let account = try result.get()
|
||||
|
||||
if let context = account.managedObjectContext, !account.isAltStorePatron
|
||||
{
|
||||
// Deactivate all beta apps now that we're no longer a patron.
|
||||
//self.deactivateBetaApps(in: context)
|
||||
}
|
||||
|
||||
try account.managedObjectContext?.save()
|
||||
}
|
||||
catch
|
||||
{
|
||||
print("Failed to fetch Patreon account.", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
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
|
||||
|
||||
let cookies = await cookieStore.allCookies()
|
||||
for cookie in cookies where cookie.domain.lowercased().hasSuffix("patreon.com")
|
||||
{
|
||||
Logger.main.debug("Saving Patreon cookie \(cookie.name, privacy: .public): \(cookie.value, privacy: .private(mask: .hash)) (Expires: \(cookie.expiresDate?.description ?? "nil", privacy: .public))")
|
||||
HTTPCookieStorage.shared.setCookie(cookie)
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteAuthCookies() async
|
||||
{
|
||||
Logger.main.info("Clearing Patreon cookie cache...")
|
||||
|
||||
let cookieStore = await MainActor.run { WKWebsiteDataStore.default().httpCookieStore } // Must access from main actor
|
||||
|
||||
for cookie in self.authCookies
|
||||
{
|
||||
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.info("Cleared Patreon cookie cache!")
|
||||
}
|
||||
}
|
||||
|
||||
extension PatreonAPI: WebViewControllerDelegate
|
||||
{
|
||||
public func webViewControllerDidFinish(_ webViewController: WebViewController)
|
||||
{
|
||||
guard let authContinuation else { return }
|
||||
self.authContinuation = nil
|
||||
|
||||
authContinuation.resume(throwing: CancellationError())
|
||||
}
|
||||
}
|
||||
|
||||
private extension PatreonAPI
|
||||
{
|
||||
func fetchAccessToken(oauthCode: String, completion: @escaping (Result<(String, String), Swift.Error>) -> Void)
|
||||
{
|
||||
let encodedRedirectURI = ("https://rileytestut.com/patreon/altstore" as NSString).addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
|
||||
let encodedOauthCode = (oauthCode as NSString).addingPercentEncoding(withAllowedCharacters: .alphanumerics)!
|
||||
|
||||
let body = "code=\(encodedOauthCode)&grant_type=authorization_code&client_id=\(clientID)&client_secret=\(clientSecret)&redirect_uri=\(encodedRedirectURI)"
|
||||
|
||||
let requestURL = URL(string: "/api/oauth2/token", relativeTo: self.baseURL)!
|
||||
|
||||
var request = URLRequest(url: requestURL)
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body.data(using: .utf8)
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
struct Response: Decodable
|
||||
{
|
||||
var access_token: String
|
||||
var refresh_token: String
|
||||
}
|
||||
|
||||
self.send(request, authorizationType: .none) { (result: Result<Response, Swift.Error>) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): completion(.failure(error))
|
||||
case .success(let response): completion(.success((response.access_token, response.refresh_token)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshAccessToken(completion: @escaping (Result<Void, Swift.Error>) -> Void)
|
||||
{
|
||||
guard let refreshToken = Keychain.shared.patreonRefreshToken else { return }
|
||||
|
||||
var components = URLComponents(string: "/api/oauth2/token")!
|
||||
components.queryItems = [URLQueryItem(name: "grant_type", value: "refresh_token"),
|
||||
URLQueryItem(name: "refresh_token", value: refreshToken),
|
||||
URLQueryItem(name: "client_id", value: clientID),
|
||||
URLQueryItem(name: "client_secret", value: clientSecret)]
|
||||
|
||||
let requestURL = components.url(relativeTo: self.baseURL)!
|
||||
|
||||
var request = URLRequest(url: requestURL)
|
||||
request.httpMethod = "POST"
|
||||
|
||||
struct Response: Decodable
|
||||
{
|
||||
var access_token: String
|
||||
var refresh_token: String
|
||||
}
|
||||
|
||||
self.send(request, authorizationType: .none) { (result: Result<Response, Swift.Error>) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): completion(.failure(error))
|
||||
case .success(let response):
|
||||
Keychain.shared.patreonAccessToken = response.access_token
|
||||
Keychain.shared.patreonRefreshToken = response.refresh_token
|
||||
|
||||
completion(.success(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func send<ResponseType: Decodable>(_ request: URLRequest, authorizationType: AuthorizationType, completion: @escaping (Result<ResponseType, Swift.Error>) -> Void)
|
||||
{
|
||||
var request = request
|
||||
|
||||
switch authorizationType
|
||||
{
|
||||
case .none: break
|
||||
case .creator:
|
||||
guard let creatorAccessToken = Keychain.shared.patreonCreatorAccessToken else { return completion(.failure(PatreonAPIError(.invalidAccessToken))) }
|
||||
request.setValue("Bearer " + creatorAccessToken, forHTTPHeaderField: "Authorization")
|
||||
|
||||
case .user:
|
||||
guard let accessToken = Keychain.shared.patreonAccessToken else { return completion(.failure(PatreonAPIError(.notAuthenticated))) }
|
||||
request.setValue("Bearer " + accessToken, forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
let task = self.session.dataTask(with: request) { (data, response, error) in
|
||||
do
|
||||
{
|
||||
let data = try Result(data, error).get()
|
||||
|
||||
if let response = response as? HTTPURLResponse, response.statusCode == 401
|
||||
{
|
||||
switch authorizationType
|
||||
{
|
||||
case .creator: completion(.failure(PatreonAPIError(.invalidAccessToken)))
|
||||
case .none: completion(.failure(PatreonAPIError(.notAuthenticated)))
|
||||
case .user:
|
||||
self.refreshAccessToken() { (result) in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error): completion(.failure(error))
|
||||
case .success: self.send(request, authorizationType: authorizationType, completion: completion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let response = try JSONDecoder().decode(ResponseType.self, from: data)
|
||||
completion(.success(response))
|
||||
}
|
||||
catch let error
|
||||
{
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
|
||||
task.resume()
|
||||
}
|
||||
}
|
||||
|
||||
extension PatreonAPI: WKURLSchemeHandler
|
||||
{
|
||||
public func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask)
|
||||
{
|
||||
guard let authContinuation else { return }
|
||||
self.authContinuation = nil
|
||||
|
||||
if let callbackURL = urlSchemeTask.request.url
|
||||
{
|
||||
authContinuation.resume(returning: callbackURL)
|
||||
}
|
||||
else
|
||||
{
|
||||
authContinuation.resume(throwing: URLError(.badURL))
|
||||
}
|
||||
}
|
||||
|
||||
public func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask)
|
||||
{
|
||||
Logger.main.debug("WKWebView stopped handling url scheme.")
|
||||
}
|
||||
}
|
||||
|
||||
extension PatreonAPI: WKUIDelegate
|
||||
{
|
||||
public func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView?
|
||||
{
|
||||
// Signing in with Google requires us to use separate windows/"tabs"
|
||||
|
||||
Logger.main.debug("Intercepting new window request: \(navigationAction.request)")
|
||||
|
||||
let webViewController = WebViewController(request: navigationAction.request, configuration: configuration)
|
||||
webViewController.delegate = self
|
||||
webViewController.webView.uiDelegate = self
|
||||
self.webViewController?.navigationController?.pushViewController(webViewController, animated: true)
|
||||
|
||||
return webViewController.webView
|
||||
}
|
||||
|
||||
public func webViewDidClose(_ webView: WKWebView)
|
||||
{
|
||||
self.webViewController?.navigationController?.popToRootViewController(animated: true)
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
//
|
||||
// Patron.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/21/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
typealias PatronResponse = DataResponse<PatronAttributes, PatronRelationships>
|
||||
|
||||
struct PatronAttributes: Decodable
|
||||
{
|
||||
var full_name: String?
|
||||
var patron_status: String?
|
||||
var currently_entitled_amount_cents: Int32 // In campaign's currency
|
||||
}
|
||||
|
||||
struct PatronRelationships: Decodable
|
||||
{
|
||||
var campaign: Response<AnyItemResponse>?
|
||||
var currently_entitled_tiers: Response<[AnyItemResponse]>?
|
||||
}
|
||||
}
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
public enum Status: String, Decodable
|
||||
{
|
||||
case active = "active_patron"
|
||||
case declined = "declined_patron"
|
||||
case former = "former_patron"
|
||||
case unknown = "unknown"
|
||||
}
|
||||
|
||||
// Roughly equivalent to AltStoreCore.Pledge
|
||||
public class Patron
|
||||
{
|
||||
public var name: String?
|
||||
public var identifier: String
|
||||
public var pledgeAmount: Decimal?
|
||||
public var status: Status
|
||||
|
||||
// Relationships
|
||||
public var campaign: Campaign?
|
||||
public var tiers: Set<Tier> = []
|
||||
public var benefits: Set<Benefit> = []
|
||||
|
||||
internal init(response: PatronResponse, including included: IncludedResponses?)
|
||||
{
|
||||
self.name = response.attributes.full_name
|
||||
self.identifier = response.id
|
||||
self.pledgeAmount = Decimal(response.attributes.currently_entitled_amount_cents) / 100
|
||||
|
||||
if let status = response.attributes.patron_status
|
||||
{
|
||||
self.status = Status(rawValue: status) ?? .unknown
|
||||
}
|
||||
else
|
||||
{
|
||||
self.status = .unknown
|
||||
}
|
||||
|
||||
guard let included, let relationships = response.relationships else { return }
|
||||
|
||||
if let campaignID = relationships.campaign?.data.id, let response = included.campaigns[campaignID]
|
||||
{
|
||||
let campaign = Campaign(response: response)
|
||||
self.campaign = campaign
|
||||
}
|
||||
|
||||
let tiers = (relationships.currently_entitled_tiers?.data ?? []).compactMap { included.tiers[$0.id] }.map { Tier(response: $0, including: included) }
|
||||
self.tiers = Set(tiers)
|
||||
|
||||
let benefits = tiers.flatMap { $0.benefits }
|
||||
self.benefits = Set(benefits)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
//
|
||||
// Tier.swift
|
||||
// AltStore
|
||||
//
|
||||
// Created by Riley Testut on 8/21/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
typealias TierResponse = DataResponse<TierAttributes, TierRelationships>
|
||||
|
||||
struct TierAttributes: Decodable
|
||||
{
|
||||
var title: String?
|
||||
var amount_cents: Int32 // In USD
|
||||
}
|
||||
|
||||
struct TierRelationships: Decodable
|
||||
{
|
||||
var benefits: Response<[AnyItemResponse]>?
|
||||
}
|
||||
}
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
public struct Tier: Hashable
|
||||
{
|
||||
public var name: String?
|
||||
public var identifier: String
|
||||
public var amount: Decimal
|
||||
|
||||
// Relationships
|
||||
public var benefits: [Benefit] = []
|
||||
|
||||
internal init(response: TierResponse, including included: IncludedResponses?)
|
||||
{
|
||||
self.name = response.attributes.title
|
||||
self.identifier = response.id
|
||||
|
||||
let amount = Decimal(response.attributes.amount_cents) / 100
|
||||
self.amount = amount
|
||||
|
||||
guard let included, let benefitIDs = response.relationships?.benefits?.data.map(\.id) else { return }
|
||||
|
||||
let benefits = benefitIDs.compactMap { included.benefits[$0] }.map(Benefit.init(response:))
|
||||
self.benefits = benefits
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
//
|
||||
// Account.swift
|
||||
// AltStoreCore
|
||||
//
|
||||
// Created by Riley Testut on 11/3/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
typealias UserAccountResponse = DataResponse<UserAccountAttributes, AnyRelationships>
|
||||
|
||||
struct UserAccountAttributes: Decodable
|
||||
{
|
||||
var first_name: String?
|
||||
var full_name: String
|
||||
}
|
||||
}
|
||||
|
||||
extension PatreonAPI
|
||||
{
|
||||
public struct UserAccount
|
||||
{
|
||||
var name: String
|
||||
var firstName: String?
|
||||
var identifier: String
|
||||
|
||||
// Relationships
|
||||
var pledges: [Patron]?
|
||||
|
||||
init(response: UserAccountResponse, including included: IncludedResponses?)
|
||||
{
|
||||
self.identifier = response.id
|
||||
self.name = response.attributes.full_name
|
||||
self.firstName = response.attributes.first_name
|
||||
|
||||
guard let included else { return }
|
||||
|
||||
let patrons = included.patrons.values.compactMap { response -> Patron? in
|
||||
let patron = Patron(response: response, including: included)
|
||||
return patron
|
||||
}
|
||||
|
||||
self.pledges = patrons
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user