From c04d63ba9d8b23e1a8b9e1954dce840a156c5bf8 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Wed, 15 Nov 2023 13:20:50 -0600 Subject: [PATCH 01/20] [AltStoreCore] Generalizes Source.sourceID(from:) logic into URL.normalized() Allows comparing URLs that may have slight (but irrelevant) differences (e.g. trailing slashes). --- AltStore.xcodeproj/project.pbxproj | 4 ++ AltStoreCore/Extensions/URL+Normalized.swift | 60 ++++++++++++++++++++ AltStoreCore/Model/Source.swift | 46 +-------------- 3 files changed, 66 insertions(+), 44 deletions(-) create mode 100644 AltStoreCore/Extensions/URL+Normalized.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 79ae53c5..a61cbcc6 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -373,6 +373,7 @@ D5418F172AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */; }; D54DED1428CBC44B008B27A0 /* ErrorLogTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */; }; D552B1D82A042A740066216F /* AppPermissionsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D552B1D72A042A740066216F /* AppPermissionsCard.swift */; }; + 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 */; }; D561B2EB28EF5A4F006752E4 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; }; @@ -986,6 +987,7 @@ D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshotCollectionViewCell.swift; sourceTree = ""; }; D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorLogTableViewCell.swift; sourceTree = ""; }; D552B1D72A042A740066216F /* AppPermissionsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionsCard.swift; sourceTree = ""; }; + 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 = ""; }; D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Regex+Permissions.swift"; sourceTree = ""; }; @@ -1609,6 +1611,7 @@ D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */, D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */, D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */, + D552EB052AF453F900A3AB4D /* URL+Normalized.swift */, ); path = Extensions; sourceTree = ""; @@ -3042,6 +3045,7 @@ BF66EECD2501AECA007EE018 /* StoreAppPolicy.swift in Sources */, BF66EEE82501AED0007EE018 /* UserDefaults+AltStore.swift in Sources */, BF340E9A250AD39500A192CB /* ViewApp.intentdefinition in Sources */, + D552EB062AF453F900A3AB4D /* URL+Normalized.swift in Sources */, BFAECC522501B0A400528F27 /* CodableError.swift in Sources */, D5F9821D2AB900060045751F /* AppScreenshot.swift in Sources */, BF66EE9E2501AEC1007EE018 /* Fetchable.swift in Sources */, diff --git a/AltStoreCore/Extensions/URL+Normalized.swift b/AltStoreCore/Extensions/URL+Normalized.swift new file mode 100644 index 00000000..7b4e161f --- /dev/null +++ b/AltStoreCore/Extensions/URL+Normalized.swift @@ -0,0 +1,60 @@ +// +// URL+Normalized.swift +// AltStoreCore +// +// Created by Riley Testut on 11/2/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import Foundation + +public extension URL +{ + func normalized() throws -> String + { + // Based on https://encyclopedia.pub/entry/29841 + + guard var components = URLComponents(url: self, resolvingAgainstBaseURL: true) else { throw URLError(.badURL, userInfo: [NSURLErrorKey: self, NSURLErrorFailingURLErrorKey: self]) } + + if components.scheme == nil && components.host == nil + { + // Special handling for URLs without explicit scheme & incorrectly assumed to have nil host (e.g. "altstore.io/my/path") + guard let updatedComponents = URLComponents(string: "https://" + self.absoluteString) else { throw URLError(.cannotFindHost, userInfo: [NSURLErrorKey: self, NSURLErrorFailingURLErrorKey: self]) } + components = updatedComponents + } + + // 1. Don't use percent encoding + guard let host = components.host else { throw URLError(.cannotFindHost, userInfo: [NSURLErrorKey: self, NSURLErrorFailingURLErrorKey: self]) } + + // 2. Ignore scheme + var normalizedURL = host + + // 3. Add port (if not default) + if let port = components.port, port != 80 && port != 443 + { + normalizedURL += ":" + String(port) + } + + // 4. Add path without fragment or query parameters + // 5. Remove duplicate slashes + let path = components.path.replacingOccurrences(of: "//", with: "/") // Only remove duplicate slashes from path, not entire URL. + normalizedURL += path // path has leading `/` + + // 6. Convert to lowercase + normalizedURL = normalizedURL.lowercased() + + // 7. Remove trailing `/` + if normalizedURL.hasSuffix("/") + { + normalizedURL.removeLast() + } + + // 8. Remove leading "www" + if normalizedURL.hasPrefix("www.") + { + normalizedURL.removeFirst(4) + } + + return normalizedURL + } +} diff --git a/AltStoreCore/Model/Source.swift b/AltStoreCore/Model/Source.swift index 2d958c92..c733f555 100644 --- a/AltStoreCore/Model/Source.swift +++ b/AltStoreCore/Model/Source.swift @@ -252,50 +252,8 @@ internal extension Source { class func sourceID(from sourceURL: URL) throws -> String { - // Based on https://encyclopedia.pub/entry/29841 - - guard var components = URLComponents(url: sourceURL, resolvingAgainstBaseURL: true) else { throw URLError(.badURL, userInfo: [NSURLErrorKey: sourceURL]) } - - if components.scheme == nil && components.host == nil - { - // Special handling for URLs without explicit scheme & incorrectly assumed to have nil host (e.g. "altstore.io/my/path") - guard let updatedComponents = URLComponents(string: "https://" + sourceURL.absoluteString) else { throw URLError(.cannotFindHost, userInfo: [NSURLErrorKey: sourceURL]) } - components = updatedComponents - } - - // 1. Don't use percent encoding - guard let host = components.host else { throw URLError(.cannotFindHost, userInfo: [NSURLErrorKey: sourceURL]) } - - // 2. Ignore scheme - var standardizedID = host - - // 3. Add port (if not default) - if let port = components.port, port != 80 && port != 443 - { - standardizedID += ":" + String(port) - } - - // 4. Add path without fragment or query parameters - // 5. Remove duplicate slashes - let path = components.path.replacingOccurrences(of: "//", with: "/") // Only remove duplicate slashes from path, not entire URL. - standardizedID += path // path has leading `/` - - // 6. Convert to lowercase - standardizedID = standardizedID.lowercased() - - // 7. Remove trailing `/` - if standardizedID.hasSuffix("/") - { - standardizedID.removeLast() - } - - // 8. Remove leading "www" - if standardizedID.hasPrefix("www.") - { - standardizedID.removeFirst(4) - } - - return standardizedID + let sourceID = try sourceURL.normalized() + return sourceID } func setFeaturedApps(_ featuredApps: [StoreApp]?) From 417837049f44e0e9a36464d514a66d64d2f3a7bc Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Wed, 15 Nov 2023 13:41:05 -0600 Subject: [PATCH 02/20] [AltStoreCore] Updates StoreApp to support Patreon-exclusive apps --- .../AltStore 14.xcdatamodel/contents | 8 +++- AltStoreCore/Model/Source.swift | 5 +- AltStoreCore/Model/StoreApp.swift | 48 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 14.xcdatamodel/contents b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 14.xcdatamodel/contents index dc9d4873..461e84bd 100644 --- a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 14.xcdatamodel/contents +++ b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 14.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -188,6 +188,7 @@ + @@ -207,8 +208,13 @@ + + + + + diff --git a/AltStoreCore/Model/Source.swift b/AltStoreCore/Model/Source.swift index c733f555..0d493ec8 100644 --- a/AltStoreCore/Model/Source.swift +++ b/AltStoreCore/Model/Source.swift @@ -63,8 +63,9 @@ public class Source: NSManagedObject, Fetchable, Decodable /* Source Detail */ @NSManaged public var subtitle: String? - @NSManaged public var websiteURL: URL? @NSManaged public var localizedDescription: String? + @NSManaged public var websiteURL: URL? + @NSManaged public var patreonURL: URL? // Optional properties with fallbacks. // `private` to prevent accidentally using instead of `effective[PropertyName]` @@ -117,6 +118,7 @@ public class Source: NSManagedObject, Fetchable, Decodable case headerImageURL = "headerURL" case websiteURL = "website" case tintColor + case patreonURL case apps case news @@ -147,6 +149,7 @@ public class Source: NSManagedObject, Fetchable, Decodable self.localizedDescription = try container.decodeIfPresent(String.self, forKey: .localizedDescription) self.iconURL = try container.decodeIfPresent(URL.self, forKey: .iconURL) self.headerImageURL = try container.decodeIfPresent(URL.self, forKey: .headerImageURL) + self.patreonURL = try container.decodeIfPresent(URL.self, forKey: .patreonURL) if let tintColorHex = try container.decodeIfPresent(String.self, forKey: .tintColor) { diff --git a/AltStoreCore/Model/StoreApp.swift b/AltStoreCore/Model/StoreApp.swift index 2a5035f8..a25db30a 100644 --- a/AltStoreCore/Model/StoreApp.swift +++ b/AltStoreCore/Model/StoreApp.swift @@ -25,6 +25,18 @@ public extension StoreApp static let dolphinAppID = "me.oatmealdome.dolphinios-njb" } +extension StoreApp +{ + private struct PatreonParameters: Decodable + { + var pledge: Decimal? + var currency: String? + var tiers: Set? + var benefit: String? + var hidden: Bool? + } +} + @objc(StoreApp) public class StoreApp: NSManagedObject, Decodable, Fetchable { @@ -44,6 +56,14 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable @NSManaged public private(set) var tintColor: UIColor? @NSManaged public private(set) var isBeta: Bool + @NSManaged public var isPledged: Bool + @NSManaged public private(set) var isPledgeRequired: Bool + @NSManaged public private(set) var isHiddenWithoutPledge: Bool + @NSManaged public private(set) var pledgeCurrency: String? + + @nonobjc public var pledgeAmount: Decimal? { _pledgeAmount as? Decimal } + @NSManaged @objc(pledgeAmount) private var _pledgeAmount: NSDecimalNumber? + @NSManaged public var sortIndex: Int32 @objc public internal(set) var sourceIdentifier: String? { @@ -137,6 +157,7 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable case size case isBeta = "beta" case versions + case patreon // Legacy case version @@ -247,6 +268,33 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable in: context) try self.setVersions([appVersion]) } + + // Must _explicitly_ set to false to ensure it updates cached database value. + self.isPledged = false + + if let patreon = try container.decodeIfPresent(PatreonParameters.self, forKey: .patreon) + { + self.isPledgeRequired = true + self.isHiddenWithoutPledge = patreon.hidden ?? false // Default to showing Patreon apps + + if let pledge = patreon.pledge + { + self._pledgeAmount = pledge as NSDecimalNumber + self.pledgeCurrency = patreon.currency ?? "USD" // Only set pledge currency if explicitly given pledge. + } + else if patreon.pledge == nil && patreon.tiers == nil && patreon.benefit == nil + { + // No conditions, so default to pledgeAmount of 0 to simplify logic. + self._pledgeAmount = 0 as NSDecimalNumber + } + } + else + { + self.isPledgeRequired = false + self.isHiddenWithoutPledge = false + self._pledgeAmount = nil + self.pledgeCurrency = nil + } } catch { From 7ed2dc8291bf4596324f84dbd48cb1fd7bf9bc58 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Wed, 15 Nov 2023 14:13:58 -0600 Subject: [PATCH 03/20] [AltStoreCore] Refactors PatreonAPI to reduce duplicate logic --- AltStore.xcodeproj/project.pbxproj | 26 ++- AltStoreCore/AltStoreCore.h | 2 +- AltStoreCore/Model/ManagedPatron.swift | 2 +- AltStoreCore/Model/PatreonAccount.swift | 35 +--- AltStoreCore/Patreon/Benefit.swift | 21 ++- AltStoreCore/Patreon/Campaign.swift | 21 ++- .../Patreon/PatreonAPI+Responses.swift | 161 ++++++++++++++++++ AltStoreCore/Patreon/PatreonAPI.swift | 92 +++------- AltStoreCore/Patreon/Patron.swift | 95 ++++++----- AltStoreCore/Patreon/Tier.swift | 57 +++---- AltStoreCore/Patreon/UserAccount.swift | 49 ++++++ AltStoreCore/Types/ALTPatreonBenefitID.h | 13 ++ AltStoreCore/Types/ALTPatreonBenefitID.m | 12 ++ AltStoreCore/Types/ALTPatreonBenefitType.h | 13 -- AltStoreCore/Types/ALTPatreonBenefitType.m | 12 -- 15 files changed, 389 insertions(+), 222 deletions(-) create mode 100644 AltStoreCore/Patreon/PatreonAPI+Responses.swift create mode 100644 AltStoreCore/Patreon/UserAccount.swift create mode 100644 AltStoreCore/Types/ALTPatreonBenefitID.h create mode 100644 AltStoreCore/Types/ALTPatreonBenefitID.m delete mode 100644 AltStoreCore/Types/ALTPatreonBenefitType.h delete mode 100644 AltStoreCore/Types/ALTPatreonBenefitType.m diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index a61cbcc6..ddc2cdf6 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -133,9 +133,9 @@ BF66EE8C2501AEB2007EE018 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE8B2501AEB1007EE018 /* Keychain.swift */; }; BF66EE942501AEBC007EE018 /* ALTAppPermissions.h in Headers */ = {isa = PBXBuildFile; fileRef = BF66EE8E2501AEBC007EE018 /* ALTAppPermissions.h */; settings = {ATTRIBUTES = (Public, ); }; }; BF66EE952501AEBC007EE018 /* ALTSourceUserInfoKey.h in Headers */ = {isa = PBXBuildFile; fileRef = BF66EE8F2501AEBC007EE018 /* ALTSourceUserInfoKey.h */; settings = {ATTRIBUTES = (Public, ); }; }; - BF66EE962501AEBC007EE018 /* ALTPatreonBenefitType.m in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE902501AEBC007EE018 /* ALTPatreonBenefitType.m */; }; + BF66EE962501AEBC007EE018 /* ALTPatreonBenefitID.m in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE902501AEBC007EE018 /* ALTPatreonBenefitID.m */; }; BF66EE972501AEBC007EE018 /* ALTAppPermissions.m in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE912501AEBC007EE018 /* ALTAppPermissions.m */; }; - BF66EE982501AEBC007EE018 /* ALTPatreonBenefitType.h in Headers */ = {isa = PBXBuildFile; fileRef = BF66EE922501AEBC007EE018 /* ALTPatreonBenefitType.h */; settings = {ATTRIBUTES = (Public, ); }; }; + BF66EE982501AEBC007EE018 /* ALTPatreonBenefitID.h in Headers */ = {isa = PBXBuildFile; fileRef = BF66EE922501AEBC007EE018 /* ALTPatreonBenefitID.h */; settings = {ATTRIBUTES = (Public, ); }; }; BF66EE992501AEBC007EE018 /* ALTSourceUserInfoKey.m in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE932501AEBC007EE018 /* ALTSourceUserInfoKey.m */; }; BF66EE9D2501AEC1007EE018 /* AppProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE9B2501AEC1007EE018 /* AppProtocol.swift */; }; BF66EE9E2501AEC1007EE018 /* Fetchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE9C2501AEC1007EE018 /* Fetchable.swift */; }; @@ -419,6 +419,8 @@ D5A299872AAB9E4E00A3988D /* ProcessError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FB7A1B2AA284ED00EF863D /* ProcessError.swift */; }; D5A299882AAB9E4E00A3988D /* JITError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A1D2E32AA50EB60066CACC /* JITError.swift */; }; D5A299892AAB9E5900A3988D /* AppProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59A6B7D2AA9226C00F61259 /* AppProcess.swift */; }; + D5A645232AF5B5C50047D980 /* PatreonAPI+Responses.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A645222AF5B5C50047D980 /* PatreonAPI+Responses.swift */; }; + D5A645252AF5BC7F0047D980 /* UserAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A645242AF5BC7F0047D980 /* UserAccount.swift */; }; D5ACE84528E3B8450021CAB9 /* ClearAppCacheOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */; }; D5B6F6A92AD75D01007EED5A /* ProcessInfo+Previews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */; }; D5B6F6AB2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */; }; @@ -754,9 +756,9 @@ BF66EE8B2501AEB1007EE018 /* Keychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; BF66EE8E2501AEBC007EE018 /* ALTAppPermissions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTAppPermissions.h; sourceTree = ""; }; BF66EE8F2501AEBC007EE018 /* ALTSourceUserInfoKey.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTSourceUserInfoKey.h; sourceTree = ""; }; - BF66EE902501AEBC007EE018 /* ALTPatreonBenefitType.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTPatreonBenefitType.m; sourceTree = ""; }; + BF66EE902501AEBC007EE018 /* ALTPatreonBenefitID.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTPatreonBenefitID.m; sourceTree = ""; }; BF66EE912501AEBC007EE018 /* ALTAppPermissions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTAppPermissions.m; sourceTree = ""; }; - BF66EE922501AEBC007EE018 /* ALTPatreonBenefitType.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTPatreonBenefitType.h; sourceTree = ""; }; + BF66EE922501AEBC007EE018 /* ALTPatreonBenefitID.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTPatreonBenefitID.h; sourceTree = ""; }; BF66EE932501AEBC007EE018 /* ALTSourceUserInfoKey.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTSourceUserInfoKey.m; sourceTree = ""; }; BF66EE9B2501AEC1007EE018 /* AppProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppProtocol.swift; sourceTree = ""; }; BF66EE9C2501AEC1007EE018 /* Fetchable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Fetchable.swift; sourceTree = ""; }; @@ -1030,6 +1032,8 @@ D5A1D2E82AA512940066CACC /* RemoteServiceDiscoveryTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteServiceDiscoveryTunnel.swift; sourceTree = ""; }; D5A1D2EA2AA513410066CACC /* URL+Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Tools.swift"; sourceTree = ""; }; D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeprecatedAPIs.swift; sourceTree = ""; }; + D5A645222AF5B5C50047D980 /* PatreonAPI+Responses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PatreonAPI+Responses.swift"; sourceTree = ""; }; + D5A645242AF5BC7F0047D980 /* UserAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAccount.swift; sourceTree = ""; }; D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearAppCacheOperation.swift; sourceTree = ""; }; D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+Previews.swift"; sourceTree = ""; }; D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewAppScreenshotsViewController.swift; sourceTree = ""; }; @@ -1498,8 +1502,8 @@ D5893F812A141E4900E767CD /* KnownSource.swift */, BF66EE8E2501AEBC007EE018 /* ALTAppPermissions.h */, BF66EE912501AEBC007EE018 /* ALTAppPermissions.m */, - BF66EE922501AEBC007EE018 /* ALTPatreonBenefitType.h */, - BF66EE902501AEBC007EE018 /* ALTPatreonBenefitType.m */, + BF66EE922501AEBC007EE018 /* ALTPatreonBenefitID.h */, + BF66EE902501AEBC007EE018 /* ALTPatreonBenefitID.m */, BF66EE8F2501AEBC007EE018 /* ALTSourceUserInfoKey.h */, BF66EE932501AEBC007EE018 /* ALTSourceUserInfoKey.m */, ); @@ -1520,11 +1524,13 @@ BF66EE9F2501AEC5007EE018 /* Patreon */ = { isa = PBXGroup; children = ( + BF66EEA12501AEC5007EE018 /* PatreonAPI.swift */, + D5A645222AF5B5C50047D980 /* PatreonAPI+Responses.swift */, BF66EEA02501AEC5007EE018 /* Benefit.swift */, BF66EEA22501AEC5007EE018 /* Campaign.swift */, - BF66EEA12501AEC5007EE018 /* PatreonAPI.swift */, BF66EEA32501AEC5007EE018 /* Patron.swift */, BF66EEA42501AEC5007EE018 /* Tier.swift */, + D5A645242AF5BC7F0047D980 /* UserAccount.swift */, ); path = Patreon; sourceTree = ""; @@ -2331,7 +2337,7 @@ files = ( BF66EE822501AE50007EE018 /* AltStoreCore.h in Headers */, BF66EE952501AEBC007EE018 /* ALTSourceUserInfoKey.h in Headers */, - BF66EE982501AEBC007EE018 /* ALTPatreonBenefitType.h in Headers */, + BF66EE982501AEBC007EE018 /* ALTPatreonBenefitID.h in Headers */, BFAECC5F2501B0BF00528F27 /* ALTConstants.h in Headers */, BFAECC5D2501B0BF00528F27 /* ALTConnection.h in Headers */, BF66EE942501AEBC007EE018 /* ALTAppPermissions.h in Headers */, @@ -3040,6 +3046,7 @@ BF66EED32501AECA007EE018 /* AltStore2ToAltStore3.xcmappingmodel in Sources */, BF66EEA52501AEC5007EE018 /* Benefit.swift in Sources */, BF66EED22501AECA007EE018 /* AltStore4ToAltStore5.xcmappingmodel in Sources */, + D5A645252AF5BC7F0047D980 /* UserAccount.swift in Sources */, D5189C022A01BC6800F44625 /* UserInfoValue.swift in Sources */, BFAECC5B2501B0A400528F27 /* Bundle+AltStore.swift in Sources */, BF66EECD2501AECA007EE018 /* StoreAppPolicy.swift in Sources */, @@ -3076,7 +3083,7 @@ D5CA0C4E280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel in Sources */, D51AD27F29356B7B00967AAA /* ALTWrappedError.m in Sources */, BF989184250AACFC002ACF50 /* Date+RelativeDate.swift in Sources */, - BF66EE962501AEBC007EE018 /* ALTPatreonBenefitType.m in Sources */, + BF66EE962501AEBC007EE018 /* ALTPatreonBenefitID.m in Sources */, BFAECC5A2501B0A400528F27 /* NetworkConnection.swift in Sources */, D5F99A1828D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel in Sources */, BF66EEE92501AED0007EE018 /* JSONDecoder+Properties.swift in Sources */, @@ -3108,6 +3115,7 @@ BF66EECC2501AECA007EE018 /* Source.swift in Sources */, BF66EED72501AECA007EE018 /* InstalledApp.swift in Sources */, D5DB145A28F9DC5A00A8F606 /* ALTLocalizedError.swift in Sources */, + D5A645232AF5B5C50047D980 /* PatreonAPI+Responses.swift in Sources */, D5185B822AE1E71D00646E33 /* Source13To14MigrationPolicy.swift in Sources */, BF66EECE2501AECA007EE018 /* InstalledAppPolicy.swift in Sources */, BF1FE359251A9FB000C3CE09 /* NSXPCConnection+MachServices.swift in Sources */, diff --git a/AltStoreCore/AltStoreCore.h b/AltStoreCore/AltStoreCore.h index c3a5a398..1b14de51 100644 --- a/AltStoreCore/AltStoreCore.h +++ b/AltStoreCore/AltStoreCore.h @@ -18,7 +18,7 @@ FOUNDATION_EXPORT const unsigned char AltStoreCoreVersionString[]; #import #import -#import +#import // Shared #import diff --git a/AltStoreCore/Model/ManagedPatron.swift b/AltStoreCore/Model/ManagedPatron.swift index 1e628b40..c5d67ac9 100644 --- a/AltStoreCore/Model/ManagedPatron.swift +++ b/AltStoreCore/Model/ManagedPatron.swift @@ -19,7 +19,7 @@ public class ManagedPatron: NSManagedObject, Fetchable super.init(entity: entity, insertInto: context) } - public init?(patron: Patron, context: NSManagedObjectContext) + public init?(patron: PatreonAPI.Patron, context: NSManagedObjectContext) { // Only cache Patrons with non-nil names. guard let name = patron.name else { return nil } diff --git a/AltStoreCore/Model/PatreonAccount.swift b/AltStoreCore/Model/PatreonAccount.swift index 95259c1e..2c0e6c4e 100644 --- a/AltStoreCore/Model/PatreonAccount.swift +++ b/AltStoreCore/Model/PatreonAccount.swift @@ -8,27 +8,6 @@ import CoreData -extension PatreonAPI -{ - struct AccountResponse: Decodable - { - struct Data: Decodable - { - struct Attributes: Decodable - { - var first_name: String? - var full_name: String - } - - var id: String - var attributes: Attributes - } - - var data: Data - var included: [PatronResponse]? - } -} - @objc(PatreonAccount) public class PatreonAccount: NSManagedObject, Fetchable { @@ -44,18 +23,18 @@ public class PatreonAccount: NSManagedObject, Fetchable super.init(entity: entity, insertInto: context) } - init(response: PatreonAPI.AccountResponse, context: NSManagedObjectContext) + init(account: PatreonAPI.UserAccount, context: NSManagedObjectContext) { super.init(entity: PatreonAccount.entity(), insertInto: context) - self.identifier = response.data.id - self.name = response.data.attributes.full_name - self.firstName = response.data.attributes.first_name + self.identifier = account.identifier + self.name = account.name + self.firstName = account.firstName - if let patronResponse = response.included?.first + if let altstorePledge = account.pledges?.first(where: { $0.campaign?.identifier == PatreonAPI.altstoreCampaignID }) { - let patron = Patron(response: patronResponse) - self.isPatron = (patron.status == .active) + let isActivePatron = (altstorePledge.status == .active) + self.isPatron = isActivePatron } else { diff --git a/AltStoreCore/Patreon/Benefit.swift b/AltStoreCore/Patreon/Benefit.swift index 63a29e9a..c1556b24 100644 --- a/AltStoreCore/Patreon/Benefit.swift +++ b/AltStoreCore/Patreon/Benefit.swift @@ -10,18 +10,25 @@ import Foundation extension PatreonAPI { - struct BenefitResponse: Decodable + typealias BenefitResponse = DataResponse + + struct BenefitAttributes: Decodable { - var id: String + var title: String } } -public struct Benefit: Hashable +extension PatreonAPI { - public var type: ALTPatreonBenefitType - - init(response: PatreonAPI.BenefitResponse) + public struct Benefit: Hashable { - self.type = ALTPatreonBenefitType(response.id) + public var name: String + public var identifier: ALTPatreonBenefitID + + internal init(response: BenefitResponse) + { + self.name = response.attributes.title + self.identifier = ALTPatreonBenefitID(response.id) + } } } diff --git a/AltStoreCore/Patreon/Campaign.swift b/AltStoreCore/Patreon/Campaign.swift index 00153eb9..68a1ea6d 100644 --- a/AltStoreCore/Patreon/Campaign.swift +++ b/AltStoreCore/Patreon/Campaign.swift @@ -10,18 +10,25 @@ import Foundation extension PatreonAPI { - struct CampaignResponse: Decodable + typealias CampaignResponse = DataResponse + + struct CampaignAttributes: Decodable { - var id: String + var url: URL } } -public struct Campaign +extension PatreonAPI { - public var identifier: String - - init(response: PatreonAPI.CampaignResponse) + public struct Campaign { - self.identifier = response.id + public var identifier: String + public var url: URL + + internal init(response: PatreonAPI.CampaignResponse) + { + self.identifier = response.id + self.url = response.attributes.url + } } } diff --git a/AltStoreCore/Patreon/PatreonAPI+Responses.swift b/AltStoreCore/Patreon/PatreonAPI+Responses.swift new file mode 100644 index 00000000..693d5e10 --- /dev/null +++ b/AltStoreCore/Patreon/PatreonAPI+Responses.swift @@ -0,0 +1,161 @@ +// +// 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: Decodable + { + var data: Data + + var included: IncludedResponses? + var links: [String: URL]? + } + + struct AnyItemResponse: ItemResponse + { + var id: String + var type: String + } + + struct DataResponse: 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) + } + } + } +} diff --git a/AltStoreCore/Patreon/PatreonAPI.swift b/AltStoreCore/Patreon/PatreonAPI.swift index f5f4cd82..3c482bcc 100644 --- a/AltStoreCore/Patreon/PatreonAPI.swift +++ b/AltStoreCore/Patreon/PatreonAPI.swift @@ -13,8 +13,6 @@ import CoreData private let clientID = "ZMx0EGUWe4TVWYXNZZwK_fbIK5jHFVWoUf1Qb-sqNXmT-YzAGwDPxxq7ak3_W5Q2" private let clientSecret = "1hktsZB89QyN69cB4R0tu55R4TCPQGXxvebYUUh7Y-5TLSnRswuxs6OUjdJ74IJt" -private let campaignID = "2863968" - typealias PatreonAPIError = PatreonAPIErrorCode.Error enum PatreonAPIErrorCode: Int, ALTErrorEnum, CaseIterable { @@ -34,42 +32,17 @@ enum PatreonAPIErrorCode: Int, ALTErrorEnum, CaseIterable extension PatreonAPI { + static let altstoreCampaignID = "2863968" + + typealias FetchAccountResponse = Response + typealias FriendZonePatronsResponse = Response<[PatronResponse]> + enum AuthorizationType { case none case user case creator } - - enum AnyResponse: Decodable - { - case tier(TierResponse) - case benefit(BenefitResponse) - - 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 tier = try TierResponse(from: decoder) - self = .tier(tier) - - case "benefit": - let benefit = try BenefitResponse(from: decoder) - self = .benefit(benefit) - - default: throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unrecognized Patreon response type.") - } - } - } } public class PatreonAPI: NSObject @@ -138,14 +111,17 @@ public extension PatreonAPI func fetchAccount(completion: @escaping (Result) -> Void) { var components = URLComponents(string: "/api/oauth2/v2/identity")! - components.queryItems = [URLQueryItem(name: "include", value: "memberships"), + 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[benefit]", value: "title"), + URLQueryItem(name: "fields[campaign]", value: "url"), let requestURL = components.url(relativeTo: self.baseURL)! let request = URLRequest(url: requestURL) - self.send(request, authorizationType: .user) { (result: Result) in + self.send(request, authorizationType: .user) { (result: Result) in switch result { case .failure(~PatreonAPIErrorCode.notAuthenticated): @@ -153,10 +129,15 @@ public extension PatreonAPI completion(.failure(PatreonAPIError(.notAuthenticated))) } - case .failure(let error): 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(response: response, context: context) + let account = PatreonAccount(account: account, context: context) Keychain.shared.patreonAccountID = account.identifier completion(.success(account)) } @@ -166,57 +147,34 @@ public extension PatreonAPI func fetchPatrons(completion: @escaping (Result<[Patron], Swift.Error>) -> Void) { - var components = URLComponents(string: "/api/oauth2/v2/campaigns/\(campaignID)/members")! + 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[benefit]", value: "title"), URLQueryItem(name: "fields[member]", value: "full_name,patron_status"), URLQueryItem(name: "page[size]", value: "1000")] let requestURL = components.url(relativeTo: self.baseURL)! - struct Response: Decodable - { - var data: [PatronResponse] - var included: [AnyResponse] - var links: [String: URL]? - } - var allPatrons = [Patron]() func fetchPatrons(url: URL) { let request = URLRequest(url: url) - self.send(request, authorizationType: .creator) { (result: Result) in + self.send(request, authorizationType: .creator) { (result: Result) in switch result { case .failure(let error): completion(.failure(error)) - case .success(let response): - let tiers = response.included.compactMap { (response) -> Tier? in - switch response - { - case .tier(let tierResponse): return Tier(response: tierResponse) - case .benefit: return nil - } - } - - let tiersByIdentifier = Dictionary(tiers.map { ($0.identifier, $0) }, uniquingKeysWith: { (a, b) in return a }) - - let patrons = response.data.map { (response) -> Patron in - let patron = Patron(response: response) - - for tierID in response.relationships?.currently_entitled_tiers.data ?? [] - { - guard let tier = tiersByIdentifier[tierID.id] else { continue } - patron.benefits.formUnion(tier.benefits) - } - + 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.type == .credits }) } + }.filter { $0.benefits.contains(where: { $0.identifier == .credits }) } allPatrons.append(contentsOf: patrons) - if let nextURL = response.links?["next"] + if let nextURL = patronsResponse.links?["next"] { fetchPatrons(url: nextURL) } diff --git a/AltStoreCore/Patreon/Patron.swift b/AltStoreCore/Patreon/Patron.swift index ef55bab4..04a76e43 100644 --- a/AltStoreCore/Patreon/Patron.swift +++ b/AltStoreCore/Patreon/Patron.swift @@ -10,38 +10,22 @@ import Foundation extension PatreonAPI { - struct PatronResponse: Decodable + typealias PatronResponse = DataResponse + + struct PatronAttributes: Decodable { - struct Attributes: Decodable - { - var full_name: String? - var patron_status: String? - } - - struct Relationships: Decodable - { - struct Tiers: Decodable - { - struct TierID: Decodable - { - var id: String - var type: String - } - - var data: [TierID] - } - - var currently_entitled_tiers: Tiers - } - - var id: String - var attributes: Attributes - - var relationships: Relationships? + var full_name: String? + var patron_status: String? + } + + struct PatronRelationships: Decodable + { + var campaign: Response? + var currently_entitled_tiers: Response<[AnyItemResponse]>? } } -extension Patron +extension PatreonAPI { public enum Status: String, Decodable { @@ -50,29 +34,46 @@ extension Patron case former = "former_patron" case unknown = "unknown" } -} - -public class Patron -{ - public var name: String? - public var identifier: String - public var status: Status - - public var benefits: Set = [] - - init(response: PatreonAPI.PatronResponse) + // Roughly equivalent to AltStoreCore.Pledge + public class Patron { - self.name = response.attributes.full_name - self.identifier = response.id + public var name: String? + public var identifier: String + public var status: Status - if let status = response.attributes.patron_status + // Relationships + public var campaign: Campaign? + public var tiers: Set = [] + public var benefits: Set = [] + + internal init(response: PatronResponse, including included: IncludedResponses?) { - self.status = Status(rawValue: status) ?? .unknown - } - else - { - self.status = .unknown + self.name = response.attributes.full_name + self.identifier = response.id + + 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) } } } diff --git a/AltStoreCore/Patreon/Tier.swift b/AltStoreCore/Patreon/Tier.swift index 0bf0dc88..f26c670a 100644 --- a/AltStoreCore/Patreon/Tier.swift +++ b/AltStoreCore/Patreon/Tier.swift @@ -10,41 +10,38 @@ import Foundation extension PatreonAPI { - struct TierResponse: Decodable + typealias TierResponse = DataResponse + + struct TierAttributes: Decodable { - struct Attributes: Decodable - { - var title: String - } - - struct Relationships: Decodable - { - struct Benefits: Decodable - { - var data: [BenefitResponse] - } - - var benefits: Benefits - } - - var id: String - var attributes: Attributes - - var relationships: Relationships + var title: String + } + + struct TierRelationships: Decodable + { + var benefits: Response<[AnyItemResponse]>? } } -public struct Tier +extension PatreonAPI { - public var name: String - public var identifier: String - - public var benefits: [Benefit] = [] - - init(response: PatreonAPI.TierResponse) + public struct Tier: Hashable { - self.name = response.attributes.title - self.identifier = response.id - self.benefits = response.relationships.benefits.data.map(Benefit.init(response:)) + public var name: String + public var identifier: String + + // Relationships + public var benefits: [Benefit] = [] + + internal init(response: TierResponse, including included: IncludedResponses?) + { + self.name = response.attributes.title + self.identifier = response.id + + 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 + } } } diff --git a/AltStoreCore/Patreon/UserAccount.swift b/AltStoreCore/Patreon/UserAccount.swift new file mode 100644 index 00000000..83e52e47 --- /dev/null +++ b/AltStoreCore/Patreon/UserAccount.swift @@ -0,0 +1,49 @@ +// +// 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 + + 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 + } + } +} diff --git a/AltStoreCore/Types/ALTPatreonBenefitID.h b/AltStoreCore/Types/ALTPatreonBenefitID.h new file mode 100644 index 00000000..e5396489 --- /dev/null +++ b/AltStoreCore/Types/ALTPatreonBenefitID.h @@ -0,0 +1,13 @@ +// +// ALTPatreonBenefitType.h +// AltStore +// +// Created by Riley Testut on 8/27/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +#import + +typedef NSString *ALTPatreonBenefitID NS_TYPED_EXTENSIBLE_ENUM; +extern ALTPatreonBenefitID const ALTPatreonBenefitIDBetaAccess; +extern ALTPatreonBenefitID const ALTPatreonBenefitIDCredits; diff --git a/AltStoreCore/Types/ALTPatreonBenefitID.m b/AltStoreCore/Types/ALTPatreonBenefitID.m new file mode 100644 index 00000000..60e87da1 --- /dev/null +++ b/AltStoreCore/Types/ALTPatreonBenefitID.m @@ -0,0 +1,12 @@ +// +// ALTPatreonBenefitType.m +// AltStore +// +// Created by Riley Testut on 8/27/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +#import "ALTPatreonBenefitID.h" + +ALTPatreonBenefitID const ALTPatreonBenefitIDBetaAccess = @"1186336"; +ALTPatreonBenefitID const ALTPatreonBenefitIDCredits = @"1186340"; diff --git a/AltStoreCore/Types/ALTPatreonBenefitType.h b/AltStoreCore/Types/ALTPatreonBenefitType.h deleted file mode 100644 index 83ad9b66..00000000 --- a/AltStoreCore/Types/ALTPatreonBenefitType.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// ALTPatreonBenefitType.h -// AltStore -// -// Created by Riley Testut on 8/27/19. -// Copyright © 2019 Riley Testut. All rights reserved. -// - -#import - -typedef NSString *ALTPatreonBenefitType NS_TYPED_EXTENSIBLE_ENUM; -extern ALTPatreonBenefitType const ALTPatreonBenefitTypeBetaAccess; -extern ALTPatreonBenefitType const ALTPatreonBenefitTypeCredits; diff --git a/AltStoreCore/Types/ALTPatreonBenefitType.m b/AltStoreCore/Types/ALTPatreonBenefitType.m deleted file mode 100644 index 7f6bf5af..00000000 --- a/AltStoreCore/Types/ALTPatreonBenefitType.m +++ /dev/null @@ -1,12 +0,0 @@ -// -// ALTPatreonBenefitType.m -// AltStore -// -// Created by Riley Testut on 8/27/19. -// Copyright © 2019 Riley Testut. All rights reserved. -// - -#import "ALTPatreonBenefitType.h" - -ALTPatreonBenefitType const ALTPatreonBenefitTypeBetaAccess = @"1186336"; -ALTPatreonBenefitType const ALTPatreonBenefitTypeCredits = @"1186340"; From d59ced920863e57b61ad435f674a1aefb3a54fb7 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Mon, 20 Nov 2023 13:55:28 -0600 Subject: [PATCH 04/20] [AltStoreCore] Adds Pledge, PledgeReward, and PledgeTier Allows us to cache pledges for current user, which can be used to determine if user has access to Patreon-only apps. --- AltStore.xcodeproj/project.pbxproj | 22 +++++++- .../AltStore 14.xcdatamodel/contents | 35 ++++++++++++ AltStoreCore/Model/MergePolicy.swift | 31 ++++++++++- .../Model/{ => Patreon}/PatreonAccount.swift | 21 ++++++++ AltStoreCore/Model/Patreon/Pledge.swift | 54 +++++++++++++++++++ AltStoreCore/Model/Patreon/PledgeReward.swift | 42 +++++++++++++++ AltStoreCore/Model/Patreon/PledgeTier.swift | 46 ++++++++++++++++ AltStoreCore/Patreon/PatreonAPI.swift | 8 +-- AltStoreCore/Patreon/Patron.swift | 3 ++ AltStoreCore/Patreon/Tier.swift | 5 ++ 10 files changed, 261 insertions(+), 6 deletions(-) rename AltStoreCore/Model/{ => Patreon}/PatreonAccount.swift (61%) create mode 100644 AltStoreCore/Model/Patreon/Pledge.swift create mode 100644 AltStoreCore/Model/Patreon/PledgeReward.swift create mode 100644 AltStoreCore/Model/Patreon/PledgeTier.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index ddc2cdf6..71c3d033 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -376,6 +376,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 */; }; @@ -992,6 +995,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 = ""; }; @@ -1551,13 +1557,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; @@ -2177,6 +2183,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 = ( @@ -3079,6 +3096,7 @@ D58916FE28C7C55C00E39C8B /* LoggedError.swift in Sources */, BFBF331B2526762200B7B8C9 /* AltStore8ToAltStore9.xcmappingmodel 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 */, @@ -3127,6 +3145,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 61% rename from AltStoreCore/Model/PatreonAccount.swift rename to AltStoreCore/Model/Patreon/PatreonAccount.swift index 2c0e6c4e..35d4fe01 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) @@ -31,6 +35,23 @@ public class PatreonAccount: NSManagedObject, Fetchable self.name = account.name self.firstName = account.firstName + let pledges = account.pledges?.compactMap { patron -> Pledge? in + // First ensure pledge is active. + guard patron.status == .active else { return nil } + + guard let pledge = Pledge(patron: patron, context: context) else { return nil } + + let tiers = patron.tiers.map { PledgeTier(tier: $0, context: context) } + pledge._tiers = Set(tiers) as NSSet + + let rewards = patron.benefits.map { PledgeReward(benefit: $0, context: context) } + pledge._rewards = Set(rewards) as NSSet + + return pledge + } ?? [] + + self._pledges = Set(pledges) as NSSet + if let altstorePledge = account.pledges?.first(where: { $0.campaign?.identifier == PatreonAPI.altstoreCampaignID }) { let isActivePatron = (altstorePledge.status == .active) 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 3c482bcc..32e18102 100644 --- a/AltStoreCore/Patreon/PatreonAPI.swift +++ b/AltStoreCore/Patreon/PatreonAPI.swift @@ -113,10 +113,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) @@ -149,9 +149,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:)) From a1038d8850c78c4fd5ead7dd0da4083b5199ea3b Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Mon, 20 Nov 2023 14:06:04 -0600 Subject: [PATCH 05/20] Verifies StoreApp.isPledged status when updating source --- .../Operations/FetchSourceOperation.swift | 58 +++++++++++++++++++ AltStoreCore/Model/DatabaseManager.swift | 2 +- AltStoreCore/Model/StoreApp.swift | 12 ++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/AltStore/Operations/FetchSourceOperation.swift b/AltStore/Operations/FetchSourceOperation.swift index d9827560..36c12ef3 100644 --- a/AltStore/Operations/FetchSourceOperation.swift +++ b/AltStore/Operations/FetchSourceOperation.swift @@ -154,6 +154,7 @@ class FetchSourceOperation: ResultOperation let identifier = source.identifier try self.verify(source, response: response) + try self.verifyPledges(for: source, in: childContext) try childContext.save() @@ -223,6 +224,63 @@ private extension FetchSourceOperation } } + func verifyPledges(for source: Source, in context: NSManagedObjectContext) throws + { + guard let patreonURL = source.patreonURL, let patreonAccount = DatabaseManager.shared.patreonAccount(in: context) else { return } + + let normalizedPatreonURL = try patreonURL.normalized() + + guard let pledge = patreonAccount.pledges.first(where: { pledge in + do + { + let normalizedCampaignURL = try pledge.campaignURL.normalized() + return normalizedCampaignURL == normalizedPatreonURL + } + catch + { + Logger.main.error("Failed to normalize Patreon URL \(pledge.campaignURL, privacy: .public). \(error.localizedDescription, privacy: .public)") + return false + } + }) else { return } + + // User is pledged to this source's Patreon, so check which apps they're pledged to. + + // We only assign `isPledged = true` because false is already the default, + // and only one check needs to be true for isPledged to be true. + + for app in source.apps where app.isPledgeRequired + { + if let requiredAppPledge = app.pledgeAmount + { + if pledge.amount >= requiredAppPledge + { + app.isPledged = true + continue + } + } + + if let tierIDs = app._tierIDs + { + let tier = pledge.tiers.first { tierIDs.contains($0.identifier) } + if tier != nil + { + app.isPledged = true + continue + } + } + + if let rewardID = app._rewardID + { + let reward = pledge.rewards.first { $0.identifier == rewardID } + if reward != nil + { + app.isPledged = true + continue + } + } + } + } + func verifySourceNotBlocked(_ source: Source, response: URLResponse?) throws { guard let blockedSources = UserDefaults.shared.blockedSources else { return } diff --git a/AltStoreCore/Model/DatabaseManager.swift b/AltStoreCore/Model/DatabaseManager.swift index 0245420f..32d6ac7b 100644 --- a/AltStoreCore/Model/DatabaseManager.swift +++ b/AltStoreCore/Model/DatabaseManager.swift @@ -226,7 +226,7 @@ public extension DatabaseManager let predicate = NSPredicate(format: "%K == %@", #keyPath(PatreonAccount.identifier), patreonAccountID) - let patreonAccount = PatreonAccount.first(satisfying: predicate, in: context) + let patreonAccount = PatreonAccount.first(satisfying: predicate, in: context, requestProperties: [\.relationshipKeyPathsForPrefetching: [#keyPath(PatreonAccount._pledges)]]) return patreonAccount } } diff --git a/AltStoreCore/Model/StoreApp.swift b/AltStoreCore/Model/StoreApp.swift index a25db30a..58f5d8f2 100644 --- a/AltStoreCore/Model/StoreApp.swift +++ b/AltStoreCore/Model/StoreApp.swift @@ -114,6 +114,12 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable @NSManaged public private(set) var loggedErrors: NSSet /* Set */ // Use NSSet to avoid eagerly fetching values. + /* Non-Core Data Properties */ + + // Used to set isPledged after fetching source. + public var _tierIDs: Set? + public var _rewardID: String? + @nonobjc public var source: Source? { set { self._source = newValue @@ -287,6 +293,9 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable // No conditions, so default to pledgeAmount of 0 to simplify logic. self._pledgeAmount = 0 as NSDecimalNumber } + + self._tierIDs = patreon.tiers + self._rewardID = patreon.benefit } else { @@ -294,6 +303,9 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable self.isHiddenWithoutPledge = false self._pledgeAmount = nil self.pledgeCurrency = nil + + self._tierIDs = nil + self._rewardID = nil } } catch From 45da6b626ffb9ca675680f450e35ff6d26ec5a56 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Wed, 29 Nov 2023 15:15:36 -0600 Subject: [PATCH 06/20] [AltStoreCore] Adds AppProtocol.storeApp Simplifies retrieving the associated StoreApp for an app. --- AltStore/Managing Apps/AppManager.swift | 2 +- .../Patch App/PatchAppOperation.swift | 2 +- AltStore/Operations/SendAppOperation.swift | 2 +- AltStoreCore/Model/LoggedError.swift | 2 +- AltStoreCore/Protocols/AppProtocol.swift | 18 +++++++++++++++++- 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 66dd9121..8f27ae7c 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -1246,7 +1246,7 @@ private extension AppManager let patchAppURL = URL(string: patchAppLink) else { throw OperationError.invalidApp } - let patchApp = AnyApp(name: app.name, bundleIdentifier: app.bundleIdentifier, url: patchAppURL) + let patchApp = AnyApp(name: app.name, bundleIdentifier: app.bundleIdentifier, url: patchAppURL, storeApp: nil) DispatchQueue.main.async { let storyboard = UIStoryboard(name: "PatchApp", bundle: nil) diff --git a/AltStore/Operations/Patch App/PatchAppOperation.swift b/AltStore/Operations/Patch App/PatchAppOperation.swift index 951f5924..42dc76ff 100644 --- a/AltStore/Operations/Patch App/PatchAppOperation.swift +++ b/AltStore/Operations/Patch App/PatchAppOperation.swift @@ -110,7 +110,7 @@ class PatchAppOperation: ResultOperation .flatMap { self.patch(resignedApp, withBinaryAt: $0) } .tryMap { try FileManager.default.zipAppBundle(at: $0) } .tryMap { (fileURL) in - let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL) + let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL, storeApp: nil) let destinationURL = InstalledApp.refreshedIPAURL(for: app) try FileManager.default.copyItem(at: fileURL, to: destinationURL, shouldReplace: true) diff --git a/AltStore/Operations/SendAppOperation.swift b/AltStore/Operations/SendAppOperation.swift index 2fd61569..191b340b 100644 --- a/AltStore/Operations/SendAppOperation.swift +++ b/AltStore/Operations/SendAppOperation.swift @@ -44,7 +44,7 @@ class SendAppOperation: ResultOperation Logger.sideload.notice("Sending app \(self.context.bundleIdentifier, privacy: .public) to AltServer \(server.localizedName ?? "nil", privacy: .public)...") // self.context.resignedApp.fileURL points to the app bundle, but we want the .ipa. - let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL) + let app = AnyApp(name: resignedApp.name, bundleIdentifier: self.context.bundleIdentifier, url: resignedApp.fileURL, storeApp: nil) let fileURL = InstalledApp.refreshedIPAURL(for: app) // Connect to server. diff --git a/AltStoreCore/Model/LoggedError.swift b/AltStoreCore/Model/LoggedError.swift index 6aba7127..0a22dd45 100644 --- a/AltStoreCore/Model/LoggedError.swift +++ b/AltStoreCore/Model/LoggedError.swift @@ -107,7 +107,7 @@ public extension LoggedError { var app: AppProtocol { // `as AppProtocol` needed to fix "cannot convert AnyApp to StoreApp" compiler error with Xcode 14. - let app = self.installedApp ?? self.storeApp ?? AnyApp(name: self.appName, bundleIdentifier: self.appBundleID, url: nil) as AppProtocol + let app = self.installedApp ?? self.storeApp ?? AnyApp(name: self.appName, bundleIdentifier: self.appBundleID, url: nil, storeApp: nil) as AppProtocol return app } diff --git a/AltStoreCore/Protocols/AppProtocol.swift b/AltStoreCore/Protocols/AppProtocol.swift index 2f4c1af0..98cda889 100644 --- a/AltStoreCore/Protocols/AppProtocol.swift +++ b/AltStoreCore/Protocols/AppProtocol.swift @@ -14,6 +14,8 @@ public protocol AppProtocol var name: String { get } var bundleIdentifier: String { get } var url: URL? { get } + + var storeApp: StoreApp? { get } } public struct AnyApp: AppProtocol @@ -21,12 +23,14 @@ public struct AnyApp: AppProtocol public var name: String public var bundleIdentifier: String public var url: URL? + public var storeApp: StoreApp? - public init(name: String, bundleIdentifier: String, url: URL?) + public init(name: String, bundleIdentifier: String, url: URL?, storeApp: StoreApp?) { self.name = name self.bundleIdentifier = bundleIdentifier self.url = url + self.storeApp = storeApp } } @@ -35,6 +39,10 @@ extension ALTApplication: AppProtocol public var url: URL? { return self.fileURL } + + public var storeApp: StoreApp? { + return nil + } } extension StoreApp: AppProtocol @@ -42,6 +50,10 @@ extension StoreApp: AppProtocol public var url: URL? { return self.latestAvailableVersion?.downloadURL } + + public var storeApp: StoreApp? { + return self + } } extension InstalledApp: AppProtocol @@ -64,4 +76,8 @@ extension AppVersion: AppProtocol public var url: URL? { return self.downloadURL } + + public var storeApp: StoreApp? { + return self.app + } } From 5cb283dc95157ac47bf9f1b892f1c29870e7824e Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Wed, 29 Nov 2023 17:28:46 -0600 Subject: [PATCH 07/20] [AltStoreCore] Caches Patreon session cookies from in-app browser Allows us to download apps from locked Patreon posts. --- AltStore.xcodeproj/project.pbxproj | 4 + AltStore/Settings/PatreonViewController.swift | 9 +- .../Components/WebViewController.swift | 356 ++++++++++++++++++ AltStoreCore/Patreon/PatreonAPI.swift | 172 +++++++-- 4 files changed, 501 insertions(+), 40 deletions(-) create mode 100644 AltStoreCore/Components/WebViewController.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 71c3d033..7fafc6b3 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -357,6 +357,7 @@ D51AD27F29356B7B00967AAA /* ALTWrappedError.m in Sources */ = {isa = PBXBuildFile; fileRef = D51AD27D29356B7B00967AAA /* ALTWrappedError.m */; }; D51AD28029356B8000967AAA /* ALTWrappedError.m in Sources */ = {isa = PBXBuildFile; fileRef = D51AD27D29356B7B00967AAA /* ALTWrappedError.m */; }; D52A2F972ACB40F700BDF8E3 /* Logger+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */; }; + D52B4ABF2AF183F0005991C3 /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52B4ABE2AF183F0005991C3 /* WebViewController.swift */; }; D52C08EE28AEC37A006C4AE5 /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */; }; D52DD35E2AAA89A600A7F2B6 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D52DD35D2AAA89A600A7F2B6 /* AltSign-Dynamic */; }; D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */; }; @@ -976,6 +977,7 @@ D51AD27C29356B7B00967AAA /* ALTWrappedError.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ALTWrappedError.h; sourceTree = ""; }; D51AD27D29356B7B00967AAA /* ALTWrappedError.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ALTWrappedError.m; sourceTree = ""; }; D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+AltStore.swift"; sourceTree = ""; }; + D52B4ABE2AF183F0005991C3 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = ""; }; D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; D52E988928D002D30032BE6B /* AltStore 11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 11.xcdatamodel"; sourceTree = ""; }; D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailCollectionViewController.swift; sourceTree = ""; }; @@ -1496,6 +1498,7 @@ isa = PBXGroup; children = ( BF66EE8B2501AEB1007EE018 /* Keychain.swift */, + D52B4ABE2AF183F0005991C3 /* WebViewController.swift */, ); path = Components; sourceTree = ""; @@ -3062,6 +3065,7 @@ D5FB28EE2ADDF89800A1C337 /* KnownSource.swift in Sources */, BF66EED32501AECA007EE018 /* AltStore2ToAltStore3.xcmappingmodel in Sources */, BF66EEA52501AEC5007EE018 /* Benefit.swift in Sources */, + D52B4ABF2AF183F0005991C3 /* WebViewController.swift in Sources */, BF66EED22501AECA007EE018 /* AltStore4ToAltStore5.xcmappingmodel in Sources */, D5A645252AF5BC7F0047D980 /* UserAccount.swift in Sources */, D5189C022A01BC6800F44625 /* UserInfoValue.swift in Sources */, diff --git a/AltStore/Settings/PatreonViewController.swift b/AltStore/Settings/PatreonViewController.swift index 9494e932..c414ab13 100644 --- a/AltStore/Settings/PatreonViewController.swift +++ b/AltStore/Settings/PatreonViewController.swift @@ -191,7 +191,7 @@ private extension PatreonViewController @IBAction func authenticate(_ sender: UIBarButtonItem) { - PatreonAPI.shared.authenticate { (result) in + PatreonAPI.shared.authenticate(presentingViewController: self) { (result) in do { let account = try result.get() @@ -201,9 +201,12 @@ private extension PatreonViewController self.update() } } - catch ASWebAuthenticationSessionError.canceledLogin + catch is CancellationError { - // Ignore + // Clear in-app browser cache in case they are signed into wrong account. + Task.detached { + await PatreonAPI.shared.deleteAuthCookies() + } } catch { diff --git a/AltStoreCore/Components/WebViewController.swift b/AltStoreCore/Components/WebViewController.swift new file mode 100644 index 00000000..f9d72b25 --- /dev/null +++ b/AltStoreCore/Components/WebViewController.swift @@ -0,0 +1,356 @@ +// +// WebViewController.swift +// AltStoreCore +// +// Created by Riley Testut on 10/31/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit +import WebKit +import Combine + +public protocol WebViewControllerDelegate: NSObject +{ + func webViewControllerDidFinish(_ webViewController: WebViewController) +} + +public class WebViewController: UIViewController +{ + //MARK: Public Properties + public weak var delegate: WebViewControllerDelegate? + + // WKWebView used to display webpages + public private(set) var webView: WKWebView! + + public private(set) lazy var backButton: UIBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "chevron.backward"), style: .plain, target: self, action: #selector(WebViewController.goBack(_:))) + public private(set) lazy var forwardButton: UIBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "chevron.forward"), style: .plain, target: self, action: #selector(WebViewController.goForward(_:))) + public private(set) lazy var shareButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .action, target: self, action: #selector(WebViewController.shareLink(_:))) + + public private(set) lazy var reloadButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .refresh, target: self, action: #selector(WebViewController.refresh(_:))) + public private(set) lazy var stopLoadingButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .stop, target: self, action: #selector(WebViewController.refresh(_:))) + + public private(set) lazy var doneButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(WebViewController.dismissWebViewController(_:))) + + //MARK: Private Properties + private let progressView = UIProgressView() + private lazy var refreshButton: UIBarButtonItem = self.reloadButton + + private let initialReqest: URLRequest? + private var ignoreUpdateProgress: Bool = false + private var cancellables: Set = [] + + public required init(request: URLRequest?, configuration: WKWebViewConfiguration = WKWebViewConfiguration()) + { + self.initialReqest = request + + super.init(nibName: nil, bundle: nil) + + self.webView = WKWebView(frame: CGRectZero, configuration: configuration) + self.webView.allowsBackForwardNavigationGestures = true + + self.progressView.progressViewStyle = .bar + self.progressView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + self.progressView.progress = 0.5 + self.progressView.alpha = 0.0 + self.progressView.isHidden = true + } + + public convenience init(url: URL?, configuration: WKWebViewConfiguration = WKWebViewConfiguration()) + { + if let url + { + let request = URLRequest(url: url) + self.init(request: request, configuration: configuration) + } + else + { + self.init(request: nil, configuration: configuration) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + //MARK: UIViewController + + public override func loadView() + { + self.preparePipeline() + + if let request = self.initialReqest + { + self.webView.load(request) + } + + self.view = self.webView + } + + public override func viewDidLoad() + { + super.viewDidLoad() + + self.navigationController?.isModalInPresentation = true + self.navigationController?.view.tintColor = .altPrimary + + if let navigationBar = self.navigationController?.navigationBar + { + navigationBar.scrollEdgeAppearance = navigationBar.standardAppearance + } + + if let toolbar = self.navigationController?.toolbar, #available(iOS 15, *) + { + toolbar.scrollEdgeAppearance = toolbar.standardAppearance + } + } + + public override func viewIsAppearing(_ animated: Bool) + { + super.viewIsAppearing(animated) + + if self.webView.estimatedProgress < 1.0 + { + self.transitionCoordinator?.animate(alongsideTransition: { context in + self.showProgressBar(animated: true) + }) { context in + if context.isCancelled + { + self.hideProgressBar(animated: false) + } + } + } + + self.navigationController?.setToolbarHidden(false, animated: false) + + self.update() + } + + public override func viewWillDisappear(_ animated: Bool) + { + super.viewWillDisappear(animated) + + var shouldHideToolbarItems = true + + if let toolbarItems = self.navigationController?.topViewController?.toolbarItems + { + if toolbarItems.count > 0 + { + shouldHideToolbarItems = false + } + } + + if shouldHideToolbarItems + { + self.navigationController?.setToolbarHidden(true, animated: false) + } + + self.transitionCoordinator?.animate(alongsideTransition: { context in + self.hideProgressBar(animated: true) + }) { (context) in + if context.isCancelled && self.webView.estimatedProgress < 1.0 + { + self.showProgressBar(animated: false) + } + } + } + + public override func didMove(toParent parent: UIViewController?) + { + super.didMove(toParent: parent) + + if parent == nil + { + self.webView.stopLoading() + } + } + + deinit + { + self.webView.stopLoading() + } +} + +private extension WebViewController +{ + func preparePipeline() + { + self.webView.publisher(for: \.title, options: [.initial, .new]) + .sink { [weak self] title in + self?.title = title + } + .store(in: &self.cancellables) + + self.webView.publisher(for: \.estimatedProgress, options: [.new]) + .sink { [weak self] progress in + self?.updateProgress(progress) + } + .store(in: &self.cancellables) + + Publishers.Merge3( + self.webView.publisher(for: \.isLoading, options: [.new]), + self.webView.publisher(for: \.canGoBack, options: [.new]), + self.webView.publisher(for: \.canGoForward, options: [.new]) + ) + .sink { [weak self] _ in + self?.update() + } + .store(in: &self.cancellables) + } + + func update() + { + if self.webView.isLoading + { + self.refreshButton = self.stopLoadingButton + } + else + { + self.refreshButton = self.reloadButton + } + + self.backButton.isEnabled = self.webView.canGoBack + self.forwardButton.isEnabled = self.webView.canGoForward + + self.navigationItem.leftBarButtonItem = self.doneButton + self.navigationItem.rightBarButtonItem = self.refreshButton + + self.toolbarItems = [self.backButton, .fixedSpace(70), self.forwardButton, .flexibleSpace(), self.shareButton] + } + + func updateProgress(_ progress: Double) + { + if self.progressView.isHidden + { + self.showProgressBar(animated: true) + } + + if self.ignoreUpdateProgress + { + self.ignoreUpdateProgress = false + self.hideProgressBar(animated: true) + } + else if progress < Double(self.progressView.progress) + { + // If progress is less than self.progressView.progress, another webpage began to load before the first one completed + // In this case, we set the progress back to 0.0, and then wait until the next updateProgress, because it results in a much better animation + + self.progressView.setProgress(0.0, animated: false) + } + else + { + UIView.animate(withDuration: 0.4, animations: { + self.progressView.setProgress(Float(progress), animated: true) + }, completion: { (finished) in + if progress == 1.0 + { + // This delay serves two purposes. One, it keeps the progress bar on screen just a bit longer so it doesn't appear to disappear too quickly. + // Two, it allows us to prevent the progress bar from disappearing if the user actually started loading another webpage before the current one finished loading. + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + if self.webView.estimatedProgress == 1.0 + { + self.hideProgressBar(animated: true) + } + } + } + }) + } + } + + func showProgressBar(animated: Bool) + { + let navigationBarBounds = self.navigationController?.navigationBar.bounds ?? .zero + self.progressView.frame = CGRect(x: 0, y: navigationBarBounds.height - self.progressView.bounds.height, width: navigationBarBounds.width, height: self.progressView.bounds.height) + + self.navigationController?.navigationBar.addSubview(self.progressView) + + self.progressView.setProgress(Float(self.webView.estimatedProgress), animated: false) + self.progressView.isHidden = false + + if animated + { + UIView.animate(withDuration: 0.4) { + self.progressView.alpha = 1.0 + } + } + else + { + self.progressView.alpha = 1.0 + } + } + + func hideProgressBar(animated: Bool) + { + if animated + { + UIView.animate(withDuration: 0.4, animations: { + self.progressView.alpha = 0.0 + }, completion: { (finished) in + self.progressView.setProgress(0.0, animated: false) + self.progressView.isHidden = true + self.progressView.removeFromSuperview() + }) + } + else + { + self.progressView.alpha = 0.0 + + // Completion + self.progressView.setProgress(0.0, animated: false) + self.progressView.isHidden = true + self.progressView.removeFromSuperview() + } + } +} + +@objc +private extension WebViewController +{ + func goBack(_ sender: UIBarButtonItem) + { + self.webView.goBack() + } + + func goForward(_ sender: UIBarButtonItem) + { + self.webView.goForward() + } + + func refresh(_ sender: UIBarButtonItem) + { + if self.webView.isLoading + { + self.ignoreUpdateProgress = true + self.webView.stopLoading() + } + else + { + if let initialRequest = self.initialReqest, self.webView.url == nil && self.webView.backForwardList.backList.count == 0 + { + self.webView.load(initialRequest) + } + else + { + self.webView.reload() + } + } + } + + func shareLink(_ sender: UIBarButtonItem) + { + let url = self.webView.url ?? (NSURL() as URL) + + let activityViewController = UIActivityViewController(activityItems: [url], applicationActivities: nil) + activityViewController.modalPresentationStyle = .popover + activityViewController.popoverPresentationController?.barButtonItem = sender + self.present(activityViewController, animated: true) + } + + func dismissWebViewController(_ sender: UIBarButtonItem) + { + self.delegate?.webViewControllerDidFinish(self) + + self.parent?.dismiss(animated: true) + } +} diff --git a/AltStoreCore/Patreon/PatreonAPI.swift b/AltStoreCore/Patreon/PatreonAPI.swift index 32e18102..305be298 100644 --- a/AltStoreCore/Patreon/PatreonAPI.swift +++ b/AltStoreCore/Patreon/PatreonAPI.swift @@ -9,6 +9,7 @@ import Foundation import AuthenticationServices import CoreData +import WebKit private let clientID = "ZMx0EGUWe4TVWYXNZZwK_fbIK5jHFVWoUf1Qb-sqNXmT-YzAGwDPxxq7ak3_W5Q2" private let clientSecret = "1hktsZB89QyN69cB4R0tu55R4TCPQGXxvebYUUh7Y-5TLSnRswuxs6OUjdJ74IJt" @@ -58,6 +59,10 @@ public class PatreonAPI: NSObject private let session = URLSession(configuration: .ephemeral) private let baseURL = URL(string: "https://www.patreon.com/")! + private var authHandlers = [(Result) -> Void]() + private var authContinuation: CheckedContinuation? + private weak var webViewController: WebViewController? + private override init() { super.init() @@ -66,19 +71,40 @@ public class PatreonAPI: NSObject public extension PatreonAPI { - func authenticate(completion: @escaping (Result) -> Void) + func authenticate(presentingViewController: UIViewController, completion: @escaping (Result) -> Void) { - 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")] - - let requestURL = components.url(relativeTo: self.baseURL)! - - self.authenticationSession = ASWebAuthenticationSession(url: requestURL, callbackURLScheme: "altstore") { (callbackURL, error) in + Task.detached { @MainActor in + guard self.authHandlers.isEmpty else { + self.authHandlers.append(completion) + return + } + + self.authHandlers.append(completion) + do { - let callbackURL = try Result(callbackURL, error).get() + 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() + + let webViewController = WebViewController(url: requestURL, configuration: configuration) + webViewController.delegate = 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), @@ -86,26 +112,45 @@ public extension PatreonAPI let code = codeQueryItem.value else { throw PatreonAPIError(.unknown) } - self.fetchAccessToken(oauthCode: code) { (result) in - switch result + 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 { - case .failure(let error): completion(.failure(error)) - case .success((let accessToken, let refreshToken)): - Keychain.shared.patreonAccessToken = accessToken - Keychain.shared.patreonRefreshToken = refreshToken - - self.fetchAccount(completion: completion) + callback(.success(patreonAccount)) } } } catch { - completion(.failure(error)) + for callback in self.authHandlers + { + callback(.failure(error)) + } + } + + self.authHandlers = [] + + await MainActor.run { + self.webViewController?.dismiss(animated: true) + self.webViewController = nil } } - - self.authenticationSession?.presentationContextProvider = self - self.authenticationSession?.start() } func fetchAccount(completion: @escaping (Result) -> Void) @@ -205,7 +250,10 @@ public extension PatreonAPI Keychain.shared.patreonRefreshToken = nil Keychain.shared.patreonAccountID = nil - completion(.success(())) + Task.detached { + await self.deleteAuthCookies() + completion(.success(())) + } } catch { @@ -239,6 +287,56 @@ public extension PatreonAPI } } +extension PatreonAPI +{ + private 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 + + if let cookies = HTTPCookieStorage.shared.cookies(for: URL(string: "https://www.patreon.com")!) + { + for cookie in cookies + { + 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!") + } + else + { + Logger.main.info("No Patreon cookies to clear.") + } + } +} + +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) @@ -366,25 +464,25 @@ private extension PatreonAPI } } -extension PatreonAPI: ASWebAuthenticationPresentationContextProviding +extension PatreonAPI: WKURLSchemeHandler { - public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor + public func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) { - //TODO: Properly support multiple scenes. - - guard let windowScene = UIApplication.alt_shared?.connectedScenes.lazy.compactMap({ $0 as? UIWindowScene }).first else { return UIWindow() } - - if #available(iOS 15, *), let keyWindow = windowScene.keyWindow + guard let authContinuation else { return } + self.authContinuation = nil + + if let callbackURL = urlSchemeTask.request.url { - return keyWindow + authContinuation.resume(returning: callbackURL) } - else if let delegate = windowScene.delegate as? UIWindowSceneDelegate, - let optionalWindow = delegate.window, - let window = optionalWindow + else { - return window + authContinuation.resume(throwing: URLError(.badURL)) } - - return UIWindow() + } + + public func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) + { + Logger.main.debug("WKWebView stopped handling url scheme.") } } From 933cec99ce52e47f302117e8ebe57dbaea3679fc Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Wed, 29 Nov 2023 17:37:21 -0600 Subject: [PATCH 08/20] =?UTF-8?q?Updates=20apps=E2=80=99=20pledge=20status?= =?UTF-8?q?=20upon=20(de-)authenticating=20with=20Patreon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No longer deactivates apps whenever pledge expires. --- AltStore/Settings/PatreonViewController.swift | 17 +++++++++++++++-- AltStoreCore/Patreon/PatreonAPI.swift | 19 ++----------------- 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/AltStore/Settings/PatreonViewController.swift b/AltStore/Settings/PatreonViewController.swift index c414ab13..52506b67 100644 --- a/AltStore/Settings/PatreonViewController.swift +++ b/AltStore/Settings/PatreonViewController.swift @@ -197,8 +197,21 @@ private extension PatreonViewController let account = try result.get() try account.managedObjectContext?.save() - DispatchQueue.main.async { - self.update() + // Update sources to show any Patreon-only apps. + AppManager.shared.fetchSources { result in + do + { + let (_, context) = try result.get() + try context.save() + } + catch + { + Logger.main.error("Failed to update sources after authenticating Patreon account. \(error.localizedDescription, privacy: .public)") + } + + DispatchQueue.main.async { + self.update() + } } } catch is CancellationError diff --git a/AltStoreCore/Patreon/PatreonAPI.swift b/AltStoreCore/Patreon/PatreonAPI.swift index 305be298..ae9744b0 100644 --- a/AltStoreCore/Patreon/PatreonAPI.swift +++ b/AltStoreCore/Patreon/PatreonAPI.swift @@ -242,7 +242,8 @@ public extension PatreonAPI let accounts = PatreonAccount.all(in: context, requestProperties: [\.returnsObjectsAsFaults: true]) accounts.forEach(context.delete(_:)) - self.deactivateBetaApps(in: context) + let pledgeRequiredApps = StoreApp.all(satisfying: NSPredicate(format: "%K == YES", #keyPath(StoreApp.isPledgeRequired)), in: context) + pledgeRequiredApps.forEach { $0.isPledged = false } try context.save() @@ -270,13 +271,6 @@ public extension PatreonAPI do { let account = try result.get() - - if let context = account.managedObjectContext, !account.isPatron - { - // Deactivate all beta apps now that we're no longer a patron. - self.deactivateBetaApps(in: context) - } - try account.managedObjectContext?.save() } catch @@ -453,15 +447,6 @@ private extension PatreonAPI task.resume() } - - func deactivateBetaApps(in context: NSManagedObjectContext) - { - let predicate = NSPredicate(format: "%K != %@ AND %K != nil AND %K == YES", - #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID, #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.isBeta)) - - let installedApps = InstalledApp.all(satisfying: predicate, in: context) - installedApps.forEach { $0.isActive = false } - } } extension PatreonAPI: WKURLSchemeHandler From 28a93b82a910e9201945f0558fb72cfc77a3e09e Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Wed, 29 Nov 2023 17:47:17 -0600 Subject: [PATCH 09/20] [AltStoreCore] Renames PatreonAccount.isPatron to isAltStorePatron --- AltStore/Settings/PatreonViewController.swift | 2 +- AltStoreCore/Model/Patreon/PatreonAccount.swift | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/AltStore/Settings/PatreonViewController.swift b/AltStore/Settings/PatreonViewController.swift index 52506b67..4fddf76f 100644 --- a/AltStore/Settings/PatreonViewController.swift +++ b/AltStore/Settings/PatreonViewController.swift @@ -138,7 +138,7 @@ private extension PatreonViewController headerView.accountButton.addTarget(self, action: #selector(PatreonViewController.signOut(_:)), for: .primaryActionTriggered) headerView.accountButton.setTitle(String(format: NSLocalizedString("Unlink %@", comment: ""), account.name), for: .normal) - if account.isPatron + if account.isAltStorePatron { headerView.supportButton.setTitle(isPatronSupportButtonTitle, for: .normal) diff --git a/AltStoreCore/Model/Patreon/PatreonAccount.swift b/AltStoreCore/Model/Patreon/PatreonAccount.swift index 35d4fe01..40fcd77e 100644 --- a/AltStoreCore/Model/Patreon/PatreonAccount.swift +++ b/AltStoreCore/Model/Patreon/PatreonAccount.swift @@ -16,7 +16,8 @@ public class PatreonAccount: NSManagedObject, Fetchable @NSManaged public var name: String @NSManaged public var firstName: String? - @NSManaged public var isPatron: Bool + // Use `isPatron` for backwards compatibility. + @NSManaged @objc(isPatron) public var isAltStorePatron: Bool /* Relationships */ @nonobjc public var pledges: Set { _pledges as! Set } @@ -55,11 +56,11 @@ public class PatreonAccount: NSManagedObject, Fetchable if let altstorePledge = account.pledges?.first(where: { $0.campaign?.identifier == PatreonAPI.altstoreCampaignID }) { let isActivePatron = (altstorePledge.status == .active) - self.isPatron = isActivePatron + self.isAltStorePatron = isActivePatron } else { - self.isPatron = false + self.isAltStorePatron = false } } } From 2d267a1e9921e017d98dd51482efa18e95b2ade9 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Wed, 29 Nov 2023 18:08:42 -0600 Subject: [PATCH 10/20] Switches from StoreApp.isBeta to isPledged to determine whether app is visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If StoreApp.isHiddenWithoutPledge == false (default), we’ll still show the app. --- AltStore/Browse/BrowseViewController.swift | 16 ++-------- AltStore/My Apps/MyAppsViewController.swift | 32 ++----------------- .../SourceDetailContentViewController.swift | 2 +- AltStore/Sources/SourcesViewController.swift | 10 +----- AltStoreCore/Model/InstalledApp.swift | 8 ++++- AltStoreCore/Model/StoreApp.swift | 12 +++++++ 6 files changed, 25 insertions(+), 55 deletions(-) diff --git a/AltStore/Browse/BrowseViewController.swift b/AltStore/Browse/BrowseViewController.swift index 2bd8e454..e7f07314 100644 --- a/AltStore/Browse/BrowseViewController.swift +++ b/AltStore/Browse/BrowseViewController.swift @@ -92,7 +92,6 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing super.viewWillAppear(animated) self.fetchSource() - self.updateDataSource() self.update() } @@ -109,7 +108,8 @@ private extension BrowseViewController NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)] fetchRequest.returnsObjectsAsFaults = false - let predicate = NSPredicate(format: "%K != %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID) + let predicate = StoreApp.visibleAppsPredicate + if let source = self.source { let filterPredicate = NSPredicate(format: "%K == %@", #keyPath(StoreApp._source), source) @@ -202,18 +202,6 @@ private extension BrowseViewController return dataSource } - func updateDataSource() - { - if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated - { - self.dataSource.predicate = nil - } - else - { - self.dataSource.predicate = NSPredicate(format: "%K == NO", #keyPath(StoreApp.isBeta)) - } - } - func fetchSource() { self.loadingState = .loading diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index ddce5cc4..057db499 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -112,11 +112,10 @@ class MyAppsViewController: UICollectionViewController, PeekPopPreviewing (self as PeekPopPreviewing).registerForPreviewing(with: self, sourceView: self.collectionView) } - override func viewWillAppear(_ animated: Bool) + override func viewIsAppearing(_ animated: Bool) { - super.viewWillAppear(animated) + super.viewIsAppearing(animated) - self.updateDataSource() self.update() self.fetchAppIDs() @@ -475,33 +474,6 @@ private extension MyAppsViewController return dataSource } - - func updateDataSource() - { - do - { - if self.updatesDataSource.fetchedResultsController.fetchedObjects == nil - { - try self.updatesDataSource.fetchedResultsController.performFetch() - } - } - catch - { - print("[ALTLog] Failed to fetch updates:", error) - } - - if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated - { - self.dataSource.predicate = nil - } - else - { - self.dataSource.predicate = NSPredicate(format: "%K == nil OR %K == NO OR %K == %@", - #keyPath(InstalledApp.storeApp), - #keyPath(InstalledApp.storeApp.isBeta), - #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID) - } - } } private extension MyAppsViewController diff --git a/AltStore/Sources/SourceDetailContentViewController.swift b/AltStore/Sources/SourceDetailContentViewController.swift index 525b435d..e2cd8eb0 100644 --- a/AltStore/Sources/SourceDetailContentViewController.swift +++ b/AltStore/Sources/SourceDetailContentViewController.swift @@ -215,7 +215,7 @@ private extension SourceDetailContentViewController let dataSource = RSTArrayCollectionViewPrefetchingDataSource(items: limitedFeaturedApps) dataSource.cellIdentifierHandler = { _ in "AppCell" } - dataSource.predicate = NSPredicate(format: "%K == NO", #keyPath(StoreApp.isBeta)) // Never show beta apps (at least until we support betas for other sources). + dataSource.predicate = StoreApp.visibleAppsPredicate dataSource.cellConfigurationHandler = { [weak self] (cell, storeApp, indexPath) in let cell = cell as! AppBannerCollectionViewCell cell.tintColor = storeApp.tintColor diff --git a/AltStore/Sources/SourcesViewController.swift b/AltStore/Sources/SourcesViewController.swift index b482b389..c44e960c 100644 --- a/AltStore/Sources/SourcesViewController.swift +++ b/AltStore/Sources/SourcesViewController.swift @@ -204,15 +204,7 @@ private extension SourcesViewController cell.bannerView.iconImageView.image = nil cell.bannerView.iconImageView.isIndicatingActivity = true - let numberOfApps: Int - if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated - { - numberOfApps = source.apps.count - } - else - { - numberOfApps = source.apps.filter { !$0.isBeta }.count - } + let numberOfApps = source.apps.filter { StoreApp.visibleAppsPredicate.evaluate(with: $0) }.count if let error = source.error { diff --git a/AltStoreCore/Model/InstalledApp.swift b/AltStoreCore/Model/InstalledApp.swift index 6ed5aa49..655c9fbc 100644 --- a/AltStoreCore/Model/InstalledApp.swift +++ b/AltStoreCore/Model/InstalledApp.swift @@ -194,13 +194,19 @@ public extension InstalledApp // We have to also check !(latestSupportedVersion.buildVersion == '' && installedApp.storeBuildVersion == nil) // because latestSupportedVersion.buildVersion stores an empty string for nil, while installedApp.storeBuildVersion uses NULL. "(%K != %K OR (%K != %K AND NOT (%K == '' AND %K == nil)))", + + "AND", + + // !isPledgeRequired || isPledged + "(%K == NO OR %K == YES)" ].joined(separator: " ") fetchRequest.predicate = NSPredicate(format: predicateFormat, #keyPath(InstalledApp.isActive), #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.latestSupportedVersion), #keyPath(InstalledApp.storeApp.latestSupportedVersion.version), #keyPath(InstalledApp.version), #keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion), #keyPath(InstalledApp.storeBuildVersion), - #keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion), #keyPath(InstalledApp.storeBuildVersion)) + #keyPath(InstalledApp.storeApp.latestSupportedVersion._buildVersion), #keyPath(InstalledApp.storeBuildVersion), + #keyPath(InstalledApp.storeApp.isPledgeRequired), #keyPath(InstalledApp.storeApp.isPledged)) return fetchRequest } diff --git a/AltStoreCore/Model/StoreApp.swift b/AltStoreCore/Model/StoreApp.swift index 58f5d8f2..9295ef5e 100644 --- a/AltStoreCore/Model/StoreApp.swift +++ b/AltStoreCore/Model/StoreApp.swift @@ -436,6 +436,18 @@ public extension StoreApp let globallyUniqueID = self.bundleIdentifier + "|" + sourceIdentifier return globallyUniqueID } +} + +public extension StoreApp +{ + class var visibleAppsPredicate: NSPredicate { + let predicate = NSPredicate(format: "(%K != %@) AND ((%K == NO) OR (%K == NO) OR (%K == YES))", + #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID, + #keyPath(StoreApp.isPledgeRequired), + #keyPath(StoreApp.isHiddenWithoutPledge), + #keyPath(StoreApp.isPledged)) + return predicate + } @nonobjc class func fetchRequest() -> NSFetchRequest { From d9ebd21541ef5f7a191b81402fe3c20e471334dd Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Wed, 29 Nov 2023 18:24:33 -0600 Subject: [PATCH 11/20] Limits installed Patreon apps that no longer have active pledge Patreon apps with inactive pledges still support these actions: * Backed up * Deactivated * Export backup --- AltStore/My Apps/MyAppsViewController.swift | 214 ++++++++++++-------- AltStoreCore/Model/InstalledApp.swift | 59 +++--- 2 files changed, 164 insertions(+), 109 deletions(-) diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 057db499..adc3a77c 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -116,6 +116,9 @@ class MyAppsViewController: UICollectionViewController, PeekPopPreviewing { super.viewIsAppearing(animated) + // Ensure the button for each app reflects correct Patreon status. + self.collectionView.reloadData() + self.update() self.fetchAppIDs() @@ -357,6 +360,17 @@ private extension MyAppsViewController cell.bannerView.button.setTitle(numberOfDaysText.uppercased(), for: .normal) cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Refresh %@", comment: ""), installedApp.name) + if let storeApp = installedApp.storeApp, storeApp.isPledgeRequired, !storeApp.isPledged + { + cell.bannerView.button.isEnabled = false + cell.bannerView.button.alpha = 0.5 + } + else + { + cell.bannerView.button.isEnabled = true + cell.bannerView.button.alpha = 1.0 + } + cell.bannerView.accessibilityLabel? += ". " + String(format: NSLocalizedString("Expires in %@", comment: ""), numberOfDaysText) // Make sure refresh button is correct size. @@ -438,6 +452,17 @@ private extension MyAppsViewController cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.activateApp(_:)), for: .primaryActionTriggered) cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Activate %@", comment: ""), installedApp.name) + if let storeApp = installedApp.storeApp, storeApp.isPledgeRequired, !storeApp.isPledged + { + cell.bannerView.button.isEnabled = false + cell.bannerView.button.alpha = 0.5 + } + else + { + cell.bannerView.button.isEnabled = true + cell.bannerView.button.alpha = 1.0 + } + // Make sure refresh button is correct size. cell.layoutIfNeeded() @@ -1676,7 +1701,7 @@ extension MyAppsViewController extension MyAppsViewController { - private func actions(for installedApp: InstalledApp) -> [UIMenuElement] + private func contextMenu(for installedApp: InstalledApp) -> UIMenu { var actions = [UIMenuElement]() @@ -1734,103 +1759,128 @@ extension MyAppsViewController let changeIconMenu = UIMenu(title: NSLocalizedString("Change Icon", comment: ""), image: UIImage(systemName: "photo"), children: changeIconActions) - guard installedApp.bundleIdentifier != StoreApp.altstoreAppID else { - #if BETA - return [refreshAction, changeIconMenu] - #else - return [refreshAction] - #endif - } - - if installedApp.isActive + if installedApp.bundleIdentifier == StoreApp.altstoreAppID { - actions.append(openMenu) - actions.append(refreshAction) + #if BETA + actions = [refreshAction, changeIconMenu] + #else + actions = [refreshAction] + #endif } else { - actions.append(activateAction) - } - - if installedApp.isActive - { - actions.append(jitAction) - } - - #if BETA - actions.append(changeIconMenu) - #endif - - if installedApp.isActive - { - actions.append(backupAction) - } - else if let _ = UTTypeCopyDeclaration(installedApp.installedAppUTI as CFString)?.takeRetainedValue() as NSDictionary?, !UserDefaults.standard.isLegacyDeactivationSupported - { - // Allow backing up inactive apps if they are still installed, - // but on an iOS version that no longer supports legacy deactivation. - // This handles edge case where you can't install more apps until you - // delete some, but can't activate inactive apps again to back them up first. - actions.append(backupAction) - } - - if let backupDirectoryURL = FileManager.default.backupDirectoryURL(for: installedApp) - { - var backupExists = false - var outError: NSError? = nil - - self.coordinator.coordinate(readingItemAt: backupDirectoryURL, options: [.withoutChanges], error: &outError) { (backupDirectoryURL) in - #if DEBUG - backupExists = true - #else - backupExists = FileManager.default.fileExists(atPath: backupDirectoryURL.path) - #endif + if installedApp.isActive + { + actions.append(openMenu) + actions.append(refreshAction) + } + else + { + actions.append(activateAction) } - if backupExists + if installedApp.isActive { - actions.append(exportBackupAction) + actions.append(jitAction) + } + + #if BETA + actions.append(changeIconMenu) + #endif + + if installedApp.isActive + { + actions.append(backupAction) + } + else if let _ = UTTypeCopyDeclaration(installedApp.installedAppUTI as CFString)?.takeRetainedValue() as NSDictionary?, !UserDefaults.standard.isLegacyDeactivationSupported + { + // Allow backing up inactive apps if they are still installed, + // but on an iOS version that no longer supports legacy deactivation. + // This handles edge case where you can't install more apps until you + // delete some, but can't activate inactive apps again to back them up first. + actions.append(backupAction) + } + + if let backupDirectoryURL = FileManager.default.backupDirectoryURL(for: installedApp) + { + var backupExists = false + var outError: NSError? = nil - if installedApp.isActive + self.coordinator.coordinate(readingItemAt: backupDirectoryURL, options: [.withoutChanges], error: &outError) { (backupDirectoryURL) in + #if DEBUG + backupExists = true + #else + backupExists = FileManager.default.fileExists(atPath: backupDirectoryURL.path) + #endif + } + + if backupExists { - actions.append(restoreBackupAction) + actions.append(exportBackupAction) + + if installedApp.isActive + { + actions.append(restoreBackupAction) + } + } + else if let error = outError + { + print("Unable to check if backup exists:", error) } } - else if let error = outError + + if installedApp.isActive { - print("Unable to check if backup exists:", error) + actions.append(deactivateAction) } + + #if DEBUG + + if installedApp.bundleIdentifier != StoreApp.altstoreAppID + { + actions.append(removeAction) + } + + #else + + if (UserDefaults.standard.legacySideloadedApps ?? []).contains(installedApp.bundleIdentifier) + { + // Legacy sideloaded app, so can't detect if it's deleted. + actions.append(removeAction) + } + else if !UserDefaults.standard.isLegacyDeactivationSupported && !installedApp.isActive + { + // Inactive apps are actually deleted, so we need another way + // for user to remove them from AltStore. + actions.append(removeAction) + } + + #endif } - if installedApp.isActive + var title: String? + + if let storeApp = installedApp.storeApp, storeApp.isPledgeRequired, !storeApp.isPledged { - actions.append(deactivateAction) + let error = OperationError.pledgeInactive(appName: installedApp.name) + title = error.localizedDescription + + // Limit options for apps requiring pledges that we are no longer pledged to. + actions = actions.filter { + $0 == openMenu || + $0 == deactivateAction || + $0 == removeAction || + $0 == backupAction || + $0 == exportBackupAction || + ($0 == refreshAction && storeApp.bundleIdentifier == StoreApp.altstoreAppID) // Always show refresh option for AltStore so the menu will be shown. + } + + // Disable refresh action for AltStore. + refreshAction.attributes = .disabled } - #if DEBUG - - if installedApp.bundleIdentifier != StoreApp.altstoreAppID - { - actions.append(removeAction) - } - - #else - - if (UserDefaults.standard.legacySideloadedApps ?? []).contains(installedApp.bundleIdentifier) - { - // Legacy sideloaded app, so can't detect if it's deleted. - actions.append(removeAction) - } - else if !UserDefaults.standard.isLegacyDeactivationSupported && !installedApp.isActive - { - // Inactive apps are actually deleted, so we need another way - // for user to remove them from AltStore. - actions.append(removeAction) - } - - #endif - - return actions + let menu = UIMenu(title: title ?? "", children: actions) + return menu } override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? @@ -1843,9 +1893,7 @@ extension MyAppsViewController let installedApp = self.dataSource.item(at: indexPath) return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: nil) { (suggestedActions) -> UIMenu? in - let actions = self.actions(for: installedApp) - - let menu = UIMenu(title: "", children: actions) + let menu = self.contextMenu(for: installedApp) return menu } } diff --git a/AltStoreCore/Model/InstalledApp.swift b/AltStoreCore/Model/InstalledApp.swift index 655c9fbc..b927b2c3 100644 --- a/AltStoreCore/Model/InstalledApp.swift +++ b/AltStoreCore/Model/InstalledApp.swift @@ -233,17 +233,12 @@ public extension InstalledApp class func fetchAppsForRefreshingAll(in context: NSManagedObjectContext) -> [InstalledApp] { - var predicate = NSPredicate(format: "%K == YES AND %K != %@", #keyPath(InstalledApp.isActive), #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID) - - if let patreonAccount = DatabaseManager.shared.patreonAccount(in: context), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated - { - // No additional predicate - } - else - { - predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate, - NSPredicate(format: "%K == nil OR %K == NO", #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.isBeta))]) - } + let predicate = NSPredicate(format: "(%K == YES AND %K != %@) AND (%K == nil OR %K == NO OR %K == YES)", + #keyPath(InstalledApp.isActive), + #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID, + #keyPath(InstalledApp.storeApp), + #keyPath(InstalledApp.storeApp.isPledgeRequired), + #keyPath(InstalledApp.storeApp.isPledged)) var installedApps = InstalledApp.all(satisfying: predicate, sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)], @@ -252,7 +247,17 @@ public extension InstalledApp if let altStoreApp = InstalledApp.fetchAltStore(in: context) { // Refresh AltStore last since it causes app to quit. - installedApps.append(altStoreApp) + + if let storeApp = altStoreApp.storeApp, !storeApp.isPledgeRequired || storeApp.isPledged + { + // Only add AltStore if it's the public version OR if it's the beta and we're pledged to it. + installedApps.append(altStoreApp) + } + else + { + // No associated storeApp, so add it just to be safe. + installedApps.append(altStoreApp) + } } return installedApps @@ -263,20 +268,14 @@ public extension InstalledApp // Date 6 hours before now. let date = Date().addingTimeInterval(-1 * 6 * 60 * 60) - var predicate = NSPredicate(format: "(%K == YES) AND (%K < %@) AND (%K != %@)", + let predicate = NSPredicate(format: "(%K == YES) AND (%K < %@) AND (%K != %@) AND (%K == nil OR %K == NO OR %K == YES)", #keyPath(InstalledApp.isActive), #keyPath(InstalledApp.refreshedDate), date as NSDate, - #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID) - - if let patreonAccount = DatabaseManager.shared.patreonAccount(in: context), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated - { - // No additional predicate - } - else - { - predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate, - NSPredicate(format: "%K == nil OR %K == NO", #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.isBeta))]) - } + #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID, + #keyPath(InstalledApp.storeApp), + #keyPath(InstalledApp.storeApp.isPledgeRequired), + #keyPath(InstalledApp.storeApp.isPledged) + ) var installedApps = InstalledApp.all(satisfying: predicate, sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)], @@ -284,8 +283,16 @@ public extension InstalledApp if let altStoreApp = InstalledApp.fetchAltStore(in: context), altStoreApp.refreshedDate < date { - // Refresh AltStore last since it may cause app to quit. - installedApps.append(altStoreApp) + if let storeApp = altStoreApp.storeApp, !storeApp.isPledgeRequired || storeApp.isPledged + { + // Only add AltStore if it's the public version OR if it's the beta and we're pledged to it. + installedApps.append(altStoreApp) + } + else + { + // No associated storeApp, so add it just to be safe. + installedApps.append(altStoreApp) + } } return installedApps From bd0220ea351e20f1b884be5d7b4f7a36095063e9 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Thu, 30 Nov 2023 14:28:57 -0600 Subject: [PATCH 12/20] Supports downloading apps from locked Patreon posts Uses cached Patreon session cookies to access post attachments despite no official API support. --- AltStore.xcodeproj/project.pbxproj | 4 + AltStore/Extensions/UTType+AltStore.swift | 14 ++ AltStore/Info.plist | 2 + AltStore/Managing Apps/AppManager.swift | 16 +- .../Operations/DownloadAppOperation.swift | 220 ++++++++++++++++-- .../Operations/Errors/OperationError.swift | 20 ++ AltTests/TestErrors.swift | 2 + 7 files changed, 258 insertions(+), 20 deletions(-) create mode 100644 AltStore/Extensions/UTType+AltStore.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 7fafc6b3..8cfd0596 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -423,6 +423,7 @@ D5A299872AAB9E4E00A3988D /* ProcessError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FB7A1B2AA284ED00EF863D /* ProcessError.swift */; }; D5A299882AAB9E4E00A3988D /* JITError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A1D2E32AA50EB60066CACC /* JITError.swift */; }; D5A299892AAB9E5900A3988D /* AppProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59A6B7D2AA9226C00F61259 /* AppProcess.swift */; }; + D5A645212AF591980047D980 /* UTType+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A645202AF591980047D980 /* UTType+AltStore.swift */; }; D5A645232AF5B5C50047D980 /* PatreonAPI+Responses.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A645222AF5B5C50047D980 /* PatreonAPI+Responses.swift */; }; D5A645252AF5BC7F0047D980 /* UserAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A645242AF5BC7F0047D980 /* UserAccount.swift */; }; D5ACE84528E3B8450021CAB9 /* ClearAppCacheOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */; }; @@ -1040,6 +1041,7 @@ D5A1D2E82AA512940066CACC /* RemoteServiceDiscoveryTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteServiceDiscoveryTunnel.swift; sourceTree = ""; }; D5A1D2EA2AA513410066CACC /* URL+Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Tools.swift"; sourceTree = ""; }; D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeprecatedAPIs.swift; sourceTree = ""; }; + D5A645202AF591980047D980 /* UTType+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UTType+AltStore.swift"; sourceTree = ""; }; D5A645222AF5B5C50047D980 /* PatreonAPI+Responses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PatreonAPI+Responses.swift"; sourceTree = ""; }; D5A645242AF5BC7F0047D980 /* UserAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAccount.swift; sourceTree = ""; }; D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearAppCacheOperation.swift; sourceTree = ""; }; @@ -1941,6 +1943,7 @@ D5927D6529DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift */, D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */, D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */, + D5A645202AF591980047D980 /* UTType+AltStore.swift */, ); path = Extensions; sourceTree = ""; @@ -3280,6 +3283,7 @@ BFB3645A2325985F00CD0EB1 /* FindServerOperation.swift in Sources */, D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */, BF3BEFBF2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift in Sources */, + D5A645212AF591980047D980 /* UTType+AltStore.swift in Sources */, D5F982212AB910180045751F /* AppScreenshotsViewController.swift in Sources */, BFF0B69023219C6D007A79E1 /* PatreonComponents.swift in Sources */, BFBE0004250ACFFB0080826E /* ViewApp.intentdefinition in Sources */, diff --git a/AltStore/Extensions/UTType+AltStore.swift b/AltStore/Extensions/UTType+AltStore.swift new file mode 100644 index 00000000..b7212d2c --- /dev/null +++ b/AltStore/Extensions/UTType+AltStore.swift @@ -0,0 +1,14 @@ +// +// UTType+AltStore.swift +// AltStore +// +// Created by Riley Testut on 11/3/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UniformTypeIdentifiers + +extension UTType +{ + static let ipa = UTType(importedAs: "com.apple.itunes.ipa") +} diff --git a/AltStore/Info.plist b/AltStore/Info.plist index 228e33db..1c6f3344 100644 --- a/AltStore/Info.plist +++ b/AltStore/Info.plist @@ -181,6 +181,8 @@ public.filename-extension ipa + public.mime-type + application/x-ios-app diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 8f27ae7c..0218518f 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -1358,8 +1358,22 @@ private extension AppManager progress.addChild(installOperation.progress, withPendingUnitCount: 30) installOperation.addDependency(sendAppOperation) - let operations = [downloadOperation, verifyOperation, deactivateAppsOperation, patchAppOperation, refreshAnisetteDataOperation, fetchProvisioningProfilesOperation, resignAppOperation, sendAppOperation, installOperation] + var operations = [downloadOperation, verifyOperation, deactivateAppsOperation, patchAppOperation, refreshAnisetteDataOperation, fetchProvisioningProfilesOperation, resignAppOperation, sendAppOperation, installOperation] group.add(operations) + + if let storeApp = downloadingApp.storeApp, storeApp.isPledgeRequired + { + // Patreon apps may require authenticating with WebViewController, + // so make sure to run DownloadAppOperation serially. + self.run([downloadOperation], context: group.context, requiresSerialQueue: true) + + if let index = operations.firstIndex(of: downloadOperation) + { + // Remove downloadOperation from operations to prevent running it twice. + operations.remove(at: index) + } + } + self.run(operations, context: group.context) return progress diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index 7fb2e597..99c01136 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -7,10 +7,12 @@ // import Foundation -import Roxas +import WebKit +import UniformTypeIdentifiers import AltStoreCore import AltSign +import Roxas @objc(DownloadAppOperation) class DownloadAppOperation: ResultOperation @@ -25,6 +27,8 @@ class DownloadAppOperation: ResultOperation private let session = URLSession(configuration: .default) private let temporaryDirectory = FileManager.default.uniqueTemporaryURL() + private var downloadPatreonAppContinuation: CheckedContinuation? + init(app: AppProtocol, destinationURL: URL, context: InstallAppOperationContext) { self.app = app @@ -194,11 +198,43 @@ private extension DownloadAppOperation func downloadIPA(from sourceURL: URL, completionHandler: @escaping (Result) -> Void) { - func finishOperation(_ result: Result) - { + Task.detached(priority: .userInitiated) { do { - let fileURL = try result.get() + let fileURL: URL + + if sourceURL.isFileURL + { + fileURL = sourceURL + self.progress.completedUnitCount += 3 + } + else if let isPledged = await self.context.$appVersion.perform({ $0?.app?.isPledged }), !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 + fileURL = try await self.downloadPatreonApp(from: sourceURL) + } + else + { + // Regular app + fileURL = try await self.downloadFile(from: sourceURL) + } + + defer { + if !sourceURL.isFileURL + { + try? FileManager.default.removeItem(at: fileURL) + } + } var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else { throw OperationError.appNotFound(name: self.appName) } @@ -235,31 +271,26 @@ private extension DownloadAppOperation completionHandler(.failure(error)) } } - - if sourceURL.isFileURL - { - finishOperation(.success(sourceURL)) - - self.progress.completedUnitCount += 3 - } - else - { - let downloadTask = self.session.downloadTask(with: sourceURL) { (fileURL, response, error) in + } + + func downloadFile(from downloadURL: URL) async throws -> URL + { + try await withCheckedThrowingContinuation { continuation in + let downloadTask = self.session.downloadTask(with: downloadURL) { (fileURL, response, error) in do { if let response = response as? HTTPURLResponse { - guard response.statusCode != 404 else { throw CocoaError(.fileNoSuchFile, userInfo: [NSURLErrorKey: sourceURL]) } + guard response.statusCode != 403 else { throw URLError(.noPermissionsToReadFile) } + guard response.statusCode != 404 else { throw CocoaError(.fileNoSuchFile, userInfo: [NSURLErrorKey: downloadURL]) } } let (fileURL, _) = try Result((fileURL, response), error).get() - finishOperation(.success(fileURL)) - - try? FileManager.default.removeItem(at: fileURL) + continuation.resume(returning: fileURL) } catch { - finishOperation(.failure(error)) + continuation.resume(throwing: error) } } self.progress.addChild(downloadTask.progress, withPendingUnitCount: 3) @@ -267,6 +298,157 @@ private extension DownloadAppOperation downloadTask.resume() } } + + func downloadPatreonApp(from patreonURL: URL) async throws -> URL + { + do + { + // User is pledged to this app, attempt to download. + + let fileURL = try await self.downloadFile(from: patreonURL) + return fileURL + } + catch URLError.noPermissionsToReadFile + { + guard let presentingViewController = self.context.presentingViewController else { throw OperationError.pledgeRequired(appName: self.appName) } + + // Attempt to sign-in again in case our Patreon session has expired. + 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) + } + } + } + + do + { + // Success, so try to download once more now that we're definitely authenticated. + + let fileURL = try await self.downloadFile(from: patreonURL) + return fileURL + } + catch URLError.noPermissionsToReadFile + { + // We know authentication succeeded, so failure must mean user isn't patron/on the correct tier, + // or that our hacky workaround for downloading Patreon attachments has failed. + // Either way, taking them directly to the post serves as a decent fallback. + + return try await downloadFromPatreonPost() + } + } + + func downloadFromPatreonPost() async throws -> URL + { + guard let presentingViewController = self.context.presentingViewController else { throw OperationError.pledgeRequired(appName: self.appName) } + + let downloadURL: URL + + if let components = URLComponents(url: patreonURL, resolvingAgainstBaseURL: false), + let postItem = components.queryItems?.first(where: { $0.name == "h" }), + let postID = postItem.value, + let patreonPostURL = URL(string: "https://www.patreon.com/posts/" + postID) + { + downloadURL = patreonPostURL + } + else + { + downloadURL = patreonURL + } + + return try await self.downloadFromPatreon(downloadURL, presentingViewController: presentingViewController) + } + } + + @MainActor + func downloadFromPatreon(_ patreonURL: URL, presentingViewController: UIViewController) async throws -> URL + { + let webViewController = WebViewController(url: patreonURL) + webViewController.delegate = self + webViewController.webView.navigationDelegate = self + + let navigationController = UINavigationController(rootViewController: webViewController) + presentingViewController.present(navigationController, animated: true) + + let downloadURL: URL + + do + { + defer { + navigationController.dismiss(animated: true) + } + + downloadURL = try await withCheckedThrowingContinuation { continuation in + self.downloadPatreonAppContinuation = continuation + } + } + + let fileURL = try await self.downloadFile(from: downloadURL) + return fileURL + } +} + +extension DownloadAppOperation: WebViewControllerDelegate +{ + func webViewControllerDidFinish(_ webViewController: WebViewController) + { + guard let continuation = self.downloadPatreonAppContinuation else { return } + self.downloadPatreonAppContinuation = nil + + continuation.resume(throwing: CancellationError()) + } +} + +extension DownloadAppOperation: WKNavigationDelegate +{ + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy + { + guard #available(iOS 14.5, *), navigationAction.shouldPerformDownload else { return .allow } + + guard let continuation = self.downloadPatreonAppContinuation else { return .allow } + self.downloadPatreonAppContinuation = nil + + if let downloadURL = navigationAction.request.url + { + continuation.resume(returning: downloadURL) + } + else + { + continuation.resume(throwing: URLError(.badURL)) + } + + return .cancel + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy + { + // Called for Patreon attachments + + guard !navigationResponse.canShowMIMEType else { return .allow } + + guard let continuation = self.downloadPatreonAppContinuation else { return .allow } + self.downloadPatreonAppContinuation = nil + + guard let response = navigationResponse.response as? HTTPURLResponse, let responseURL = response.url, + let mimeType = response.mimeType, let type = UTType(mimeType: mimeType), + type.conforms(to: .ipa) || type.conforms(to: .zip) || type.conforms(to: .application) + else { + continuation.resume(throwing: OperationError.invalidApp) + return .cancel + } + + continuation.resume(returning: responseURL) + + return .cancel + } } private extension DownloadAppOperation diff --git a/AltStore/Operations/Errors/OperationError.swift b/AltStore/Operations/Errors/OperationError.swift index 32dfdd43..9b6ed458 100644 --- a/AltStore/Operations/Errors/OperationError.swift +++ b/AltStore/Operations/Errors/OperationError.swift @@ -36,6 +36,10 @@ extension OperationError case serverNotFound = 1200 case connectionFailed = 1201 case connectionDropped = 1202 + + /* Pledges */ + case pledgeRequired = 1401 + case pledgeInactive = 1402 } static var cancelled: CancellationError { CancellationError() } @@ -67,6 +71,14 @@ extension OperationError static func forbidden(failureReason: String? = nil, file: String = #fileID, line: UInt = #line) -> OperationError { OperationError(code: .forbidden, failureReason: failureReason, sourceFile: file, sourceLine: line) } + + static func pledgeRequired(appName: String, file: String = #fileID, line: UInt = #line) -> OperationError { + OperationError(code: .pledgeRequired, appName: appName, sourceFile: file, sourceLine: line) + } + + static func pledgeInactive(appName: String, file: String = #fileID, line: UInt = #line) -> OperationError { + OperationError(code: .pledgeInactive, appName: appName, sourceFile: file, sourceLine: line) + } } struct OperationError: ALTLocalizedError @@ -132,6 +144,14 @@ struct OperationError: ALTLocalizedError case .serverNotFound: return NSLocalizedString("AltServer could not be found.", comment: "") case .connectionFailed: return NSLocalizedString("A connection to AltServer could not be established.", comment: "") case .connectionDropped: return NSLocalizedString("The connection to AltServer was dropped.", comment: "") + + case .pledgeRequired: + let appName = self.appName ?? NSLocalizedString("This app", comment: "") + return String(format: NSLocalizedString("%@ requires an active pledge in order to be installed.", comment: ""), appName) + + case .pledgeInactive: + let appName = self.appName ?? NSLocalizedString("this app", comment: "") + return String(format: NSLocalizedString("Your pledge is no longer active. Please renew it to continue using %@ normally.", comment: ""), appName) } } private var _failureReason: String? diff --git a/AltTests/TestErrors.swift b/AltTests/TestErrors.swift index 02728df0..9e8d6985 100644 --- a/AltTests/TestErrors.swift +++ b/AltTests/TestErrors.swift @@ -237,6 +237,8 @@ extension OperationError case .connectionFailed: return .connectionFailed case .connectionDropped: return .connectionDropped case .forbidden: return .forbidden() + case .pledgeRequired: return .pledgeRequired(appName: "Delta") + case .pledgeInactive: return .pledgeInactive(appName: "Delta") } } } From 2377ada199b0a7279ca02f9c8357edbcf54836dc Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Thu, 30 Nov 2023 14:33:08 -0600 Subject: [PATCH 13/20] Supports remotely disabling workaround for downloading Patreon attachments In case our workaround for downloading Patreon post attachments breaks, we can remotely disable it and force AltStore to use its fallback instead (taking user to post directly). --- AltStore/Operations/DownloadAppOperation.swift | 5 +++++ AltStore/Operations/FetchSourceOperation.swift | 5 +++++ AltStoreCore/Extensions/UserDefaults+AltStore.swift | 2 ++ AltStoreCore/Types/ALTSourceUserInfoKey.h | 1 + AltStoreCore/Types/ALTSourceUserInfoKey.m | 1 + 5 files changed, 14 insertions(+) diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index 99c01136..4b0bbadb 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -301,6 +301,11 @@ private extension DownloadAppOperation func downloadPatreonApp(from patreonURL: URL) async throws -> URL { + guard !UserDefaults.shared.skipPatreonDownloads else { + // Skip all hacks, take user straight to Patreon post. + return try await downloadFromPatreonPost() + } + do { // User is pledged to this app, attempt to download. diff --git a/AltStore/Operations/FetchSourceOperation.swift b/AltStore/Operations/FetchSourceOperation.swift index 36c12ef3..7c0ec04e 100644 --- a/AltStore/Operations/FetchSourceOperation.swift +++ b/AltStore/Operations/FetchSourceOperation.swift @@ -153,6 +153,11 @@ class FetchSourceOperation: ResultOperation let identifier = source.identifier + if identifier == Source.altStoreIdentifier, let skipPatreonDownloads = source.userInfo?[.skipPatreonDownloads] + { + UserDefaults.shared.skipPatreonDownloads = (skipPatreonDownloads == "true") + } + try self.verify(source, response: response) try self.verifyPledges(for: source, in: childContext) diff --git a/AltStoreCore/Extensions/UserDefaults+AltStore.swift b/AltStoreCore/Extensions/UserDefaults+AltStore.swift index 9b3551f4..507d443c 100644 --- a/AltStoreCore/Extensions/UserDefaults+AltStore.swift +++ b/AltStoreCore/Extensions/UserDefaults+AltStore.swift @@ -39,6 +39,8 @@ public extension UserDefaults @NSManaged var patronsRefreshID: String? + @NSManaged var skipPatreonDownloads: Bool + @nonobjc var activeAppsLimit: Int? { get { diff --git a/AltStoreCore/Types/ALTSourceUserInfoKey.h b/AltStoreCore/Types/ALTSourceUserInfoKey.h index f18a0365..5621334e 100644 --- a/AltStoreCore/Types/ALTSourceUserInfoKey.h +++ b/AltStoreCore/Types/ALTSourceUserInfoKey.h @@ -10,3 +10,4 @@ typedef NSString *ALTSourceUserInfoKey NS_TYPED_EXTENSIBLE_ENUM; extern ALTSourceUserInfoKey const ALTSourceUserInfoKeyPatreonAccessToken; +extern ALTSourceUserInfoKey const ALTSourceUserInfoKeySkipPatreonDownloads; diff --git a/AltStoreCore/Types/ALTSourceUserInfoKey.m b/AltStoreCore/Types/ALTSourceUserInfoKey.m index 4d715cdc..d326b71f 100644 --- a/AltStoreCore/Types/ALTSourceUserInfoKey.m +++ b/AltStoreCore/Types/ALTSourceUserInfoKey.m @@ -9,3 +9,4 @@ #import "ALTSourceUserInfoKey.h" ALTSourceUserInfoKey const ALTSourceUserInfoKeyPatreonAccessToken = @"patreonAccessToken"; +ALTSourceUserInfoKey const ALTSourceUserInfoKeySkipPatreonDownloads = @"skipPatreonDownloads"; From a20950b693d0b42b3d9159b00b07bd5bbb5e1339 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Thu, 30 Nov 2023 15:10:35 -0600 Subject: [PATCH 14/20] Fixes showing Patreon page when installing non-Patreon apps --- AltStore/Operations/DownloadAppOperation.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index 4b0bbadb..ef9c474f 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -208,7 +208,7 @@ private extension DownloadAppOperation fileURL = sourceURL self.progress.completedUnitCount += 3 } - else if let isPledged = await self.context.$appVersion.perform({ $0?.app?.isPledged }), !isPledged + 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, From 5732da1f2cd0aca5e38690393832a5973f3e5df9 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Thu, 30 Nov 2023 15:14:01 -0600 Subject: [PATCH 15/20] Fixes AltStore still being refreshing even after pledge expires --- AltStore/My Apps/MyAppsViewController.swift | 23 +++++++++++++++++++-- AltStoreCore/Model/InstalledApp.swift | 18 ++++++++++------ 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index adc3a77c..656ac976 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -707,10 +707,29 @@ private extension MyAppsViewController @IBAction func refreshAllApps(_ sender: UIBarButtonItem) { + let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: DatabaseManager.shared.viewContext) + guard !installedApps.isEmpty else { + let error: Error + + if let altstoreApp = InstalledApp.fetchAltStore(in: DatabaseManager.shared.viewContext), + let storeApp = altstoreApp.storeApp, storeApp.isPledgeRequired && !storeApp.isPledged + { + // Assume the reason there are no apps is because we are no longer pledged to AltStore beta. + error = OperationError(.pledgeInactive(appName: altstoreApp.name)) + } + else + { + // Otherwise, fall back to generic noInstalledApps. + error = RefreshError(.noInstalledApps) + } + + let toastView = ToastView(error: error) + toastView.show(in: self) + return + } + self.isRefreshingAllApps = true self.collectionView.collectionViewLayout.invalidateLayout() - - let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: DatabaseManager.shared.viewContext) self.refresh(installedApps) { (result) in DispatchQueue.main.async { diff --git a/AltStoreCore/Model/InstalledApp.swift b/AltStoreCore/Model/InstalledApp.swift index b927b2c3..39bdf4df 100644 --- a/AltStoreCore/Model/InstalledApp.swift +++ b/AltStoreCore/Model/InstalledApp.swift @@ -248,10 +248,13 @@ public extension InstalledApp { // Refresh AltStore last since it causes app to quit. - if let storeApp = altStoreApp.storeApp, !storeApp.isPledgeRequired || storeApp.isPledged + if let storeApp = altStoreApp.storeApp { - // Only add AltStore if it's the public version OR if it's the beta and we're pledged to it. - installedApps.append(altStoreApp) + if !storeApp.isPledgeRequired || storeApp.isPledged + { + // Only add AltStore if it's the public version OR if it's the beta and we're pledged to it. + installedApps.append(altStoreApp) + } } else { @@ -283,10 +286,13 @@ public extension InstalledApp if let altStoreApp = InstalledApp.fetchAltStore(in: context), altStoreApp.refreshedDate < date { - if let storeApp = altStoreApp.storeApp, !storeApp.isPledgeRequired || storeApp.isPledged + if let storeApp = altStoreApp.storeApp { - // Only add AltStore if it's the public version OR if it's the beta and we're pledged to it. - installedApps.append(altStoreApp) + if !storeApp.isPledgeRequired || storeApp.isPledged + { + // Only add AltStore if it's the public version OR if it's the beta and we're pledged to it. + installedApps.append(altStoreApp) + } } else { From f9cff51d1c306292314c24e6716b4066151a193e Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Thu, 30 Nov 2023 18:50:54 -0600 Subject: [PATCH 16/20] Supports updating apps from (almost) all AppBannerViews Previously, you could only update apps from MyAppsViewController and AppViewController. --- AltStore/App Detail/AppViewController.swift | 37 +---- AltStore/Browse/BrowseViewController.swift | 65 ++++----- AltStore/Components/AppBannerView.swift | 98 ++++++++++++- .../AppCardCollectionViewCell.swift | 4 + AltStore/My Apps/MyAppsViewController.swift | 35 ++--- .../My Apps/UpdateCollectionViewCell.swift | 3 +- AltStore/News/NewsViewController.swift | 53 +++---- .../SourceDetailContentViewController.swift | 133 +++++++++--------- AltStoreCore/Model/InstalledApp.swift | 7 + 9 files changed, 240 insertions(+), 195 deletions(-) diff --git a/AltStore/App Detail/AppViewController.swift b/AltStore/App Detail/AppViewController.swift index 3d17e8f3..a3fc871b 100644 --- a/AltStore/App Detail/AppViewController.swift +++ b/AltStore/App Detail/AppViewController.swift @@ -87,8 +87,6 @@ class AppViewController: UIViewController self.bannerView.iconImageView.tintColor = self.app.tintColor self.bannerView.button.tintColor = self.app.tintColor self.bannerView.tintColor = self.app.tintColor - - self.bannerView.configure(for: self.app) self.bannerView.accessibilityTraits.remove(.button) self.bannerView.button.addTarget(self, action: #selector(AppViewController.performAppAction(_:)), for: .primaryActionTriggered) @@ -366,37 +364,14 @@ private extension AppViewController { button.tintColor = self.app.tintColor button.isIndicatingActivity = false - - if let installedApp = self.app.installedApp - { - if let latestVersion = self.app.latestSupportedVersion, !installedApp.matches(latestVersion) - { - button.setTitle(NSLocalizedString("UPDATE", comment: ""), for: .normal) - } - else - { - button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal) - } - } - else - { - button.setTitle(NSLocalizedString("FREE", comment: ""), for: .normal) - } - - let progress = AppManager.shared.installationProgress(for: self.app) - button.progress = progress } - if let versionDate = self.app.latestSupportedVersion?.date, versionDate > Date() - { - self.bannerView.button.countdownDate = versionDate - self.navigationBarDownloadButton.countdownDate = versionDate - } - else - { - self.bannerView.button.countdownDate = nil - self.navigationBarDownloadButton.countdownDate = nil - } + self.bannerView.configure(for: self.app) + + let title = self.bannerView.button.title(for: .normal) + self.navigationBarDownloadButton.setTitle(title, for: .normal) + self.navigationBarDownloadButton.progress = self.bannerView.button.progress + self.navigationBarDownloadButton.countdownDate = self.bannerView.button.countdownDate let barButtonItem = self.navigationItem.rightBarButtonItem self.navigationItem.rightBarButtonItem = nil diff --git a/AltStore/Browse/BrowseViewController.swift b/AltStore/Browse/BrowseViewController.swift index e7f07314..cc991e34 100644 --- a/AltStore/Browse/BrowseViewController.swift +++ b/AltStore/Browse/BrowseViewController.swift @@ -136,40 +136,8 @@ private extension BrowseViewController cell.bannerView.button.activityIndicatorView.style = .medium cell.bannerView.button.activityIndicatorView.color = .white - // Explicitly set to false to ensure we're starting from a non-activity indicating state. - // Otherwise, cell reuse can mess up some cached values. - cell.bannerView.button.isIndicatingActivity = false - let tintColor = app.tintColor ?? .altPrimary cell.tintColor = tintColor - - if app.installedApp == nil - { - let buttonTitle = NSLocalizedString("Free", comment: "") - cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal) - cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name) - cell.bannerView.button.accessibilityValue = buttonTitle - - let progress = AppManager.shared.installationProgress(for: app) - cell.bannerView.button.progress = progress - - if let versionDate = app.latestSupportedVersion?.date, versionDate > Date() - { - cell.bannerView.button.countdownDate = versionDate - } - else - { - cell.bannerView.button.countdownDate = nil - } - } - else - { - cell.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal) - cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), app.name) - cell.bannerView.button.accessibilityValue = nil - cell.bannerView.button.progress = nil - cell.bannerView.button.countdownDate = nil - } } dataSource.prefetchHandler = { (storeApp, indexPath, completionHandler) -> Foundation.Operation? in let iconURL = storeApp.iconURL @@ -305,7 +273,7 @@ private extension BrowseViewController let app = self.dataSource.item(at: indexPath) - if let installedApp = app.installedApp + if let installedApp = app.installedApp, !installedApp.isUpdateAvailable { self.open(installedApp) } @@ -323,7 +291,21 @@ private extension BrowseViewController return } - _ = AppManager.shared.install(app, presentingViewController: self) { (result) in + if let installedApp = app.installedApp, installedApp.isUpdateAvailable + { + AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:)) + } + else + { + AppManager.shared.install(app, presentingViewController: self, completionHandler: finish(_:)) + } + + UIView.performWithoutAnimation { + self.collectionView.reloadItems(at: [indexPath]) + } + + func finish(_ result: Result) + { DispatchQueue.main.async { switch result { @@ -332,15 +314,22 @@ private extension BrowseViewController let toastView = ToastView(error: error) toastView.opensErrorLog = true toastView.show(in: self) - + case .success: print("Installed app:", app.bundleIdentifier) } - self.collectionView.reloadItems(at: [indexPath]) + UIView.performWithoutAnimation { + if let indexPath = self.dataSource.fetchedResultsController.indexPath(forObject: app) + { + self.collectionView.reloadItems(at: [indexPath]) + } + else + { + self.collectionView.reloadSections(IndexSet(integer: indexPath.section)) + } + } } } - - self.collectionView.reloadItems(at: [indexPath]) } func open(_ installedApp: InstalledApp) diff --git a/AltStore/Components/AppBannerView.swift b/AltStore/Components/AppBannerView.swift index 9396b6e8..ed316768 100644 --- a/AltStore/Components/AppBannerView.swift +++ b/AltStore/Components/AppBannerView.swift @@ -18,6 +18,14 @@ extension AppBannerView case app case source } + + enum AppAction + { + case install + case open + case update + case custom(String) + } } class AppBannerView: RSTNibView @@ -111,7 +119,7 @@ class AppBannerView: RSTNibView extension AppBannerView { - func configure(for app: AppProtocol) + func configure(for app: AppProtocol, action: AppAction? = nil) { struct AppValues { @@ -150,6 +158,94 @@ extension AppBannerView self.subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "") self.accessibilityLabel = values.name } + + self.buttonLabel.isHidden = true + + let buttonAction: AppAction + + if let action + { + buttonAction = action + } + else if let storeApp = app.storeApp + { + if let installedApp = storeApp.installedApp + { + // App is installed + + if installedApp.isUpdateAvailable + { + buttonAction = .update + } + else + { + buttonAction = .open + } + } + else + { + // App is not installed + buttonAction = .install + } + } + else + { + // App is not from a source, fall back to .open + buttonAction = .open + } + + switch buttonAction + { + case .open: + let buttonTitle = NSLocalizedString("Open", comment: "") + self.button.setTitle(buttonTitle.uppercased(), for: .normal) + self.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), values.name) + self.button.accessibilityValue = buttonTitle + + self.button.countdownDate = nil + + case .update: + let buttonTitle = NSLocalizedString("Update", comment: "") + self.button.setTitle(buttonTitle.uppercased(), for: .normal) + self.button.accessibilityLabel = String(format: NSLocalizedString("Update %@", comment: ""), values.name) + self.button.accessibilityValue = buttonTitle + + self.button.countdownDate = nil + + case .custom(let buttonTitle): + self.button.setTitle(buttonTitle, for: .normal) + self.button.accessibilityLabel = buttonTitle + self.button.accessibilityValue = buttonTitle + + self.button.countdownDate = nil + + case .install: + let buttonTitle = NSLocalizedString("Free", comment: "") + self.button.setTitle(buttonTitle.uppercased(), for: .normal) + self.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name) + self.button.accessibilityValue = buttonTitle + + if let versionDate = app.storeApp?.latestSupportedVersion?.date, versionDate > Date() + { + self.button.countdownDate = versionDate + } + else + { + self.button.countdownDate = nil + } + } + + // Ensure PillButton is correct size before assigning progress. + self.layoutIfNeeded() + + if let progress = AppManager.shared.installationProgress(for: app), progress.fractionCompleted < 1.0 + { + self.button.progress = progress + } + else + { + self.button.progress = nil + } } func configure(for source: Source) diff --git a/AltStore/Components/AppCardCollectionViewCell.swift b/AltStore/Components/AppCardCollectionViewCell.swift index 26fb00ba..25fa3046 100644 --- a/AltStore/Components/AppCardCollectionViewCell.swift +++ b/AltStore/Components/AppCardCollectionViewCell.swift @@ -289,6 +289,10 @@ extension AppCardCollectionViewCell { self.screenshots = storeApp.preferredScreenshots() + // Explicitly set to false to ensure we're starting from a non-activity indicating state. + // Otherwise, cell reuse can mess up some cached values. + self.bannerView.button.isIndicatingActivity = false + self.bannerView.tintColor = storeApp.tintColor self.bannerView.configure(for: storeApp) diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 656ac976..052ba97b 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -229,7 +229,8 @@ private extension MyAppsViewController cell.bannerView.iconImageView.image = nil cell.bannerView.iconImageView.isIndicatingActivity = true - cell.bannerView.configure(for: app) + cell.bannerView.button.isIndicatingActivity = false + cell.bannerView.configure(for: app, action: .update) let versionDate = Date().relativeDateString(since: latestSupportedVersion.date) cell.bannerView.subtitleLabel.text = versionDate @@ -247,7 +248,6 @@ private extension MyAppsViewController cell.bannerView.accessibilityLabel = String(format: NSLocalizedString("%@ %@ update. Released on %@.", comment: ""), appName, latestSupportedVersion.localizedVersion, versionDate) - cell.bannerView.button.isIndicatingActivity = false cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.updateApp(_:)), for: .primaryActionTriggered) cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Update %@", comment: ""), installedApp.name) @@ -262,9 +262,6 @@ private extension MyAppsViewController cell.versionDescriptionTextView.moreButton.addTarget(self, action: #selector(MyAppsViewController.toggleUpdateCellMode(_:)), for: .primaryActionTriggered) - let progress = AppManager.shared.installationProgress(for: app) - cell.bannerView.button.progress = progress - cell.setNeedsLayout() } dataSource.prefetchHandler = { (installedApp, indexPath, completionHandler) in @@ -332,17 +329,6 @@ private extension MyAppsViewController cell.deactivateBadge?.transform = CGAffineTransform.identity.scaledBy(x: 0.33, y: 0.33) } - cell.bannerView.configure(for: installedApp) - - cell.bannerView.iconImageView.isIndicatingActivity = true - - cell.bannerView.buttonLabel.isHidden = false - cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "") - - cell.bannerView.button.isIndicatingActivity = false - cell.bannerView.button.removeTarget(self, action: nil, for: .primaryActionTriggered) - cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered) - let currentDate = Date() let numberOfDays = installedApp.expirationDate.numberOfCalendarDays(since: currentDate) @@ -357,7 +343,17 @@ private extension MyAppsViewController numberOfDaysText = String(format: NSLocalizedString("%@ days", comment: ""), NSNumber(value: numberOfDays)) } - cell.bannerView.button.setTitle(numberOfDaysText.uppercased(), for: .normal) + cell.bannerView.button.isIndicatingActivity = false + cell.bannerView.configure(for: installedApp, action: .custom(numberOfDaysText.uppercased())) + + cell.bannerView.iconImageView.isIndicatingActivity = true + + cell.bannerView.buttonLabel.isHidden = false + cell.bannerView.buttonLabel.text = NSLocalizedString("Expires in", comment: "") + + cell.bannerView.button.removeTarget(self, action: nil, for: .primaryActionTriggered) + cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.refreshApp(_:)), for: .primaryActionTriggered) + cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Refresh %@", comment: ""), installedApp.name) if let storeApp = installedApp.storeApp, storeApp.isPledgeRequired, !storeApp.isPledged @@ -443,11 +439,10 @@ private extension MyAppsViewController cell.deactivateBadge?.alpha = 0.0 cell.deactivateBadge?.transform = CGAffineTransform.identity.scaledBy(x: 0.5, y: 0.5) - cell.bannerView.configure(for: installedApp) - cell.bannerView.button.isIndicatingActivity = false + cell.bannerView.configure(for: installedApp, action: .custom(NSLocalizedString("ACTIVATE", comment: ""))) + cell.bannerView.button.tintColor = tintColor - cell.bannerView.button.setTitle(NSLocalizedString("ACTIVATE", comment: ""), for: .normal) cell.bannerView.button.removeTarget(self, action: nil, for: .primaryActionTriggered) cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.activateApp(_:)), for: .primaryActionTriggered) cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Activate %@", comment: ""), installedApp.name) diff --git a/AltStore/My Apps/UpdateCollectionViewCell.swift b/AltStore/My Apps/UpdateCollectionViewCell.swift index beecafdd..527fe19f 100644 --- a/AltStore/My Apps/UpdateCollectionViewCell.swift +++ b/AltStore/My Apps/UpdateCollectionViewCell.swift @@ -42,8 +42,7 @@ extension UpdateCollectionViewCell self.contentView.preservesSuperviewLayoutMargins = true self.bannerView.backgroundEffectView.isHidden = true - self.bannerView.button.setTitle(NSLocalizedString("UPDATE", comment: ""), for: .normal) - + self.blurView.layer.cornerRadius = 20 self.blurView.layer.masksToBounds = true diff --git a/AltStore/News/NewsViewController.swift b/AltStore/News/NewsViewController.swift index 76c9d0c0..8de8e78a 100644 --- a/AltStore/News/NewsViewController.swift +++ b/AltStore/News/NewsViewController.swift @@ -341,7 +341,7 @@ private extension NewsViewController let app = self.dataSource.item(at: indexPath) guard let storeApp = app.storeApp else { return } - if let installedApp = app.storeApp?.installedApp + if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable { self.open(installedApp) } @@ -359,7 +359,21 @@ private extension NewsViewController return } - _ = AppManager.shared.install(storeApp, presentingViewController: self) { (result) in + if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable + { + AppManager.shared.update(installedApp, presentingViewController: self, completionHandler: finish(_:)) + } + else + { + AppManager.shared.install(storeApp, presentingViewController: self, completionHandler: finish(_:)) + } + + UIView.performWithoutAnimation { + self.collectionView.reloadSections(IndexSet(integer: indexPath.section)) + } + + func finish(_ result: Result) + { DispatchQueue.main.async { switch result { @@ -377,10 +391,6 @@ private extension NewsViewController } } } - - UIView.performWithoutAnimation { - self.collectionView.reloadSections(IndexSet(integer: indexPath.section)) - } } func open(_ installedApp: InstalledApp) @@ -426,42 +436,13 @@ extension NewsViewController footerView.layoutMargins.left = self.view.layoutMargins.left footerView.layoutMargins.right = self.view.layoutMargins.right + footerView.bannerView.button.isIndicatingActivity = false footerView.bannerView.configure(for: storeApp) footerView.bannerView.tintColor = storeApp.tintColor footerView.bannerView.button.addTarget(self, action: #selector(NewsViewController.performAppAction(_:)), for: .primaryActionTriggered) footerView.tapGestureRecognizer.addTarget(self, action: #selector(NewsViewController.handleTapGesture(_:))) - footerView.bannerView.button.isIndicatingActivity = false - - if storeApp.installedApp == nil - { - let buttonTitle = NSLocalizedString("Free", comment: "") - footerView.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal) - footerView.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), storeApp.name) - footerView.bannerView.button.accessibilityValue = buttonTitle - - let progress = AppManager.shared.installationProgress(for: storeApp) - footerView.bannerView.button.progress = progress - - if let versionDate = storeApp.latestSupportedVersion?.date, versionDate > Date() - { - footerView.bannerView.button.countdownDate = versionDate - } - else - { - footerView.bannerView.button.countdownDate = nil - } - } - else - { - footerView.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal) - footerView.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), storeApp.name) - footerView.bannerView.button.accessibilityValue = nil - footerView.bannerView.button.progress = nil - footerView.bannerView.button.countdownDate = nil - } - Nuke.loadImage(with: storeApp.iconURL, into: footerView.bannerView.iconImageView) return footerView diff --git a/AltStore/Sources/SourceDetailContentViewController.swift b/AltStore/Sources/SourceDetailContentViewController.swift index e2cd8eb0..d1ef6e56 100644 --- a/AltStore/Sources/SourceDetailContentViewController.swift +++ b/AltStore/Sources/SourceDetailContentViewController.swift @@ -225,43 +225,13 @@ private extension SourceDetailContentViewController cell.contentView.layoutMargins = .zero cell.contentView.backgroundColor = .altBackground + cell.bannerView.button.isIndicatingActivity = false cell.bannerView.configure(for: storeApp) - cell.bannerView.iconImageView.isIndicatingActivity = true - cell.bannerView.buttonLabel.isHidden = true - - cell.bannerView.button.isIndicatingActivity = false cell.bannerView.button.tintColor = storeApp.tintColor + cell.bannerView.button.addTarget(self, action: #selector(SourceDetailContentViewController.performAppAction(_:)), for: .primaryActionTriggered) - let buttonTitle = NSLocalizedString("Free", comment: "") - cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal) - cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), storeApp.name) - cell.bannerView.button.accessibilityValue = buttonTitle - cell.bannerView.button.addTarget(self, action: #selector(SourceDetailContentViewController.addSourceThenDownloadApp(_:)), for: .primaryActionTriggered) - - let progress = AppManager.shared.installationProgress(for: storeApp) - cell.bannerView.button.progress = progress - - if let versionDate = storeApp.latestSupportedVersion?.date, versionDate > Date() - { - cell.bannerView.button.countdownDate = versionDate - } - else - { - cell.bannerView.button.countdownDate = nil - } - - // Make sure refresh button is correct size. - cell.layoutIfNeeded() - - if let progress = AppManager.shared.installationProgress(for: storeApp), progress.fractionCompleted < 1.0 - { - cell.bannerView.button.progress = progress - } - else - { - cell.bannerView.button.progress = nil - } + cell.bannerView.iconImageView.isIndicatingActivity = true } dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in return RSTAsyncBlockOperation { (operation) in @@ -404,64 +374,93 @@ extension SourceDetailContentViewController private extension SourceDetailContentViewController { - @objc func addSourceThenDownloadApp(_ sender: UIButton) + @objc func performAppAction(_ sender: PillButton) { let point = self.collectionView.convert(sender.center, from: sender.superview) guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return } - sender.isIndicatingActivity = true - let storeApp = self.dataSource.item(at: indexPath) as! StoreApp - Task { + if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable + { + self.open(installedApp) + } + else + { + sender.isIndicatingActivity = true + + Task { + await self.addSourceThenDownloadApp(storeApp) + sender.isIndicatingActivity = false + } + } + } + + func addSourceThenDownloadApp(_ storeApp: StoreApp) async + { + do + { + let isAdded = try await self.source.isAdded + if !isAdded + { + let message = String(format: NSLocalizedString("You must add this source before you can install apps from it.\n\n“%@” will begin downloading once it has been added.", comment: ""), storeApp.name) + try await AppManager.shared.add(self.source, message: message, presentingViewController: self) + } + do { - let isAdded = try await self.source.isAdded - if !isAdded - { - let message = String(format: NSLocalizedString("You must add this source before you can install apps from it.\n\n“%@” will begin downloading once it has been added.", comment: ""), storeApp.name) - try await AppManager.shared.add(self.source, message: message, presentingViewController: self) - } - - do - { - try await self.downloadApp(storeApp) - } - catch OperationError.cancelled {} - catch - { - let toastView = ToastView(error: error) - toastView.opensErrorLog = true - toastView.show(in: self) - } + try await self.downloadApp(storeApp) } catch is CancellationError {} catch { - await self.presentAlert(title: NSLocalizedString("Unable to Add Source", comment: ""), message: error.localizedDescription) + let toastView = ToastView(error: error) + toastView.opensErrorLog = true + toastView.show(in: self) } - - sender.isIndicatingActivity = false - self.collectionView.reloadSections([Section.featuredApps.rawValue]) } + catch is CancellationError {} + catch + { + await self.presentAlert(title: NSLocalizedString("Unable to Add Source", comment: ""), message: error.localizedDescription) + } + + self.collectionView.reloadSections([Section.featuredApps.rawValue]) } + @MainActor func downloadApp(_ storeApp: StoreApp) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - AppManager.shared.install(storeApp, presentingViewController: self) { result in - continuation.resume(with: result.map { _ in }) + if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable + { + AppManager.shared.update(installedApp, presentingViewController: self) { result in + continuation.resume(with: result.map { _ in () }) + } + } + else + { + AppManager.shared.install(storeApp, presentingViewController: self) { result in + continuation.resume(with: result.map { _ in () }) + } } - guard let index = self.appsDataSource.items.firstIndex(of: storeApp) else { - self.collectionView.reloadSections([Section.featuredApps.rawValue]) - return + UIView.performWithoutAnimation { + guard let index = self.appsDataSource.items.firstIndex(of: storeApp) else { + self.collectionView.reloadSections([Section.featuredApps.rawValue]) + return + } + + let indexPath = IndexPath(item: index, section: Section.featuredApps.rawValue) + self.collectionView.reloadItems(at: [indexPath]) } - - let indexPath = IndexPath(item: index, section: Section.featuredApps.rawValue) - self.collectionView.reloadItems(at: [indexPath]) } } + + func open(_ installedApp: InstalledApp) + { + UIApplication.shared.open(installedApp.openAppURL) + } } extension SourceDetailContentViewController: ScrollableContentViewController diff --git a/AltStoreCore/Model/InstalledApp.swift b/AltStoreCore/Model/InstalledApp.swift index 39bdf4df..660a5c23 100644 --- a/AltStoreCore/Model/InstalledApp.swift +++ b/AltStoreCore/Model/InstalledApp.swift @@ -317,6 +317,13 @@ public extension InstalledApp let openAppURL = URL(string: "altstore-" + app.bundleIdentifier + "://")! return openAppURL } + + var isUpdateAvailable: Bool { + guard let storeApp = self.storeApp, let latestVersion = storeApp.latestSupportedVersion else { return false } + + let isUpdateAvailable = !self.matches(latestVersion) + return isUpdateAvailable + } } public extension InstalledApp From 2c1ffedfe3ae135a34c2afaffce75f7d5898aa1e Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Thu, 30 Nov 2023 18:54:03 -0600 Subject: [PATCH 17/20] Designates Patreon apps with label + displays price (if provided) --- AltStore/Components/AppBannerView.swift | 52 ++++++++++++++++++++++--- AltStore/Components/AppBannerView.xib | 49 +++++++++++------------ 2 files changed, 70 insertions(+), 31 deletions(-) diff --git a/AltStore/Components/AppBannerView.swift b/AltStore/Components/AppBannerView.swift index ed316768..6ddeb72a 100644 --- a/AltStore/Components/AppBannerView.swift +++ b/AltStore/Components/AppBannerView.swift @@ -159,7 +159,16 @@ extension AppBannerView self.accessibilityLabel = values.name } - self.buttonLabel.isHidden = true + if let storeApp = app.storeApp, storeApp.isPledgeRequired + { + // Always show button label for Patreon apps. + self.buttonLabel.isHidden = false + self.buttonLabel.text = storeApp.isPledged ? NSLocalizedString("Pledged", comment: "") : NSLocalizedString("Join Patreon", comment: "") + } + else + { + self.buttonLabel.isHidden = true + } let buttonAction: AppAction @@ -220,10 +229,43 @@ extension AppBannerView self.button.countdownDate = nil case .install: - let buttonTitle = NSLocalizedString("Free", comment: "") - self.button.setTitle(buttonTitle.uppercased(), for: .normal) - self.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name) - self.button.accessibilityValue = buttonTitle + if let storeApp = app.storeApp, storeApp.isPledgeRequired + { + // Pledge required + + if storeApp.isPledged + { + let buttonTitle = NSLocalizedString("Install", comment: "") + self.button.setTitle(buttonTitle.uppercased(), for: .normal) + self.button.accessibilityLabel = String(format: NSLocalizedString("Install %@", comment: ""), app.name) + self.button.accessibilityValue = buttonTitle + } + else if let amount = storeApp.pledgeAmount, let currencyCode = storeApp.pledgeCurrency, #available(iOS 15, *) + { + let price = amount.formatted(.currency(code: currencyCode).presentation(.narrow).precision(.fractionLength(0...2))) + + let buttonTitle = String(format: NSLocalizedString("%@/mo", comment: ""), price) + self.button.setTitle(buttonTitle, for: .normal) + self.button.accessibilityLabel = String(format: NSLocalizedString("Pledge %@ a month", comment: ""), price) + self.button.accessibilityValue = String(format: NSLocalizedString("%@ a month", comment: ""), price) + } + else + { + let buttonTitle = NSLocalizedString("Pledge", comment: "") + self.button.setTitle(buttonTitle.uppercased(), for: .normal) + self.button.accessibilityLabel = buttonTitle + self.button.accessibilityValue = buttonTitle + } + } + else + { + // Free app + + let buttonTitle = NSLocalizedString("Free", comment: "") + self.button.setTitle(buttonTitle.uppercased(), for: .normal) + self.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), app.name) + self.button.accessibilityValue = buttonTitle + } if let versionDate = app.storeApp?.latestSupportedVersion?.date, versionDate > Date() { diff --git a/AltStore/Components/AppBannerView.xib b/AltStore/Components/AppBannerView.xib index e4d18eee..d140f5ae 100644 --- a/AltStore/Components/AppBannerView.xib +++ b/AltStore/Components/AppBannerView.xib @@ -1,9 +1,9 @@ - + - + @@ -78,13 +78,13 @@ - + - + - - + - - + + + + + + + + + + + From 92f3be07f66cd286237f29199246a16b19a6fbc1 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Fri, 1 Dec 2023 16:03:06 -0600 Subject: [PATCH 18/20] Downloads latest _available_ version when updating from AppViewController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asks user to fall back to latest supported verson if version is not compatible with device’s iOS version. --- AltStore/App Detail/AppViewController.swift | 18 ++++-- AltStore/Managing Apps/AppManager.swift | 4 +- .../Operations/DownloadAppOperation.swift | 55 +++++++++++-------- 3 files changed, 47 insertions(+), 30 deletions(-) diff --git a/AltStore/App Detail/AppViewController.swift b/AltStore/App Detail/AppViewController.swift index a3fc871b..cca50224 100644 --- a/AltStore/App Detail/AppViewController.swift +++ b/AltStore/App Detail/AppViewController.swift @@ -360,13 +360,21 @@ private extension AppViewController { func update() { + var buttonAction: AppBannerView.AppAction? + + if let installedApp = self.app.installedApp, let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion) + { + // Explicitly set button action to .update if there is an update available, even if it's not supported. + buttonAction = .update + } + for button in [self.bannerView.button!, self.navigationBarDownloadButton!] { button.tintColor = self.app.tintColor button.isIndicatingActivity = false } - self.bannerView.configure(for: self.app) + self.bannerView.configure(for: self.app, action: buttonAction) let title = self.bannerView.button.title(for: .normal) self.navigationBarDownloadButton.setTitle(title, for: .normal) @@ -498,9 +506,9 @@ extension AppViewController { if let installedApp = self.app.installedApp { - if let latestVersion = self.app.latestSupportedVersion, !installedApp.matches(latestVersion) + if let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion) { - self.updateApp(installedApp) + self.updateApp(installedApp, to: latestVersion) } else { @@ -551,7 +559,7 @@ extension AppViewController UIApplication.shared.open(installedApp.openAppURL) } - func updateApp(_ installedApp: InstalledApp) + func updateApp(_ installedApp: InstalledApp, to version: AppVersion) { let previousProgress = AppManager.shared.installationProgress(for: installedApp) guard previousProgress == nil else { @@ -560,7 +568,7 @@ extension AppViewController return } - _ = AppManager.shared.update(installedApp, presentingViewController: self) { (result) in + AppManager.shared.update(installedApp, to: version, presentingViewController: self) { (result) in DispatchQueue.main.async { switch result { diff --git a/AltStore/Managing Apps/AppManager.swift b/AltStore/Managing Apps/AppManager.swift index 0218518f..48bda993 100644 --- a/AltStore/Managing Apps/AppManager.swift +++ b/AltStore/Managing Apps/AppManager.swift @@ -557,9 +557,9 @@ extension AppManager } @discardableResult - func update(_ installedApp: InstalledApp, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result) -> Void) -> Progress + func update(_ installedApp: InstalledApp, to version: AppVersion? = nil, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result) -> Void) -> Progress { - guard let appVersion = installedApp.storeApp?.latestSupportedVersion else { + guard let appVersion = version ?? installedApp.storeApp?.latestSupportedVersion else { completionHandler(.failure(OperationError.appNotFound(name: installedApp.name))) return Progress.discreteProgress(totalUnitCount: 1) } diff --git a/AltStore/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index ef9c474f..39dccba5 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -17,7 +17,9 @@ import Roxas @objc(DownloadAppOperation) class DownloadAppOperation: ResultOperation { - let app: AppProtocol + @Managed + private(set) var app: AppProtocol + let context: InstallAppOperationContext private let appName: String @@ -59,22 +61,36 @@ class DownloadAppOperation: ResultOperation // Set _after_ checking self.context.error to prevent overwriting localized failure for previous errors. self.localizedFailure = String(format: NSLocalizedString("%@ could not be downloaded.", comment: ""), self.appName) - guard let storeApp = self.app as? StoreApp else { - // Only StoreApp allows falling back to previous versions. - // AppVersion can only install itself, and ALTApplication doesn't have previous versions. - return self.download(self.app) - } - - // Verify storeApp - storeApp.managedObjectContext?.perform { + self.$app.perform { app in do { - let latestVersion = try self.verify(storeApp) - self.download(latestVersion) + var appVersion: AppVersion? + + if let version = app as? AppVersion + { + appVersion = version + } + else if let storeApp = app as? StoreApp + { + guard let latestVersion = storeApp.latestAvailableVersion else { + let failureReason = String(format: NSLocalizedString("The latest version of %@ could not be determined.", comment: ""), self.appName) + throw OperationError.unknown(failureReason: failureReason) + } + + // Attempt to download latest _available_ version, and fall back to older versions if necessary. + appVersion = latestVersion + } + + if let appVersion + { + try self.verify(appVersion) + } + + self.download(appVersion ?? app) } catch let error as VerificationError where error.code == .iOSVersionNotSupported { - guard let presentingViewController = self.context.presentingViewController, let latestSupportedVersion = storeApp.latestSupportedVersion + guard let presentingViewController = self.context.presentingViewController, let storeApp = app.storeApp, let latestSupportedVersion = storeApp.latestSupportedVersion else { return self.finish(.failure(error)) } if let installedApp = storeApp.installedApp @@ -85,7 +101,7 @@ class DownloadAppOperation: ResultOperation let title = NSLocalizedString("Unsupported iOS Version", comment: "") let message = error.localizedDescription + "\n\n" + NSLocalizedString("Would you like to download the last version compatible with this device instead?", comment: "") let localizedVersion = latestSupportedVersion.localizedVersion - + DispatchQueue.main.async { let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { _ in @@ -121,23 +137,16 @@ class DownloadAppOperation: ResultOperation private extension DownloadAppOperation { - func verify(_ storeApp: StoreApp) throws -> AppVersion + func verify(_ version: AppVersion) throws { - guard let version = storeApp.latestAvailableVersion else { - let failureReason = String(format: NSLocalizedString("The latest version of %@ could not be determined.", comment: ""), self.appName) - throw OperationError.unknown(failureReason: failureReason) - } - if let minOSVersion = version.minOSVersion, !ProcessInfo.processInfo.isOperatingSystemAtLeast(minOSVersion) { - throw VerificationError.iOSVersionNotSupported(app: storeApp, requiredOSVersion: minOSVersion) + throw VerificationError.iOSVersionNotSupported(app: version, requiredOSVersion: minOSVersion) } else if let maxOSVersion = version.maxOSVersion, ProcessInfo.processInfo.operatingSystemVersion > maxOSVersion { - throw VerificationError.iOSVersionNotSupported(app: storeApp, requiredOSVersion: maxOSVersion) + throw VerificationError.iOSVersionNotSupported(app: version, requiredOSVersion: maxOSVersion) } - - return version } func download(@Managed _ app: AppProtocol) From 42302786e28f803e4971953eb92cf278a3fad3a0 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Fri, 1 Dec 2023 16:38:31 -0600 Subject: [PATCH 19/20] Disables actions for Patreon apps with expired pledges instead of hiding them --- AltStore/My Apps/MyAppsViewController.swift | 34 ++++++++++++++------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/AltStore/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index 052ba97b..ebe53070 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -1879,18 +1879,30 @@ extension MyAppsViewController let error = OperationError.pledgeInactive(appName: installedApp.name) title = error.localizedDescription - // Limit options for apps requiring pledges that we are no longer pledged to. - actions = actions.filter { - $0 == openMenu || - $0 == deactivateAction || - $0 == removeAction || - $0 == backupAction || - $0 == exportBackupAction || - ($0 == refreshAction && storeApp.bundleIdentifier == StoreApp.altstoreAppID) // Always show refresh option for AltStore so the menu will be shown. - } + let allowedActions: Set = [ + openMenu, + deactivateAction, + removeAction, + backupAction, + exportBackupAction + ] - // Disable refresh action for AltStore. - refreshAction.attributes = .disabled + for action in actions where !allowedActions.contains(action) + { + // Disable options for Patreon apps that we are no longer pledged to. + + if let action = action as? UIAction + { + action.attributes = .disabled + } + else if let menu = action as? UIMenu + { + for case let action as UIAction in menu.children + { + action.attributes = .disabled + } + } + } } let menu = UIMenu(title: title ?? "", children: actions) From fe3d8a4edb1085c4991f726c577ea0195ceec06a Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Fri, 1 Dec 2023 16:42:49 -0600 Subject: [PATCH 20/20] =?UTF-8?q?Hides=20=E2=80=9CUPDATE=E2=80=9D=20option?= =?UTF-8?q?=20for=20Patreon=20apps=20with=20expired=20pledges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AltStore/App Detail/AppViewController.swift | 4 ++-- AltStore/Components/AppBannerView.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AltStore/App Detail/AppViewController.swift b/AltStore/App Detail/AppViewController.swift index cca50224..7f103d38 100644 --- a/AltStore/App Detail/AppViewController.swift +++ b/AltStore/App Detail/AppViewController.swift @@ -362,7 +362,7 @@ private extension AppViewController { var buttonAction: AppBannerView.AppAction? - if let installedApp = self.app.installedApp, let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion) + if let installedApp = self.app.installedApp, let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged { // Explicitly set button action to .update if there is an update available, even if it's not supported. buttonAction = .update @@ -506,7 +506,7 @@ extension AppViewController { if let installedApp = self.app.installedApp { - if let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion) + if let latestVersion = self.app.latestAvailableVersion, !installedApp.matches(latestVersion), !self.app.isPledgeRequired || self.app.isPledged { self.updateApp(installedApp, to: latestVersion) } diff --git a/AltStore/Components/AppBannerView.swift b/AltStore/Components/AppBannerView.swift index 6ddeb72a..4cbe3632 100644 --- a/AltStore/Components/AppBannerView.swift +++ b/AltStore/Components/AppBannerView.swift @@ -182,7 +182,7 @@ extension AppBannerView { // App is installed - if installedApp.isUpdateAvailable + if installedApp.isUpdateAvailable && (!storeApp.isPledgeRequired || storeApp.isPledged) { buttonAction = .update }