diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 7f54c4e1..fd1ad822 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -374,6 +374,9 @@ D552EB062AF453F900A3AB4D /* URL+Normalized.swift in Sources */ = {isa = PBXBuildFile; fileRef = D552EB052AF453F900A3AB4D /* URL+Normalized.swift */; }; D55467B82A8D5E2600F4CE90 /* AppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */; }; D55467C52A8D72C300F4CE90 /* ActiveAppsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */; }; + 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 */; }; 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 */; }; @@ -1047,6 +1050,9 @@ D552EB052AF453F900A3AB4D /* URL+Normalized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Normalized.swift"; sourceTree = ""; }; D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppShortcuts.swift; sourceTree = ""; }; D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveAppsWidget.swift; sourceTree = ""; }; + 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 = ""; }; 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 = ""; }; D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Comparable.swift"; sourceTree = ""; }; @@ -1692,13 +1698,13 @@ D58916FD28C7C55C00E39C8B /* LoggedError.swift */, BF66EEC52501AECA007EE018 /* MergePolicy.swift */, BF66EEBF2501AECA007EE018 /* NewsItem.swift */, - BF66EEC82501AECA007EE018 /* PatreonAccount.swift */, D5CA0C4A280E141900469595 /* ManagedPatron.swift */, BF66EEC32501AECA007EE018 /* RefreshAttempt.swift */, BF66EEC12501AECA007EE018 /* SecureValueTransformer.swift */, BF66EEAB2501AECA007EE018 /* Source.swift */, BF66EEC42501AECA007EE018 /* StoreApp.swift */, BF66EEC22501AECA007EE018 /* Team.swift */, + D557A4862AE88232007D0DCF /* Patreon */, BF66EEAC2501AECA007EE018 /* Migrations */, ); path = Model; @@ -2247,6 +2253,17 @@ path = "App Intents"; sourceTree = ""; }; + D557A4862AE88232007D0DCF /* Patreon */ = { + isa = PBXGroup; + children = ( + BF66EEC82501AECA007EE018 /* PatreonAccount.swift */, + D557A4802AE85BB0007D0DCF /* Pledge.swift */, + D557A4842AE88227007D0DCF /* PledgeTier.swift */, + D557A4822AE85DB7007D0DCF /* PledgeReward.swift */, + ); + path = Patreon; + sourceTree = ""; + }; D55FEC9C2A8FEC600057D6E6 /* Legacy */ = { isa = PBXGroup; children = ( @@ -3117,6 +3134,7 @@ BFBF331B2526762200B7B8C9 /* AltStore8ToAltStore9.xcmappingmodel in Sources */, 0EE7FDC72BE8CF4100D1E390 /* ALTWrappedError.m in Sources */, D5FD4ECB2A9532960097BEE8 /* DatabaseManager+Async.swift in Sources */, + D557A4832AE85DB7007D0DCF /* PledgeReward.swift in Sources */, D5893F802A1419E800E767CD /* NSManagedObjectContext+Conveniences.swift in Sources */, D5CA0C4E280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel in Sources */, D51AD27F29356B7B00967AAA /* ALTWrappedError.m in Sources */, @@ -3166,6 +3184,8 @@ BF66EED62501AECA007EE018 /* NewsItem.swift in Sources */, BF66EEA72501AEC5007EE018 /* Campaign.swift in Sources */, BF66EE992501AEBC007EE018 /* ALTSourceUserInfoKey.m in Sources */, + D557A4812AE85BB0007D0DCF /* Pledge.swift in Sources */, + D557A4852AE88227007D0DCF /* PledgeTier.swift in Sources */, BF989185250AAD1D002ACF50 /* UIColor+AltStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 14.xcdatamodel/contents b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 14.xcdatamodel/contents index 461e84bd..0e2fb7df 100644 --- a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 14.xcdatamodel/contents +++ b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 14.xcdatamodel/contents @@ -154,6 +154,7 @@ + @@ -169,6 +170,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AltStoreCore/Model/MergePolicy.swift b/AltStoreCore/Model/MergePolicy.swift index 4bac336c..4e6f0f4c 100644 --- a/AltStoreCore/Model/MergePolicy.swift +++ b/AltStoreCore/Model/MergePolicy.swift @@ -262,7 +262,36 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy { featuredAppIDsBySourceID[databaseObject.identifier] = contextSource.featuredApps?.map { $0.bundleIdentifier } } - + + 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 } } diff --git a/AltStoreCore/Model/PatreonAccount.swift b/AltStoreCore/Model/Patreon/PatreonAccount.swift similarity index 89% rename from AltStoreCore/Model/PatreonAccount.swift rename to AltStoreCore/Model/Patreon/PatreonAccount.swift index 94707a5d..af668c3e 100644 --- a/AltStoreCore/Model/PatreonAccount.swift +++ b/AltStoreCore/Model/Patreon/PatreonAccount.swift @@ -18,6 +18,10 @@ public class PatreonAccount: NSManagedObject, Fetchable @NSManaged public var isPatron: Bool + /* Relationships */ + @nonobjc public var pledges: Set { _pledges as! Set } + @NSManaged @objc(pledges) internal var _pledges: NSSet + private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) { super.init(entity: entity, insertInto: context) diff --git a/AltStoreCore/Model/Patreon/Pledge.swift b/AltStoreCore/Model/Patreon/Pledge.swift new file mode 100644 index 00000000..be98b2fe --- /dev/null +++ b/AltStoreCore/Model/Patreon/Pledge.swift @@ -0,0 +1,54 @@ +// +// 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 { _tiers as! Set } + @NSManaged @objc(tiers) internal var _tiers: NSSet + + @nonobjc public var rewards: Set { _rewards as! Set } + @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 + { + return NSFetchRequest(entityName: "Pledge") + } +} diff --git a/AltStoreCore/Model/Patreon/PledgeReward.swift b/AltStoreCore/Model/Patreon/PledgeReward.swift new file mode 100644 index 00000000..2bd2924e --- /dev/null +++ b/AltStoreCore/Model/Patreon/PledgeReward.swift @@ -0,0 +1,42 @@ +// +// 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 + { + return NSFetchRequest(entityName: "PledgeReward") + } +} diff --git a/AltStoreCore/Model/Patreon/PledgeTier.swift b/AltStoreCore/Model/Patreon/PledgeTier.swift new file mode 100644 index 00000000..51cf330e --- /dev/null +++ b/AltStoreCore/Model/Patreon/PledgeTier.swift @@ -0,0 +1,46 @@ +// +// 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 + { + return NSFetchRequest(entityName: "PledgeTier") + } +} diff --git a/AltStoreCore/Patreon/PatreonAPI.swift b/AltStoreCore/Patreon/PatreonAPI.swift index 59fb774e..8f22d28f 100644 --- a/AltStoreCore/Patreon/PatreonAPI.swift +++ b/AltStoreCore/Patreon/PatreonAPI.swift @@ -116,10 +116,10 @@ public extension PatreonAPI 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[member]", value: "full_name,patron_status")] - URLQueryItem(name: "fields[tier]", value: "title"), + 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) @@ -152,9 +152,9 @@ public extension PatreonAPI { 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"), + URLQueryItem(name: "fields[tier]", value: "title,amount_cents"), URLQueryItem(name: "fields[benefit]", value: "title"), - URLQueryItem(name: "fields[member]", value: "full_name,patron_status"), + 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)! diff --git a/AltStoreCore/Patreon/Patron.swift b/AltStoreCore/Patreon/Patron.swift index 04a76e43..b1429407 100644 --- a/AltStoreCore/Patreon/Patron.swift +++ b/AltStoreCore/Patreon/Patron.swift @@ -16,6 +16,7 @@ extension PatreonAPI { var full_name: String? var patron_status: String? + var currently_entitled_amount_cents: Int32 // In campaign's currency } struct PatronRelationships: Decodable @@ -40,6 +41,7 @@ extension PatreonAPI { public var name: String? public var identifier: String + public var pledgeAmount: Decimal? public var status: Status // Relationships @@ -51,6 +53,7 @@ extension PatreonAPI { 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 { diff --git a/AltStoreCore/Patreon/Tier.swift b/AltStoreCore/Patreon/Tier.swift index f26c670a..34485bf8 100644 --- a/AltStoreCore/Patreon/Tier.swift +++ b/AltStoreCore/Patreon/Tier.swift @@ -15,6 +15,7 @@ extension PatreonAPI struct TierAttributes: Decodable { var title: String + var amount_cents: Int32 // In USD } struct TierRelationships: Decodable @@ -29,6 +30,7 @@ extension PatreonAPI { public var name: String public var identifier: String + public var amount: Decimal // Relationships public var benefits: [Benefit] = [] @@ -38,6 +40,9 @@ extension PatreonAPI 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:))