diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 319894d4..c23d88f4 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 */; }; @@ -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 */; }; @@ -373,8 +374,12 @@ 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 */; }; + 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 */; }; @@ -418,6 +423,9 @@ 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 */; }; D5B6F6A92AD75D01007EED5A /* ProcessInfo+Previews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */; }; D5B6F6AB2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */; }; @@ -753,9 +761,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 = ""; }; @@ -970,6 +978,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 = ""; }; @@ -986,8 +995,12 @@ 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 = ""; }; + 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 = ""; }; @@ -1028,6 +1041,9 @@ 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 = ""; }; 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 = ""; }; @@ -1484,6 +1500,7 @@ isa = PBXGroup; children = ( BF66EE8B2501AEB1007EE018 /* Keychain.swift */, + D52B4ABE2AF183F0005991C3 /* WebViewController.swift */, ); path = Components; sourceTree = ""; @@ -1496,8 +1513,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 */, ); @@ -1518,11 +1535,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 = ""; @@ -1543,13 +1562,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; @@ -1609,6 +1628,7 @@ D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */, D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */, D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */, + D552EB052AF453F900A3AB4D /* URL+Normalized.swift */, ); path = Extensions; sourceTree = ""; @@ -1923,6 +1943,7 @@ D5927D6529DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift */, D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */, D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */, + D5A645202AF591980047D980 /* UTType+AltStore.swift */, ); path = Extensions; sourceTree = ""; @@ -2168,6 +2189,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 = ( @@ -2328,7 +2360,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 */, @@ -3036,12 +3068,15 @@ 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 */, BFAECC5B2501B0A400528F27 /* Bundle+AltStore.swift in Sources */, 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 */, @@ -3068,11 +3103,12 @@ 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 */, 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 */, @@ -3104,6 +3140,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 */, @@ -3115,6 +3152,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; @@ -3244,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/App Detail/AppViewController.swift b/AltStore/App Detail/AppViewController.swift index 3d17e8f3..7f103d38 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) @@ -362,41 +360,26 @@ private extension AppViewController { func update() { + var buttonAction: AppBannerView.AppAction? + + 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 + } + for button in [self.bannerView.button!, self.navigationBarDownloadButton!] { 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, action: buttonAction) + + 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 @@ -523,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.app.isPledgeRequired || self.app.isPledged { - self.updateApp(installedApp) + self.updateApp(installedApp, to: latestVersion) } else { @@ -576,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 { @@ -585,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/Browse/BrowseViewController.swift b/AltStore/Browse/BrowseViewController.swift index 2bd8e454..cc991e34 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) @@ -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 @@ -202,18 +170,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 @@ -317,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) } @@ -335,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 { @@ -344,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..4cbe3632 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,136 @@ extension AppBannerView self.subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "") self.accessibilityLabel = values.name } + + 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 + + if let action + { + buttonAction = action + } + else if let storeApp = app.storeApp + { + if let installedApp = storeApp.installedApp + { + // App is installed + + if installedApp.isUpdateAvailable && (!storeApp.isPledgeRequired || storeApp.isPledged) + { + 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: + 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() + { + 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/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 @@ - + - + - - + - - + + + + + + + + + + + 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/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 fdbca9bf..61262e09 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) } @@ -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) @@ -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/My Apps/MyAppsViewController.swift b/AltStore/My Apps/MyAppsViewController.swift index ddce5cc4..ebe53070 100644 --- a/AltStore/My Apps/MyAppsViewController.swift +++ b/AltStore/My Apps/MyAppsViewController.swift @@ -112,11 +112,13 @@ 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) + + // Ensure the button for each app reflects correct Patreon status. + self.collectionView.reloadData() - self.updateDataSource() self.update() self.fetchAppIDs() @@ -227,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 @@ -245,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) @@ -260,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 @@ -330,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) @@ -355,9 +343,30 @@ 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 + { + 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. @@ -430,15 +439,25 @@ 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) + 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() @@ -475,33 +494,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 @@ -710,10 +702,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 { @@ -1704,7 +1715,7 @@ extension MyAppsViewController extension MyAppsViewController { - private func actions(for installedApp: InstalledApp) -> [UIMenuElement] + private func contextMenu(for installedApp: InstalledApp) -> UIMenu { var actions = [UIMenuElement]() @@ -1762,103 +1773,140 @@ 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 + } + + var title: String? + + if let storeApp = installedApp.storeApp, storeApp.isPledgeRequired, !storeApp.isPledged + { + let error = OperationError.pledgeInactive(appName: installedApp.name) + title = error.localizedDescription + + let allowedActions: Set = [ + openMenu, + deactivateAction, + removeAction, + backupAction, + exportBackupAction + ] + + 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 + } + } } } - if installedApp.isActive - { - 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 - - return actions + let menu = UIMenu(title: title ?? "", children: actions) + return menu } override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? @@ -1871,9 +1919,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/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/Operations/DownloadAppOperation.swift b/AltStore/Operations/DownloadAppOperation.swift index 7fb2e597..39dccba5 100644 --- a/AltStore/Operations/DownloadAppOperation.swift +++ b/AltStore/Operations/DownloadAppOperation.swift @@ -7,15 +7,19 @@ // import Foundation -import Roxas +import WebKit +import UniformTypeIdentifiers import AltStoreCore import AltSign +import Roxas @objc(DownloadAppOperation) class DownloadAppOperation: ResultOperation { - let app: AppProtocol + @Managed + private(set) var app: AppProtocol + let context: InstallAppOperationContext private let appName: String @@ -25,6 +29,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 @@ -55,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 @@ -81,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 @@ -117,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) @@ -194,11 +207,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, 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, + 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 +280,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 +307,162 @@ private extension DownloadAppOperation downloadTask.resume() } } + + 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. + + 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/AltStore/Operations/FetchSourceOperation.swift b/AltStore/Operations/FetchSourceOperation.swift index d9827560..7c0ec04e 100644 --- a/AltStore/Operations/FetchSourceOperation.swift +++ b/AltStore/Operations/FetchSourceOperation.swift @@ -153,7 +153,13 @@ 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) try childContext.save() @@ -223,6 +229,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/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/AltStore/Settings/PatreonViewController.swift b/AltStore/Settings/PatreonViewController.swift index 9494e932..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) @@ -191,19 +191,35 @@ 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() 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 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/AltStore/Sources/SourceDetailContentViewController.swift b/AltStore/Sources/SourceDetailContentViewController.swift index 525b435d..d1ef6e56 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 @@ -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/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/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/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/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/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/Model/AltStore.xcdatamodeld/AltStore 14.xcdatamodel/contents b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 14.xcdatamodel/contents index dc9d4873..0e2fb7df 100644 --- a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 14.xcdatamodel/contents +++ b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 14.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -154,6 +154,7 @@ + @@ -169,6 +170,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -188,6 +223,7 @@ + @@ -207,8 +243,13 @@ + + + + + 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/InstalledApp.swift b/AltStoreCore/Model/InstalledApp.swift index 6ed5aa49..660a5c23 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 } @@ -227,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)], @@ -246,7 +247,20 @@ 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 + { + 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 + { + // No associated storeApp, so add it just to be safe. + installedApps.append(altStoreApp) + } } return installedApps @@ -257,20 +271,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)], @@ -278,8 +286,19 @@ 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 + { + 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 + { + // No associated storeApp, so add it just to be safe. + installedApps.append(altStoreApp) + } } return installedApps @@ -298,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 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/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/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/Patreon/PatreonAccount.swift b/AltStoreCore/Model/Patreon/PatreonAccount.swift new file mode 100644 index 00000000..40fcd77e --- /dev/null +++ b/AltStoreCore/Model/Patreon/PatreonAccount.swift @@ -0,0 +1,75 @@ +// +// PatreonAccount.swift +// AltStore +// +// Created by Riley Testut on 8/20/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import CoreData + +@objc(PatreonAccount) +public class PatreonAccount: NSManagedObject, Fetchable +{ + @NSManaged public var identifier: String + + @NSManaged public var name: String + @NSManaged public var firstName: String? + + // Use `isPatron` for backwards compatibility. + @NSManaged @objc(isPatron) public var isAltStorePatron: Bool + + /* Relationships */ + @nonobjc public var pledges: Set { _pledges as! Set } + @NSManaged @objc(pledges) internal var _pledges: NSSet + + private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) + { + super.init(entity: entity, insertInto: context) + } + + init(account: PatreonAPI.UserAccount, context: NSManagedObjectContext) + { + super.init(entity: PatreonAccount.entity(), insertInto: context) + + self.identifier = account.identifier + self.name = account.name + self.firstName = account.firstName + + 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) + self.isAltStorePatron = isActivePatron + } + else + { + self.isAltStorePatron = false + } + } +} + +public extension PatreonAccount +{ + @nonobjc class func fetchRequest() -> NSFetchRequest + { + return NSFetchRequest(entityName: "PatreonAccount") + } +} + 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/Model/PatreonAccount.swift b/AltStoreCore/Model/PatreonAccount.swift deleted file mode 100644 index 95259c1e..00000000 --- a/AltStoreCore/Model/PatreonAccount.swift +++ /dev/null @@ -1,74 +0,0 @@ -// -// PatreonAccount.swift -// AltStore -// -// Created by Riley Testut on 8/20/19. -// Copyright © 2019 Riley Testut. All rights reserved. -// - -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 -{ - @NSManaged public var identifier: String - - @NSManaged public var name: String - @NSManaged public var firstName: String? - - @NSManaged public var isPatron: Bool - - private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) - { - super.init(entity: entity, insertInto: context) - } - - init(response: PatreonAPI.AccountResponse, 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 - - if let patronResponse = response.included?.first - { - let patron = Patron(response: patronResponse) - self.isPatron = (patron.status == .active) - } - else - { - self.isPatron = false - } - } -} - -public extension PatreonAccount -{ - @nonobjc class func fetchRequest() -> NSFetchRequest - { - return NSFetchRequest(entityName: "PatreonAccount") - } -} - diff --git a/AltStoreCore/Model/Source.swift b/AltStoreCore/Model/Source.swift index 2d958c92..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) { @@ -252,50 +255,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]?) diff --git a/AltStoreCore/Model/StoreApp.swift b/AltStoreCore/Model/StoreApp.swift index 2a5035f8..9295ef5e 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? { @@ -94,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 @@ -137,6 +163,7 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable case size case isBeta = "beta" case versions + case patreon // Legacy case version @@ -247,6 +274,39 @@ 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 + } + + self._tierIDs = patreon.tiers + self._rewardID = patreon.benefit + } + else + { + self.isPledgeRequired = false + self.isHiddenWithoutPledge = false + self._pledgeAmount = nil + self.pledgeCurrency = nil + + self._tierIDs = nil + self._rewardID = nil + } } catch { @@ -376,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 { 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..ae9744b0 100644 --- a/AltStoreCore/Patreon/PatreonAPI.swift +++ b/AltStoreCore/Patreon/PatreonAPI.swift @@ -9,12 +9,11 @@ import Foundation import AuthenticationServices import CoreData +import WebKit 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 +33,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 @@ -85,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() @@ -93,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), @@ -113,39 +112,61 @@ 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) { 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,amount_cents"), + URLQueryItem(name: "fields[benefit]", value: "title"), + URLQueryItem(name: "fields[campaign]", value: "url"), + URLQueryItem(name: "fields[member]", value: "full_name,patron_status,currently_entitled_amount_cents")] let requestURL = components.url(relativeTo: self.baseURL)! let request = URLRequest(url: requestURL) - self.send(request, authorizationType: .user) { (result: Result) in + self.send(request, authorizationType: .user) { (result: Result) in switch result { case .failure(~PatreonAPIErrorCode.notAuthenticated): @@ -153,10 +174,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 +192,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[member]", value: "full_name,patron_status"), + URLQueryItem(name: "fields[tier]", value: "title,amount_cents"), + URLQueryItem(name: "fields[benefit]", value: "title"), + URLQueryItem(name: "fields[member]", value: "full_name,patron_status,currently_entitled_amount_cents"), URLQueryItem(name: "page[size]", value: "1000")] let requestURL = components.url(relativeTo: self.baseURL)! - 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) } @@ -239,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() @@ -247,7 +251,10 @@ public extension PatreonAPI Keychain.shared.patreonRefreshToken = nil Keychain.shared.patreonAccountID = nil - completion(.success(())) + Task.detached { + await self.deleteAuthCookies() + completion(.success(())) + } } catch { @@ -264,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 @@ -281,6 +281,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) @@ -397,36 +447,27 @@ 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: 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.") } } diff --git a/AltStoreCore/Patreon/Patron.swift b/AltStoreCore/Patreon/Patron.swift index ef55bab4..b1429407 100644 --- a/AltStoreCore/Patreon/Patron.swift +++ b/AltStoreCore/Patreon/Patron.swift @@ -10,38 +10,23 @@ 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? + var currently_entitled_amount_cents: Int32 // In campaign's currency + } + + struct PatronRelationships: Decodable + { + var campaign: Response? + var currently_entitled_tiers: Response<[AnyItemResponse]>? } } -extension Patron +extension PatreonAPI { public enum Status: String, Decodable { @@ -50,29 +35,48 @@ 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 pledgeAmount: Decimal? + 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 + self.pledgeAmount = Decimal(response.attributes.currently_entitled_amount_cents) / 100 + + if let status = response.attributes.patron_status + { + self.status = Status(rawValue: status) ?? .unknown + } + else + { + self.status = .unknown + } + + guard let included, let relationships = response.relationships else { return } + + if let campaignID = relationships.campaign?.data.id, let response = included.campaigns[campaignID] + { + let campaign = Campaign(response: response) + self.campaign = campaign + } + + let tiers = (relationships.currently_entitled_tiers?.data ?? []).compactMap { included.tiers[$0.id] }.map { Tier(response: $0, including: included) } + self.tiers = Set(tiers) + + let benefits = tiers.flatMap { $0.benefits } + self.benefits = Set(benefits) } } } diff --git a/AltStoreCore/Patreon/Tier.swift b/AltStoreCore/Patreon/Tier.swift index 0bf0dc88..34485bf8 100644 --- a/AltStoreCore/Patreon/Tier.swift +++ b/AltStoreCore/Patreon/Tier.swift @@ -10,41 +10,43 @@ 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 + var amount_cents: Int32 // In USD + } + + 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 + public var amount: Decimal + + // Relationships + public var benefits: [Benefit] = [] + + internal init(response: TierResponse, including included: IncludedResponses?) + { + self.name = response.attributes.title + self.identifier = response.id + + let amount = Decimal(response.attributes.amount_cents) / 100 + self.amount = amount + + guard let included, let benefitIDs = response.relationships?.benefits?.data.map(\.id) else { return } + + let benefits = benefitIDs.compactMap { included.benefits[$0] }.map(Benefit.init(response:)) + self.benefits = benefits + } } } 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/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 + } } 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"; 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"; 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") } } }