Merge branch 'patreon'

This commit is contained in:
Riley Testut
2023-12-01 17:20:24 -06:00
52 changed files with 2286 additions and 824 deletions

View File

@@ -133,9 +133,9 @@
BF66EE8C2501AEB2007EE018 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE8B2501AEB1007EE018 /* Keychain.swift */; }; 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, ); }; }; 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, ); }; }; 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 */; }; 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 */; }; BF66EE992501AEBC007EE018 /* ALTSourceUserInfoKey.m in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE932501AEBC007EE018 /* ALTSourceUserInfoKey.m */; };
BF66EE9D2501AEC1007EE018 /* AppProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE9B2501AEC1007EE018 /* AppProtocol.swift */; }; BF66EE9D2501AEC1007EE018 /* AppProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE9B2501AEC1007EE018 /* AppProtocol.swift */; };
BF66EE9E2501AEC1007EE018 /* Fetchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF66EE9C2501AEC1007EE018 /* Fetchable.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 */; }; D51AD27F29356B7B00967AAA /* ALTWrappedError.m in Sources */ = {isa = PBXBuildFile; fileRef = D51AD27D29356B7B00967AAA /* ALTWrappedError.m */; };
D51AD28029356B8000967AAA /* 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 */; }; 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 */; }; D52C08EE28AEC37A006C4AE5 /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */; };
D52DD35E2AAA89A600A7F2B6 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D52DD35D2AAA89A600A7F2B6 /* AltSign-Dynamic */; }; D52DD35E2AAA89A600A7F2B6 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D52DD35D2AAA89A600A7F2B6 /* AltSign-Dynamic */; };
D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */; }; 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 */; }; D5418F172AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */; };
D54DED1428CBC44B008B27A0 /* ErrorLogTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */; }; D54DED1428CBC44B008B27A0 /* ErrorLogTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */; };
D552B1D82A042A740066216F /* AppPermissionsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D552B1D72A042A740066216F /* AppPermissionsCard.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 */; }; D55467B82A8D5E2600F4CE90 /* AppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */; };
D55467C52A8D72C300F4CE90 /* ActiveAppsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.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 */; }; 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, ); }; }; 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 */; }; 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 */; }; D5A299872AAB9E4E00A3988D /* ProcessError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FB7A1B2AA284ED00EF863D /* ProcessError.swift */; };
D5A299882AAB9E4E00A3988D /* JITError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A1D2E32AA50EB60066CACC /* JITError.swift */; }; D5A299882AAB9E4E00A3988D /* JITError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A1D2E32AA50EB60066CACC /* JITError.swift */; };
D5A299892AAB9E5900A3988D /* AppProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59A6B7D2AA9226C00F61259 /* AppProcess.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 */; }; D5ACE84528E3B8450021CAB9 /* ClearAppCacheOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */; };
D5B6F6A92AD75D01007EED5A /* ProcessInfo+Previews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.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 */; }; 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 = "<group>"; }; BF66EE8B2501AEB1007EE018 /* Keychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = "<group>"; };
BF66EE8E2501AEBC007EE018 /* ALTAppPermissions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTAppPermissions.h; sourceTree = "<group>"; }; BF66EE8E2501AEBC007EE018 /* ALTAppPermissions.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTAppPermissions.h; sourceTree = "<group>"; };
BF66EE8F2501AEBC007EE018 /* ALTSourceUserInfoKey.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTSourceUserInfoKey.h; sourceTree = "<group>"; }; BF66EE8F2501AEBC007EE018 /* ALTSourceUserInfoKey.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTSourceUserInfoKey.h; sourceTree = "<group>"; };
BF66EE902501AEBC007EE018 /* ALTPatreonBenefitType.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTPatreonBenefitType.m; sourceTree = "<group>"; }; BF66EE902501AEBC007EE018 /* ALTPatreonBenefitID.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTPatreonBenefitID.m; sourceTree = "<group>"; };
BF66EE912501AEBC007EE018 /* ALTAppPermissions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTAppPermissions.m; sourceTree = "<group>"; }; BF66EE912501AEBC007EE018 /* ALTAppPermissions.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTAppPermissions.m; sourceTree = "<group>"; };
BF66EE922501AEBC007EE018 /* ALTPatreonBenefitType.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTPatreonBenefitType.h; sourceTree = "<group>"; }; BF66EE922501AEBC007EE018 /* ALTPatreonBenefitID.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ALTPatreonBenefitID.h; sourceTree = "<group>"; };
BF66EE932501AEBC007EE018 /* ALTSourceUserInfoKey.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTSourceUserInfoKey.m; sourceTree = "<group>"; }; BF66EE932501AEBC007EE018 /* ALTSourceUserInfoKey.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ALTSourceUserInfoKey.m; sourceTree = "<group>"; };
BF66EE9B2501AEC1007EE018 /* AppProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppProtocol.swift; sourceTree = "<group>"; }; BF66EE9B2501AEC1007EE018 /* AppProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppProtocol.swift; sourceTree = "<group>"; };
BF66EE9C2501AEC1007EE018 /* Fetchable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Fetchable.swift; sourceTree = "<group>"; }; BF66EE9C2501AEC1007EE018 /* Fetchable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Fetchable.swift; sourceTree = "<group>"; };
@@ -970,6 +978,7 @@
D51AD27C29356B7B00967AAA /* ALTWrappedError.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ALTWrappedError.h; sourceTree = "<group>"; }; D51AD27C29356B7B00967AAA /* ALTWrappedError.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ALTWrappedError.h; sourceTree = "<group>"; };
D51AD27D29356B7B00967AAA /* ALTWrappedError.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ALTWrappedError.m; sourceTree = "<group>"; }; D51AD27D29356B7B00967AAA /* ALTWrappedError.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ALTWrappedError.m; sourceTree = "<group>"; };
D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+AltStore.swift"; sourceTree = "<group>"; }; D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Logger+AltStore.swift"; sourceTree = "<group>"; };
D52B4ABE2AF183F0005991C3 /* WebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewController.swift; sourceTree = "<group>"; };
D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = "<group>"; }; D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = "<group>"; };
D52E988928D002D30032BE6B /* AltStore 11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 11.xcdatamodel"; sourceTree = "<group>"; }; D52E988928D002D30032BE6B /* AltStore 11.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 11.xcdatamodel"; sourceTree = "<group>"; };
D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailCollectionViewController.swift; sourceTree = "<group>"; }; D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDetailCollectionViewController.swift; sourceTree = "<group>"; };
@@ -986,8 +995,12 @@
D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshotCollectionViewCell.swift; sourceTree = "<group>"; }; D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshotCollectionViewCell.swift; sourceTree = "<group>"; };
D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorLogTableViewCell.swift; sourceTree = "<group>"; }; D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorLogTableViewCell.swift; sourceTree = "<group>"; };
D552B1D72A042A740066216F /* AppPermissionsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionsCard.swift; sourceTree = "<group>"; }; D552B1D72A042A740066216F /* AppPermissionsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionsCard.swift; sourceTree = "<group>"; };
D552EB052AF453F900A3AB4D /* URL+Normalized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Normalized.swift"; sourceTree = "<group>"; };
D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppShortcuts.swift; sourceTree = "<group>"; }; D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppShortcuts.swift; sourceTree = "<group>"; };
D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveAppsWidget.swift; sourceTree = "<group>"; }; D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveAppsWidget.swift; sourceTree = "<group>"; };
D557A4802AE85BB0007D0DCF /* Pledge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pledge.swift; sourceTree = "<group>"; };
D557A4822AE85DB7007D0DCF /* PledgeReward.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PledgeReward.swift; sourceTree = "<group>"; };
D557A4842AE88227007D0DCF /* PledgeTier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PledgeTier.swift; sourceTree = "<group>"; };
D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Regex+Permissions.swift"; sourceTree = "<group>"; }; D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Regex+Permissions.swift"; sourceTree = "<group>"; };
D56915082AD5F3E800A2B747 /* AltTests+Sources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AltTests+Sources.swift"; sourceTree = "<group>"; }; D56915082AD5F3E800A2B747 /* AltTests+Sources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AltTests+Sources.swift"; sourceTree = "<group>"; };
D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Comparable.swift"; sourceTree = "<group>"; }; D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Comparable.swift"; sourceTree = "<group>"; };
@@ -1028,6 +1041,9 @@
D5A1D2E82AA512940066CACC /* RemoteServiceDiscoveryTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteServiceDiscoveryTunnel.swift; sourceTree = "<group>"; }; D5A1D2E82AA512940066CACC /* RemoteServiceDiscoveryTunnel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteServiceDiscoveryTunnel.swift; sourceTree = "<group>"; };
D5A1D2EA2AA513410066CACC /* URL+Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Tools.swift"; sourceTree = "<group>"; }; D5A1D2EA2AA513410066CACC /* URL+Tools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Tools.swift"; sourceTree = "<group>"; };
D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeprecatedAPIs.swift; sourceTree = "<group>"; }; D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeprecatedAPIs.swift; sourceTree = "<group>"; };
D5A645202AF591980047D980 /* UTType+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UTType+AltStore.swift"; sourceTree = "<group>"; };
D5A645222AF5B5C50047D980 /* PatreonAPI+Responses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PatreonAPI+Responses.swift"; sourceTree = "<group>"; };
D5A645242AF5BC7F0047D980 /* UserAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAccount.swift; sourceTree = "<group>"; };
D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearAppCacheOperation.swift; sourceTree = "<group>"; }; D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearAppCacheOperation.swift; sourceTree = "<group>"; };
D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+Previews.swift"; sourceTree = "<group>"; }; D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+Previews.swift"; sourceTree = "<group>"; };
D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewAppScreenshotsViewController.swift; sourceTree = "<group>"; }; D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewAppScreenshotsViewController.swift; sourceTree = "<group>"; };
@@ -1484,6 +1500,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
BF66EE8B2501AEB1007EE018 /* Keychain.swift */, BF66EE8B2501AEB1007EE018 /* Keychain.swift */,
D52B4ABE2AF183F0005991C3 /* WebViewController.swift */,
); );
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -1496,8 +1513,8 @@
D5893F812A141E4900E767CD /* KnownSource.swift */, D5893F812A141E4900E767CD /* KnownSource.swift */,
BF66EE8E2501AEBC007EE018 /* ALTAppPermissions.h */, BF66EE8E2501AEBC007EE018 /* ALTAppPermissions.h */,
BF66EE912501AEBC007EE018 /* ALTAppPermissions.m */, BF66EE912501AEBC007EE018 /* ALTAppPermissions.m */,
BF66EE922501AEBC007EE018 /* ALTPatreonBenefitType.h */, BF66EE922501AEBC007EE018 /* ALTPatreonBenefitID.h */,
BF66EE902501AEBC007EE018 /* ALTPatreonBenefitType.m */, BF66EE902501AEBC007EE018 /* ALTPatreonBenefitID.m */,
BF66EE8F2501AEBC007EE018 /* ALTSourceUserInfoKey.h */, BF66EE8F2501AEBC007EE018 /* ALTSourceUserInfoKey.h */,
BF66EE932501AEBC007EE018 /* ALTSourceUserInfoKey.m */, BF66EE932501AEBC007EE018 /* ALTSourceUserInfoKey.m */,
); );
@@ -1518,11 +1535,13 @@
BF66EE9F2501AEC5007EE018 /* Patreon */ = { BF66EE9F2501AEC5007EE018 /* Patreon */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
BF66EEA12501AEC5007EE018 /* PatreonAPI.swift */,
D5A645222AF5B5C50047D980 /* PatreonAPI+Responses.swift */,
BF66EEA02501AEC5007EE018 /* Benefit.swift */, BF66EEA02501AEC5007EE018 /* Benefit.swift */,
BF66EEA22501AEC5007EE018 /* Campaign.swift */, BF66EEA22501AEC5007EE018 /* Campaign.swift */,
BF66EEA12501AEC5007EE018 /* PatreonAPI.swift */,
BF66EEA32501AEC5007EE018 /* Patron.swift */, BF66EEA32501AEC5007EE018 /* Patron.swift */,
BF66EEA42501AEC5007EE018 /* Tier.swift */, BF66EEA42501AEC5007EE018 /* Tier.swift */,
D5A645242AF5BC7F0047D980 /* UserAccount.swift */,
); );
path = Patreon; path = Patreon;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -1543,13 +1562,13 @@
D58916FD28C7C55C00E39C8B /* LoggedError.swift */, D58916FD28C7C55C00E39C8B /* LoggedError.swift */,
BF66EEC52501AECA007EE018 /* MergePolicy.swift */, BF66EEC52501AECA007EE018 /* MergePolicy.swift */,
BF66EEBF2501AECA007EE018 /* NewsItem.swift */, BF66EEBF2501AECA007EE018 /* NewsItem.swift */,
BF66EEC82501AECA007EE018 /* PatreonAccount.swift */,
D5CA0C4A280E141900469595 /* ManagedPatron.swift */, D5CA0C4A280E141900469595 /* ManagedPatron.swift */,
BF66EEC32501AECA007EE018 /* RefreshAttempt.swift */, BF66EEC32501AECA007EE018 /* RefreshAttempt.swift */,
BF66EEC12501AECA007EE018 /* SecureValueTransformer.swift */, BF66EEC12501AECA007EE018 /* SecureValueTransformer.swift */,
BF66EEAB2501AECA007EE018 /* Source.swift */, BF66EEAB2501AECA007EE018 /* Source.swift */,
BF66EEC42501AECA007EE018 /* StoreApp.swift */, BF66EEC42501AECA007EE018 /* StoreApp.swift */,
BF66EEC22501AECA007EE018 /* Team.swift */, BF66EEC22501AECA007EE018 /* Team.swift */,
D557A4862AE88232007D0DCF /* Patreon */,
BF66EEAC2501AECA007EE018 /* Migrations */, BF66EEAC2501AECA007EE018 /* Migrations */,
); );
path = Model; path = Model;
@@ -1609,6 +1628,7 @@
D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */, D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */,
D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */, D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */,
D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */, D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */,
D552EB052AF453F900A3AB4D /* URL+Normalized.swift */,
); );
path = Extensions; path = Extensions;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -1923,6 +1943,7 @@
D5927D6529DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift */, D5927D6529DCC89000D6898E /* UINavigationBarAppearance+TintColor.swift */,
D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */, D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */,
D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */, D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */,
D5A645202AF591980047D980 /* UTType+AltStore.swift */,
); );
path = Extensions; path = Extensions;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -2168,6 +2189,17 @@
path = "App Intents"; path = "App Intents";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D557A4862AE88232007D0DCF /* Patreon */ = {
isa = PBXGroup;
children = (
BF66EEC82501AECA007EE018 /* PatreonAccount.swift */,
D557A4802AE85BB0007D0DCF /* Pledge.swift */,
D557A4842AE88227007D0DCF /* PledgeTier.swift */,
D557A4822AE85DB7007D0DCF /* PledgeReward.swift */,
);
path = Patreon;
sourceTree = "<group>";
};
D55FEC9C2A8FEC600057D6E6 /* Legacy */ = { D55FEC9C2A8FEC600057D6E6 /* Legacy */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -2328,7 +2360,7 @@
files = ( files = (
BF66EE822501AE50007EE018 /* AltStoreCore.h in Headers */, BF66EE822501AE50007EE018 /* AltStoreCore.h in Headers */,
BF66EE952501AEBC007EE018 /* ALTSourceUserInfoKey.h in Headers */, BF66EE952501AEBC007EE018 /* ALTSourceUserInfoKey.h in Headers */,
BF66EE982501AEBC007EE018 /* ALTPatreonBenefitType.h in Headers */, BF66EE982501AEBC007EE018 /* ALTPatreonBenefitID.h in Headers */,
BFAECC5F2501B0BF00528F27 /* ALTConstants.h in Headers */, BFAECC5F2501B0BF00528F27 /* ALTConstants.h in Headers */,
BFAECC5D2501B0BF00528F27 /* ALTConnection.h in Headers */, BFAECC5D2501B0BF00528F27 /* ALTConnection.h in Headers */,
BF66EE942501AEBC007EE018 /* ALTAppPermissions.h in Headers */, BF66EE942501AEBC007EE018 /* ALTAppPermissions.h in Headers */,
@@ -3036,12 +3068,15 @@
D5FB28EE2ADDF89800A1C337 /* KnownSource.swift in Sources */, D5FB28EE2ADDF89800A1C337 /* KnownSource.swift in Sources */,
BF66EED32501AECA007EE018 /* AltStore2ToAltStore3.xcmappingmodel in Sources */, BF66EED32501AECA007EE018 /* AltStore2ToAltStore3.xcmappingmodel in Sources */,
BF66EEA52501AEC5007EE018 /* Benefit.swift in Sources */, BF66EEA52501AEC5007EE018 /* Benefit.swift in Sources */,
D52B4ABF2AF183F0005991C3 /* WebViewController.swift in Sources */,
BF66EED22501AECA007EE018 /* AltStore4ToAltStore5.xcmappingmodel in Sources */, BF66EED22501AECA007EE018 /* AltStore4ToAltStore5.xcmappingmodel in Sources */,
D5A645252AF5BC7F0047D980 /* UserAccount.swift in Sources */,
D5189C022A01BC6800F44625 /* UserInfoValue.swift in Sources */, D5189C022A01BC6800F44625 /* UserInfoValue.swift in Sources */,
BFAECC5B2501B0A400528F27 /* Bundle+AltStore.swift in Sources */, BFAECC5B2501B0A400528F27 /* Bundle+AltStore.swift in Sources */,
BF66EECD2501AECA007EE018 /* StoreAppPolicy.swift in Sources */, BF66EECD2501AECA007EE018 /* StoreAppPolicy.swift in Sources */,
BF66EEE82501AED0007EE018 /* UserDefaults+AltStore.swift in Sources */, BF66EEE82501AED0007EE018 /* UserDefaults+AltStore.swift in Sources */,
BF340E9A250AD39500A192CB /* ViewApp.intentdefinition in Sources */, BF340E9A250AD39500A192CB /* ViewApp.intentdefinition in Sources */,
D552EB062AF453F900A3AB4D /* URL+Normalized.swift in Sources */,
BFAECC522501B0A400528F27 /* CodableError.swift in Sources */, BFAECC522501B0A400528F27 /* CodableError.swift in Sources */,
D5F9821D2AB900060045751F /* AppScreenshot.swift in Sources */, D5F9821D2AB900060045751F /* AppScreenshot.swift in Sources */,
BF66EE9E2501AEC1007EE018 /* Fetchable.swift in Sources */, BF66EE9E2501AEC1007EE018 /* Fetchable.swift in Sources */,
@@ -3068,11 +3103,12 @@
D58916FE28C7C55C00E39C8B /* LoggedError.swift in Sources */, D58916FE28C7C55C00E39C8B /* LoggedError.swift in Sources */,
BFBF331B2526762200B7B8C9 /* AltStore8ToAltStore9.xcmappingmodel in Sources */, BFBF331B2526762200B7B8C9 /* AltStore8ToAltStore9.xcmappingmodel in Sources */,
D5FD4ECB2A9532960097BEE8 /* DatabaseManager+Async.swift in Sources */, D5FD4ECB2A9532960097BEE8 /* DatabaseManager+Async.swift in Sources */,
D557A4832AE85DB7007D0DCF /* PledgeReward.swift in Sources */,
D5893F802A1419E800E767CD /* NSManagedObjectContext+Conveniences.swift in Sources */, D5893F802A1419E800E767CD /* NSManagedObjectContext+Conveniences.swift in Sources */,
D5CA0C4E280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel in Sources */, D5CA0C4E280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel in Sources */,
D51AD27F29356B7B00967AAA /* ALTWrappedError.m in Sources */, D51AD27F29356B7B00967AAA /* ALTWrappedError.m in Sources */,
BF989184250AACFC002ACF50 /* Date+RelativeDate.swift in Sources */, BF989184250AACFC002ACF50 /* Date+RelativeDate.swift in Sources */,
BF66EE962501AEBC007EE018 /* ALTPatreonBenefitType.m in Sources */, BF66EE962501AEBC007EE018 /* ALTPatreonBenefitID.m in Sources */,
BFAECC5A2501B0A400528F27 /* NetworkConnection.swift in Sources */, BFAECC5A2501B0A400528F27 /* NetworkConnection.swift in Sources */,
D5F99A1828D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel in Sources */, D5F99A1828D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel in Sources */,
BF66EEE92501AED0007EE018 /* JSONDecoder+Properties.swift in Sources */, BF66EEE92501AED0007EE018 /* JSONDecoder+Properties.swift in Sources */,
@@ -3104,6 +3140,7 @@
BF66EECC2501AECA007EE018 /* Source.swift in Sources */, BF66EECC2501AECA007EE018 /* Source.swift in Sources */,
BF66EED72501AECA007EE018 /* InstalledApp.swift in Sources */, BF66EED72501AECA007EE018 /* InstalledApp.swift in Sources */,
D5DB145A28F9DC5A00A8F606 /* ALTLocalizedError.swift in Sources */, D5DB145A28F9DC5A00A8F606 /* ALTLocalizedError.swift in Sources */,
D5A645232AF5B5C50047D980 /* PatreonAPI+Responses.swift in Sources */,
D5185B822AE1E71D00646E33 /* Source13To14MigrationPolicy.swift in Sources */, D5185B822AE1E71D00646E33 /* Source13To14MigrationPolicy.swift in Sources */,
BF66EECE2501AECA007EE018 /* InstalledAppPolicy.swift in Sources */, BF66EECE2501AECA007EE018 /* InstalledAppPolicy.swift in Sources */,
BF1FE359251A9FB000C3CE09 /* NSXPCConnection+MachServices.swift in Sources */, BF1FE359251A9FB000C3CE09 /* NSXPCConnection+MachServices.swift in Sources */,
@@ -3115,6 +3152,8 @@
BF66EED62501AECA007EE018 /* NewsItem.swift in Sources */, BF66EED62501AECA007EE018 /* NewsItem.swift in Sources */,
BF66EEA72501AEC5007EE018 /* Campaign.swift in Sources */, BF66EEA72501AEC5007EE018 /* Campaign.swift in Sources */,
BF66EE992501AEBC007EE018 /* ALTSourceUserInfoKey.m in Sources */, BF66EE992501AEBC007EE018 /* ALTSourceUserInfoKey.m in Sources */,
D557A4812AE85BB0007D0DCF /* Pledge.swift in Sources */,
D557A4852AE88227007D0DCF /* PledgeTier.swift in Sources */,
BF989185250AAD1D002ACF50 /* UIColor+AltStore.swift in Sources */, BF989185250AAD1D002ACF50 /* UIColor+AltStore.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -3244,6 +3283,7 @@
BFB3645A2325985F00CD0EB1 /* FindServerOperation.swift in Sources */, BFB3645A2325985F00CD0EB1 /* FindServerOperation.swift in Sources */,
D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */, D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */,
BF3BEFBF2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift in Sources */, BF3BEFBF2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift in Sources */,
D5A645212AF591980047D980 /* UTType+AltStore.swift in Sources */,
D5F982212AB910180045751F /* AppScreenshotsViewController.swift in Sources */, D5F982212AB910180045751F /* AppScreenshotsViewController.swift in Sources */,
BFF0B69023219C6D007A79E1 /* PatreonComponents.swift in Sources */, BFF0B69023219C6D007A79E1 /* PatreonComponents.swift in Sources */,
BFBE0004250ACFFB0080826E /* ViewApp.intentdefinition in Sources */, BFBE0004250ACFFB0080826E /* ViewApp.intentdefinition in Sources */,

View File

@@ -87,8 +87,6 @@ class AppViewController: UIViewController
self.bannerView.iconImageView.tintColor = self.app.tintColor self.bannerView.iconImageView.tintColor = self.app.tintColor
self.bannerView.button.tintColor = self.app.tintColor self.bannerView.button.tintColor = self.app.tintColor
self.bannerView.tintColor = self.app.tintColor self.bannerView.tintColor = self.app.tintColor
self.bannerView.configure(for: self.app)
self.bannerView.accessibilityTraits.remove(.button) self.bannerView.accessibilityTraits.remove(.button)
self.bannerView.button.addTarget(self, action: #selector(AppViewController.performAppAction(_:)), for: .primaryActionTriggered) self.bannerView.button.addTarget(self, action: #selector(AppViewController.performAppAction(_:)), for: .primaryActionTriggered)
@@ -362,41 +360,26 @@ private extension AppViewController
{ {
func update() 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!] for button in [self.bannerView.button!, self.navigationBarDownloadButton!]
{ {
button.tintColor = self.app.tintColor button.tintColor = self.app.tintColor
button.isIndicatingActivity = false 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.configure(for: self.app, action: buttonAction)
{
self.bannerView.button.countdownDate = versionDate let title = self.bannerView.button.title(for: .normal)
self.navigationBarDownloadButton.countdownDate = versionDate self.navigationBarDownloadButton.setTitle(title, for: .normal)
} self.navigationBarDownloadButton.progress = self.bannerView.button.progress
else self.navigationBarDownloadButton.countdownDate = self.bannerView.button.countdownDate
{
self.bannerView.button.countdownDate = nil
self.navigationBarDownloadButton.countdownDate = nil
}
let barButtonItem = self.navigationItem.rightBarButtonItem let barButtonItem = self.navigationItem.rightBarButtonItem
self.navigationItem.rightBarButtonItem = nil self.navigationItem.rightBarButtonItem = nil
@@ -523,9 +506,9 @@ extension AppViewController
{ {
if let installedApp = self.app.installedApp 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 else
{ {
@@ -576,7 +559,7 @@ extension AppViewController
UIApplication.shared.open(installedApp.openAppURL) UIApplication.shared.open(installedApp.openAppURL)
} }
func updateApp(_ installedApp: InstalledApp) func updateApp(_ installedApp: InstalledApp, to version: AppVersion)
{ {
let previousProgress = AppManager.shared.installationProgress(for: installedApp) let previousProgress = AppManager.shared.installationProgress(for: installedApp)
guard previousProgress == nil else { guard previousProgress == nil else {
@@ -585,7 +568,7 @@ extension AppViewController
return return
} }
_ = AppManager.shared.update(installedApp, presentingViewController: self) { (result) in AppManager.shared.update(installedApp, to: version, presentingViewController: self) { (result) in
DispatchQueue.main.async { DispatchQueue.main.async {
switch result switch result
{ {

View File

@@ -92,7 +92,6 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing
super.viewWillAppear(animated) super.viewWillAppear(animated)
self.fetchSource() self.fetchSource()
self.updateDataSource()
self.update() self.update()
} }
@@ -109,7 +108,8 @@ private extension BrowseViewController
NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)] NSSortDescriptor(keyPath: \StoreApp.bundleIdentifier, ascending: true)]
fetchRequest.returnsObjectsAsFaults = false fetchRequest.returnsObjectsAsFaults = false
let predicate = NSPredicate(format: "%K != %@", #keyPath(StoreApp.bundleIdentifier), StoreApp.altstoreAppID) let predicate = StoreApp.visibleAppsPredicate
if let source = self.source if let source = self.source
{ {
let filterPredicate = NSPredicate(format: "%K == %@", #keyPath(StoreApp._source), 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.style = .medium
cell.bannerView.button.activityIndicatorView.color = .white 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 let tintColor = app.tintColor ?? .altPrimary
cell.tintColor = tintColor 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 dataSource.prefetchHandler = { (storeApp, indexPath, completionHandler) -> Foundation.Operation? in
let iconURL = storeApp.iconURL let iconURL = storeApp.iconURL
@@ -202,18 +170,6 @@ private extension BrowseViewController
return dataSource 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() func fetchSource()
{ {
self.loadingState = .loading self.loadingState = .loading
@@ -317,7 +273,7 @@ private extension BrowseViewController
let app = self.dataSource.item(at: indexPath) let app = self.dataSource.item(at: indexPath)
if let installedApp = app.installedApp if let installedApp = app.installedApp, !installedApp.isUpdateAvailable
{ {
self.open(installedApp) self.open(installedApp)
} }
@@ -335,7 +291,21 @@ private extension BrowseViewController
return 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<InstalledApp, Error>)
{
DispatchQueue.main.async { DispatchQueue.main.async {
switch result switch result
{ {
@@ -344,15 +314,22 @@ private extension BrowseViewController
let toastView = ToastView(error: error) let toastView = ToastView(error: error)
toastView.opensErrorLog = true toastView.opensErrorLog = true
toastView.show(in: self) toastView.show(in: self)
case .success: print("Installed app:", app.bundleIdentifier) 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) func open(_ installedApp: InstalledApp)

View File

@@ -18,6 +18,14 @@ extension AppBannerView
case app case app
case source case source
} }
enum AppAction
{
case install
case open
case update
case custom(String)
}
} }
class AppBannerView: RSTNibView class AppBannerView: RSTNibView
@@ -111,7 +119,7 @@ class AppBannerView: RSTNibView
extension AppBannerView extension AppBannerView
{ {
func configure(for app: AppProtocol) func configure(for app: AppProtocol, action: AppAction? = nil)
{ {
struct AppValues struct AppValues
{ {
@@ -150,6 +158,136 @@ extension AppBannerView
self.subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "") self.subtitleLabel.text = NSLocalizedString("Sideloaded", comment: "")
self.accessibilityLabel = values.name 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) func configure(for source: Source)

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="22155" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/> <device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22130"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22131"/>
<capability name="Named colors" minToolsVersion="9.0"/> <capability name="Named colors" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/> <capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@@ -78,13 +78,13 @@
</subviews> </subviews>
</stackView> </stackView>
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fC4-1V-iMn"> <visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fC4-1V-iMn">
<rect key="frame" x="0.0" y="21.5" width="184" height="16"/> <rect key="frame" x="0.0" y="21.5" width="62" height="16"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="LQh-pN-ePC"> <view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="LQh-pN-ePC">
<rect key="frame" x="0.0" y="0.0" width="184" height="16"/> <rect key="frame" x="0.0" y="0.0" width="62" height="16"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="100" verticalHuggingPriority="750" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="oN5-vu-Dnw"> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="750" verticalHuggingPriority="750" text="Developer" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="oN5-vu-Dnw">
<rect key="frame" x="0.0" y="0.0" width="184" height="16"/> <rect key="frame" x="0.0" y="0.0" width="62" height="16"/>
<fontDescription key="fontDescription" type="system" pointSize="13"/> <fontDescription key="fontDescription" type="system" pointSize="13"/>
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
@@ -103,36 +103,33 @@
</visualEffectView> </visualEffectView>
</subviews> </subviews>
</stackView> </stackView>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="4" translatesAutoresizingMaskIntoConstraints="NO" id="3ox-Q2-Rnd" userLabel="Button Stack View"> <button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="900" horizontalCompressionResistancePriority="900" placeholderIntrinsicWidth="77" placeholderIntrinsicHeight="31" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tVx-3G-dcu" customClass="PillButton" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="282" y="28.5" width="77" height="31"/> <rect key="frame" x="282" y="28.5" width="77" height="31"/>
<subviews> <color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yd9-jw-faD"> <constraints>
<rect key="frame" x="0.0" y="0.0" width="77" height="0.0"/> <constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="tVx-3G-dcu" secondAttribute="height" priority="999" id="Vbk-VH-5eU"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="10"/> <constraint firstAttribute="height" constant="31" id="Zwh-yQ-GTu"/>
<color key="textColor" systemColor="secondaryLabelColor"/> </constraints>
<nil key="highlightedColor"/> <fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
</label> <state key="normal" title="FREE"/>
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="900" horizontalCompressionResistancePriority="900" placeholderIntrinsicWidth="77" placeholderIntrinsicHeight="31" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="tVx-3G-dcu" customClass="PillButton" customModule="AltStore" customModuleProvider="target"> </button>
<rect key="frame" x="0.0" y="0.0" width="77" height="31"/>
<color key="backgroundColor" red="0.22352941179999999" green="0.4941176471" blue="0.39607843139999999" alpha="0.10000000000000001" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstAttribute="width" relation="greaterThanOrEqual" secondItem="tVx-3G-dcu" secondAttribute="height" priority="999" id="Vbk-VH-5eU"/>
<constraint firstAttribute="height" constant="31" id="Zwh-yQ-GTu"/>
</constraints>
<fontDescription key="fontDescription" type="boldSystem" pointSize="14"/>
<state key="normal" title="FREE"/>
</button>
</subviews>
</stackView>
</subviews> </subviews>
<edgeInsets key="layoutMargins" top="14" left="14" bottom="14" right="12"/> <edgeInsets key="layoutMargins" top="14" left="14" bottom="14" right="12"/>
</stackView> </stackView>
<label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Yd9-jw-faD">
<rect key="frame" x="307" y="12.5" width="27" height="12"/>
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="10"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
<nil key="highlightedColor"/>
</label>
</subviews> </subviews>
<constraints> <constraints>
<constraint firstItem="d1T-UD-gWG" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="5tv-QN-ZWU"/> <constraint firstItem="d1T-UD-gWG" firstAttribute="leading" secondItem="FxI-Fh-ll5" secondAttribute="leading" id="5tv-QN-ZWU"/>
<constraint firstAttribute="bottom" secondItem="bJL-Yw-i4u" secondAttribute="bottom" id="FRq-ZD-2rE"/> <constraint firstAttribute="bottom" secondItem="bJL-Yw-i4u" secondAttribute="bottom" id="FRq-ZD-2rE"/>
<constraint firstItem="rZk-be-tiI" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="N6R-B2-Rie"/> <constraint firstItem="rZk-be-tiI" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="N6R-B2-Rie"/>
<constraint firstItem="Yd9-jw-faD" firstAttribute="centerX" secondItem="tVx-3G-dcu" secondAttribute="centerX" id="acx-pf-8hH"/>
<constraint firstItem="bJL-Yw-i4u" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="h6T-q1-YV9"/> <constraint firstItem="bJL-Yw-i4u" firstAttribute="top" secondItem="FxI-Fh-ll5" secondAttribute="top" id="h6T-q1-YV9"/>
<constraint firstItem="tVx-3G-dcu" firstAttribute="top" secondItem="Yd9-jw-faD" secondAttribute="bottom" constant="4" id="hTD-wh-KV8"/>
<constraint firstAttribute="trailing" secondItem="d1T-UD-gWG" secondAttribute="trailing" id="mlG-w3-Ly6"/> <constraint firstAttribute="trailing" secondItem="d1T-UD-gWG" secondAttribute="trailing" id="mlG-w3-Ly6"/>
<constraint firstAttribute="trailing" secondItem="rZk-be-tiI" secondAttribute="trailing" id="nEy-pm-Fcs"/> <constraint firstAttribute="trailing" secondItem="rZk-be-tiI" secondAttribute="trailing" id="nEy-pm-Fcs"/>
<constraint firstAttribute="bottom" secondItem="d1T-UD-gWG" secondAttribute="bottom" priority="999" id="nJo-To-LmX"/> <constraint firstAttribute="bottom" secondItem="d1T-UD-gWG" secondAttribute="bottom" priority="999" id="nJo-To-LmX"/>

View File

@@ -289,6 +289,10 @@ extension AppCardCollectionViewCell
{ {
self.screenshots = storeApp.preferredScreenshots() 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.tintColor = storeApp.tintColor
self.bannerView.configure(for: storeApp) self.bannerView.configure(for: storeApp)

View File

@@ -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")
}

View File

@@ -181,6 +181,8 @@
<dict> <dict>
<key>public.filename-extension</key> <key>public.filename-extension</key>
<string>ipa</string> <string>ipa</string>
<key>public.mime-type</key>
<string>application/x-ios-app</string>
</dict> </dict>
</dict> </dict>
</array> </array>

View File

@@ -557,9 +557,9 @@ extension AppManager
} }
@discardableResult @discardableResult
func update(_ installedApp: InstalledApp, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress func update(_ installedApp: InstalledApp, to version: AppVersion? = nil, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> Progress
{ {
guard let appVersion = installedApp.storeApp?.latestSupportedVersion else { guard let appVersion = version ?? installedApp.storeApp?.latestSupportedVersion else {
completionHandler(.failure(OperationError.appNotFound(name: installedApp.name))) completionHandler(.failure(OperationError.appNotFound(name: installedApp.name)))
return Progress.discreteProgress(totalUnitCount: 1) return Progress.discreteProgress(totalUnitCount: 1)
} }
@@ -1246,7 +1246,7 @@ private extension AppManager
let patchAppURL = URL(string: patchAppLink) let patchAppURL = URL(string: patchAppLink)
else { throw OperationError.invalidApp } 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 { DispatchQueue.main.async {
let storyboard = UIStoryboard(name: "PatchApp", bundle: nil) let storyboard = UIStoryboard(name: "PatchApp", bundle: nil)
@@ -1358,8 +1358,22 @@ private extension AppManager
progress.addChild(installOperation.progress, withPendingUnitCount: 30) progress.addChild(installOperation.progress, withPendingUnitCount: 30)
installOperation.addDependency(sendAppOperation) 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) 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) self.run(operations, context: group.context)
return progress return progress

View File

@@ -112,11 +112,13 @@ class MyAppsViewController: UICollectionViewController, PeekPopPreviewing
(self as PeekPopPreviewing).registerForPreviewing(with: self, sourceView: self.collectionView) (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.update()
self.fetchAppIDs() self.fetchAppIDs()
@@ -227,7 +229,8 @@ private extension MyAppsViewController
cell.bannerView.iconImageView.image = nil cell.bannerView.iconImageView.image = nil
cell.bannerView.iconImageView.isIndicatingActivity = true 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) let versionDate = Date().relativeDateString(since: latestSupportedVersion.date)
cell.bannerView.subtitleLabel.text = versionDate 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.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.addTarget(self, action: #selector(MyAppsViewController.updateApp(_:)), for: .primaryActionTriggered)
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Update %@", comment: ""), installedApp.name) 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) 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() cell.setNeedsLayout()
} }
dataSource.prefetchHandler = { (installedApp, indexPath, completionHandler) in 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.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 currentDate = Date()
let numberOfDays = installedApp.expirationDate.numberOfCalendarDays(since: currentDate) let numberOfDays = installedApp.expirationDate.numberOfCalendarDays(since: currentDate)
@@ -355,9 +343,30 @@ private extension MyAppsViewController
numberOfDaysText = String(format: NSLocalizedString("%@ days", comment: ""), NSNumber(value: numberOfDays)) 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) 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) cell.bannerView.accessibilityLabel? += ". " + String(format: NSLocalizedString("Expires in %@", comment: ""), numberOfDaysText)
// Make sure refresh button is correct size. // Make sure refresh button is correct size.
@@ -430,15 +439,25 @@ private extension MyAppsViewController
cell.deactivateBadge?.alpha = 0.0 cell.deactivateBadge?.alpha = 0.0
cell.deactivateBadge?.transform = CGAffineTransform.identity.scaledBy(x: 0.5, y: 0.5) cell.deactivateBadge?.transform = CGAffineTransform.identity.scaledBy(x: 0.5, y: 0.5)
cell.bannerView.configure(for: installedApp)
cell.bannerView.button.isIndicatingActivity = false cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.configure(for: installedApp, action: .custom(NSLocalizedString("ACTIVATE", comment: "")))
cell.bannerView.button.tintColor = tintColor 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.removeTarget(self, action: nil, for: .primaryActionTriggered)
cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.activateApp(_:)), for: .primaryActionTriggered) cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.activateApp(_:)), for: .primaryActionTriggered)
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Activate %@", comment: ""), installedApp.name) 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. // Make sure refresh button is correct size.
cell.layoutIfNeeded() cell.layoutIfNeeded()
@@ -475,33 +494,6 @@ private extension MyAppsViewController
return dataSource 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 private extension MyAppsViewController
@@ -710,10 +702,29 @@ private extension MyAppsViewController
@IBAction func refreshAllApps(_ sender: UIBarButtonItem) @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.isRefreshingAllApps = true
self.collectionView.collectionViewLayout.invalidateLayout() self.collectionView.collectionViewLayout.invalidateLayout()
let installedApps = InstalledApp.fetchAppsForRefreshingAll(in: DatabaseManager.shared.viewContext)
self.refresh(installedApps) { (result) in self.refresh(installedApps) { (result) in
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -1704,7 +1715,7 @@ extension MyAppsViewController
extension MyAppsViewController extension MyAppsViewController
{ {
private func actions(for installedApp: InstalledApp) -> [UIMenuElement] private func contextMenu(for installedApp: InstalledApp) -> UIMenu
{ {
var actions = [UIMenuElement]() var actions = [UIMenuElement]()
@@ -1762,103 +1773,140 @@ extension MyAppsViewController
let changeIconMenu = UIMenu(title: NSLocalizedString("Change Icon", comment: ""), image: UIImage(systemName: "photo"), children: changeIconActions) let changeIconMenu = UIMenu(title: NSLocalizedString("Change Icon", comment: ""), image: UIImage(systemName: "photo"), children: changeIconActions)
guard installedApp.bundleIdentifier != StoreApp.altstoreAppID else { if installedApp.bundleIdentifier == StoreApp.altstoreAppID
#if BETA
return [refreshAction, changeIconMenu]
#else
return [refreshAction]
#endif
}
if installedApp.isActive
{ {
actions.append(openMenu) #if BETA
actions.append(refreshAction) actions = [refreshAction, changeIconMenu]
#else
actions = [refreshAction]
#endif
} }
else else
{ {
actions.append(activateAction) if installedApp.isActive
} {
actions.append(openMenu)
if installedApp.isActive actions.append(refreshAction)
{ }
actions.append(jitAction) else
} {
actions.append(activateAction)
#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 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<UIMenuElement> = [
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 let menu = UIMenu(title: title ?? "", children: actions)
{ return menu
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
} }
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration?
@@ -1871,9 +1919,7 @@ extension MyAppsViewController
let installedApp = self.dataSource.item(at: indexPath) let installedApp = self.dataSource.item(at: indexPath)
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: nil) { (suggestedActions) -> UIMenu? in return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: nil) { (suggestedActions) -> UIMenu? in
let actions = self.actions(for: installedApp) let menu = self.contextMenu(for: installedApp)
let menu = UIMenu(title: "", children: actions)
return menu return menu
} }
} }

View File

@@ -42,8 +42,7 @@ extension UpdateCollectionViewCell
self.contentView.preservesSuperviewLayoutMargins = true self.contentView.preservesSuperviewLayoutMargins = true
self.bannerView.backgroundEffectView.isHidden = true self.bannerView.backgroundEffectView.isHidden = true
self.bannerView.button.setTitle(NSLocalizedString("UPDATE", comment: ""), for: .normal)
self.blurView.layer.cornerRadius = 20 self.blurView.layer.cornerRadius = 20
self.blurView.layer.masksToBounds = true self.blurView.layer.masksToBounds = true

View File

@@ -341,7 +341,7 @@ private extension NewsViewController
let app = self.dataSource.item(at: indexPath) let app = self.dataSource.item(at: indexPath)
guard let storeApp = app.storeApp else { return } guard let storeApp = app.storeApp else { return }
if let installedApp = app.storeApp?.installedApp if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
{ {
self.open(installedApp) self.open(installedApp)
} }
@@ -359,7 +359,21 @@ private extension NewsViewController
return 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<InstalledApp, Error>)
{
DispatchQueue.main.async { DispatchQueue.main.async {
switch result switch result
{ {
@@ -377,10 +391,6 @@ private extension NewsViewController
} }
} }
} }
UIView.performWithoutAnimation {
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
}
} }
func open(_ installedApp: InstalledApp) func open(_ installedApp: InstalledApp)
@@ -426,42 +436,13 @@ extension NewsViewController
footerView.layoutMargins.left = self.view.layoutMargins.left footerView.layoutMargins.left = self.view.layoutMargins.left
footerView.layoutMargins.right = self.view.layoutMargins.right footerView.layoutMargins.right = self.view.layoutMargins.right
footerView.bannerView.button.isIndicatingActivity = false
footerView.bannerView.configure(for: storeApp) footerView.bannerView.configure(for: storeApp)
footerView.bannerView.tintColor = storeApp.tintColor footerView.bannerView.tintColor = storeApp.tintColor
footerView.bannerView.button.addTarget(self, action: #selector(NewsViewController.performAppAction(_:)), for: .primaryActionTriggered) footerView.bannerView.button.addTarget(self, action: #selector(NewsViewController.performAppAction(_:)), for: .primaryActionTriggered)
footerView.tapGestureRecognizer.addTarget(self, action: #selector(NewsViewController.handleTapGesture(_:))) 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) Nuke.loadImage(with: storeApp.iconURL, into: footerView.bannerView.iconImageView)
return footerView return footerView

View File

@@ -7,15 +7,19 @@
// //
import Foundation import Foundation
import Roxas import WebKit
import UniformTypeIdentifiers
import AltStoreCore import AltStoreCore
import AltSign import AltSign
import Roxas
@objc(DownloadAppOperation) @objc(DownloadAppOperation)
class DownloadAppOperation: ResultOperation<ALTApplication> class DownloadAppOperation: ResultOperation<ALTApplication>
{ {
let app: AppProtocol @Managed
private(set) var app: AppProtocol
let context: InstallAppOperationContext let context: InstallAppOperationContext
private let appName: String private let appName: String
@@ -25,6 +29,8 @@ class DownloadAppOperation: ResultOperation<ALTApplication>
private let session = URLSession(configuration: .default) private let session = URLSession(configuration: .default)
private let temporaryDirectory = FileManager.default.uniqueTemporaryURL() private let temporaryDirectory = FileManager.default.uniqueTemporaryURL()
private var downloadPatreonAppContinuation: CheckedContinuation<URL, Error>?
init(app: AppProtocol, destinationURL: URL, context: InstallAppOperationContext) init(app: AppProtocol, destinationURL: URL, context: InstallAppOperationContext)
{ {
self.app = app self.app = app
@@ -55,22 +61,36 @@ class DownloadAppOperation: ResultOperation<ALTApplication>
// Set _after_ checking self.context.error to prevent overwriting localized failure for previous errors. // 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) self.localizedFailure = String(format: NSLocalizedString("%@ could not be downloaded.", comment: ""), self.appName)
guard let storeApp = self.app as? StoreApp else { self.$app.perform { app in
// 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 {
do do
{ {
let latestVersion = try self.verify(storeApp) var appVersion: AppVersion?
self.download(latestVersion)
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 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)) } else { return self.finish(.failure(error)) }
if let installedApp = storeApp.installedApp if let installedApp = storeApp.installedApp
@@ -81,7 +101,7 @@ class DownloadAppOperation: ResultOperation<ALTApplication>
let title = NSLocalizedString("Unsupported iOS Version", comment: "") 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 message = error.localizedDescription + "\n\n" + NSLocalizedString("Would you like to download the last version compatible with this device instead?", comment: "")
let localizedVersion = latestSupportedVersion.localizedVersion let localizedVersion = latestSupportedVersion.localizedVersion
DispatchQueue.main.async { DispatchQueue.main.async {
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { _ in alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { _ in
@@ -117,23 +137,16 @@ class DownloadAppOperation: ResultOperation<ALTApplication>
private extension DownloadAppOperation 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) 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 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) func download(@Managed _ app: AppProtocol)
@@ -194,11 +207,43 @@ private extension DownloadAppOperation
func downloadIPA(from sourceURL: URL, completionHandler: @escaping (Result<ALTApplication, Error>) -> Void) func downloadIPA(from sourceURL: URL, completionHandler: @escaping (Result<ALTApplication, Error>) -> Void)
{ {
func finishOperation(_ result: Result<URL, Error>) Task<Void, Never>.detached(priority: .userInitiated) {
{
do 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 var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: fileURL.path, isDirectory: &isDirectory) else { throw OperationError.appNotFound(name: self.appName) } 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)) completionHandler(.failure(error))
} }
} }
}
if sourceURL.isFileURL
{ func downloadFile(from downloadURL: URL) async throws -> URL
finishOperation(.success(sourceURL)) {
try await withCheckedThrowingContinuation { continuation in
self.progress.completedUnitCount += 3 let downloadTask = self.session.downloadTask(with: downloadURL) { (fileURL, response, error) in
}
else
{
let downloadTask = self.session.downloadTask(with: sourceURL) { (fileURL, response, error) in
do do
{ {
if let response = response as? HTTPURLResponse 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() let (fileURL, _) = try Result((fileURL, response), error).get()
finishOperation(.success(fileURL)) continuation.resume(returning: fileURL)
try? FileManager.default.removeItem(at: fileURL)
} }
catch catch
{ {
finishOperation(.failure(error)) continuation.resume(throwing: error)
} }
} }
self.progress.addChild(downloadTask.progress, withPendingUnitCount: 3) self.progress.addChild(downloadTask.progress, withPendingUnitCount: 3)
@@ -267,6 +307,162 @@ private extension DownloadAppOperation
downloadTask.resume() 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 private extension DownloadAppOperation

View File

@@ -36,6 +36,10 @@ extension OperationError
case serverNotFound = 1200 case serverNotFound = 1200
case connectionFailed = 1201 case connectionFailed = 1201
case connectionDropped = 1202 case connectionDropped = 1202
/* Pledges */
case pledgeRequired = 1401
case pledgeInactive = 1402
} }
static var cancelled: CancellationError { CancellationError() } static var cancelled: CancellationError { CancellationError() }
@@ -67,6 +71,14 @@ extension OperationError
static func forbidden(failureReason: String? = nil, file: String = #fileID, line: UInt = #line) -> OperationError { static func forbidden(failureReason: String? = nil, file: String = #fileID, line: UInt = #line) -> OperationError {
OperationError(code: .forbidden, failureReason: failureReason, sourceFile: file, sourceLine: line) 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 struct OperationError: ALTLocalizedError
@@ -132,6 +144,14 @@ struct OperationError: ALTLocalizedError
case .serverNotFound: return NSLocalizedString("AltServer could not be found.", comment: "") case .serverNotFound: return NSLocalizedString("AltServer could not be found.", comment: "")
case .connectionFailed: return NSLocalizedString("A connection to AltServer could not be established.", 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 .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? private var _failureReason: String?

View File

@@ -153,7 +153,13 @@ class FetchSourceOperation: ResultOperation<Source>
let identifier = source.identifier 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.verify(source, response: response)
try self.verifyPledges(for: source, in: childContext)
try childContext.save() 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 func verifySourceNotBlocked(_ source: Source, response: URLResponse?) throws
{ {
guard let blockedSources = UserDefaults.shared.blockedSources else { return } guard let blockedSources = UserDefaults.shared.blockedSources else { return }

View File

@@ -110,7 +110,7 @@ class PatchAppOperation: ResultOperation<Void>
.flatMap { self.patch(resignedApp, withBinaryAt: $0) } .flatMap { self.patch(resignedApp, withBinaryAt: $0) }
.tryMap { try FileManager.default.zipAppBundle(at: $0) } .tryMap { try FileManager.default.zipAppBundle(at: $0) }
.tryMap { (fileURL) in .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) let destinationURL = InstalledApp.refreshedIPAURL(for: app)
try FileManager.default.copyItem(at: fileURL, to: destinationURL, shouldReplace: true) try FileManager.default.copyItem(at: fileURL, to: destinationURL, shouldReplace: true)

View File

@@ -44,7 +44,7 @@ class SendAppOperation: ResultOperation<ServerConnection>
Logger.sideload.notice("Sending app \(self.context.bundleIdentifier, privacy: .public) to AltServer \(server.localizedName ?? "nil", privacy: .public)...") 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. // 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) let fileURL = InstalledApp.refreshedIPAURL(for: app)
// Connect to server. // Connect to server.

View File

@@ -138,7 +138,7 @@ private extension PatreonViewController
headerView.accountButton.addTarget(self, action: #selector(PatreonViewController.signOut(_:)), for: .primaryActionTriggered) headerView.accountButton.addTarget(self, action: #selector(PatreonViewController.signOut(_:)), for: .primaryActionTriggered)
headerView.accountButton.setTitle(String(format: NSLocalizedString("Unlink %@", comment: ""), account.name), for: .normal) headerView.accountButton.setTitle(String(format: NSLocalizedString("Unlink %@", comment: ""), account.name), for: .normal)
if account.isPatron if account.isAltStorePatron
{ {
headerView.supportButton.setTitle(isPatronSupportButtonTitle, for: .normal) headerView.supportButton.setTitle(isPatronSupportButtonTitle, for: .normal)
@@ -191,19 +191,35 @@ private extension PatreonViewController
@IBAction func authenticate(_ sender: UIBarButtonItem) @IBAction func authenticate(_ sender: UIBarButtonItem)
{ {
PatreonAPI.shared.authenticate { (result) in PatreonAPI.shared.authenticate(presentingViewController: self) { (result) in
do do
{ {
let account = try result.get() let account = try result.get()
try account.managedObjectContext?.save() try account.managedObjectContext?.save()
DispatchQueue.main.async { // Update sources to show any Patreon-only apps.
self.update() 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<Void, Never>.detached {
await PatreonAPI.shared.deleteAuthCookies()
}
} }
catch catch
{ {

View File

@@ -215,7 +215,7 @@ private extension SourceDetailContentViewController
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<StoreApp, UIImage>(items: limitedFeaturedApps) let dataSource = RSTArrayCollectionViewPrefetchingDataSource<StoreApp, UIImage>(items: limitedFeaturedApps)
dataSource.cellIdentifierHandler = { _ in "AppCell" } 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 dataSource.cellConfigurationHandler = { [weak self] (cell, storeApp, indexPath) in
let cell = cell as! AppBannerCollectionViewCell let cell = cell as! AppBannerCollectionViewCell
cell.tintColor = storeApp.tintColor cell.tintColor = storeApp.tintColor
@@ -225,43 +225,13 @@ private extension SourceDetailContentViewController
cell.contentView.layoutMargins = .zero cell.contentView.layoutMargins = .zero
cell.contentView.backgroundColor = .altBackground cell.contentView.backgroundColor = .altBackground
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.configure(for: storeApp) 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.tintColor = storeApp.tintColor
cell.bannerView.button.addTarget(self, action: #selector(SourceDetailContentViewController.performAppAction(_:)), for: .primaryActionTriggered)
let buttonTitle = NSLocalizedString("Free", comment: "") cell.bannerView.iconImageView.isIndicatingActivity = true
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
}
} }
dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in
return RSTAsyncBlockOperation { (operation) in return RSTAsyncBlockOperation { (operation) in
@@ -404,64 +374,93 @@ extension SourceDetailContentViewController
private 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) let point = self.collectionView.convert(sender.center, from: sender.superview)
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return } guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
sender.isIndicatingActivity = true
let storeApp = self.dataSource.item(at: indexPath) as! StoreApp let storeApp = self.dataSource.item(at: indexPath) as! StoreApp
Task<Void, Never> { if let installedApp = storeApp.installedApp, !installedApp.isUpdateAvailable
{
self.open(installedApp)
}
else
{
sender.isIndicatingActivity = true
Task<Void, Never> {
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 do
{ {
let isAdded = try await self.source.isAdded try await self.downloadApp(storeApp)
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)
}
} }
catch is CancellationError {} catch is CancellationError {}
catch 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 func downloadApp(_ storeApp: StoreApp) async throws
{ {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
AppManager.shared.install(storeApp, presentingViewController: self) { result in if let installedApp = storeApp.installedApp, installedApp.isUpdateAvailable
continuation.resume(with: result.map { _ in }) {
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 { UIView.performWithoutAnimation {
self.collectionView.reloadSections([Section.featuredApps.rawValue]) guard let index = self.appsDataSource.items.firstIndex(of: storeApp) else {
return 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 extension SourceDetailContentViewController: ScrollableContentViewController

View File

@@ -204,15 +204,7 @@ private extension SourcesViewController
cell.bannerView.iconImageView.image = nil cell.bannerView.iconImageView.image = nil
cell.bannerView.iconImageView.isIndicatingActivity = true cell.bannerView.iconImageView.isIndicatingActivity = true
let numberOfApps: Int let numberOfApps = source.apps.filter { StoreApp.visibleAppsPredicate.evaluate(with: $0) }.count
if let patreonAccount = DatabaseManager.shared.patreonAccount(), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated
{
numberOfApps = source.apps.count
}
else
{
numberOfApps = source.apps.filter { !$0.isBeta }.count
}
if let error = source.error if let error = source.error
{ {

View File

@@ -18,7 +18,7 @@ FOUNDATION_EXPORT const unsigned char AltStoreCoreVersionString[];
#import <AltStoreCore/ALTAppPermissions.h> #import <AltStoreCore/ALTAppPermissions.h>
#import <AltStoreCore/ALTSourceUserInfoKey.h> #import <AltStoreCore/ALTSourceUserInfoKey.h>
#import <AltStoreCore/ALTPatreonBenefitType.h> #import <AltStoreCore/ALTPatreonBenefitID.h>
// Shared // Shared
#import <AltStoreCore/ALTConstants.h> #import <AltStoreCore/ALTConstants.h>

View File

@@ -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<AnyCancellable> = []
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)
}
}

View File

@@ -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
}
}

View File

@@ -39,6 +39,8 @@ public extension UserDefaults
@NSManaged var patronsRefreshID: String? @NSManaged var patronsRefreshID: String?
@NSManaged var skipPatreonDownloads: Bool
@nonobjc @nonobjc
var activeAppsLimit: Int? { var activeAppsLimit: Int? {
get { get {

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22222" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22225" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="Account" syncable="YES"> <entity name="Account" representedClassName="Account" syncable="YES">
<attribute name="appleID" attributeType="String"/> <attribute name="appleID" attributeType="String"/>
<attribute name="firstName" attributeType="String"/> <attribute name="firstName" attributeType="String"/>
@@ -154,6 +154,7 @@
<attribute name="identifier" attributeType="String"/> <attribute name="identifier" attributeType="String"/>
<attribute name="isPatron" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="isPatron" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="name" attributeType="String"/> <attribute name="name" attributeType="String"/>
<relationship name="pledges" toMany="YES" deletionRule="Cascade" destinationEntity="Pledge" inverseName="account" inverseEntity="Pledge"/>
<uniquenessConstraints> <uniquenessConstraints>
<uniquenessConstraint> <uniquenessConstraint>
<constraint value="identifier"/> <constraint value="identifier"/>
@@ -169,6 +170,40 @@
</uniquenessConstraint> </uniquenessConstraint>
</uniquenessConstraints> </uniquenessConstraints>
</entity> </entity>
<entity name="Pledge" representedClassName="Pledge" syncable="YES">
<attribute name="amount" attributeType="Decimal" defaultValueString="0"/>
<attribute name="campaignURL" attributeType="URI"/>
<attribute name="identifier" attributeType="String"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="PatreonAccount" inverseName="pledges" inverseEntity="PatreonAccount"/>
<relationship name="rewards" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="PledgeReward" inverseName="pledge" inverseEntity="PledgeReward"/>
<relationship name="tiers" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="PledgeTier" inverseName="pledge" inverseEntity="PledgeTier"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="identifier"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="PledgeReward" representedClassName="PledgeReward" syncable="YES">
<attribute name="identifier" attributeType="String"/>
<attribute name="name" attributeType="String"/>
<relationship name="pledge" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Pledge" inverseName="rewards" inverseEntity="Pledge"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="identifier"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="PledgeTier" representedClassName="PledgeTier" syncable="YES">
<attribute name="amount" attributeType="Decimal" defaultValueString="0.0"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="name" attributeType="String"/>
<relationship name="pledge" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Pledge" inverseName="tiers" inverseEntity="Pledge"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="identifier"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="RefreshAttempt" representedClassName="RefreshAttempt" syncable="YES"> <entity name="RefreshAttempt" representedClassName="RefreshAttempt" syncable="YES">
<attribute name="date" attributeType="Date" usesScalarValueType="NO"/> <attribute name="date" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="errorDescription" optional="YES" attributeType="String"/> <attribute name="errorDescription" optional="YES" attributeType="String"/>
@@ -188,6 +223,7 @@
<attribute name="identifier" attributeType="String"/> <attribute name="identifier" attributeType="String"/>
<attribute name="localizedDescription" optional="YES" attributeType="String"/> <attribute name="localizedDescription" optional="YES" attributeType="String"/>
<attribute name="name" attributeType="String"/> <attribute name="name" attributeType="String"/>
<attribute name="patreonURL" optional="YES" attributeType="URI"/>
<attribute name="sourceURL" attributeType="URI"/> <attribute name="sourceURL" attributeType="URI"/>
<attribute name="subtitle" optional="YES" attributeType="String"/> <attribute name="subtitle" optional="YES" attributeType="String"/>
<attribute name="tintColor" optional="YES" attributeType="Transformable" valueTransformerName="ALTSecureValueTransformer"/> <attribute name="tintColor" optional="YES" attributeType="Transformable" valueTransformerName="ALTSecureValueTransformer"/>
@@ -207,8 +243,13 @@
<attribute name="downloadURL" attributeType="URI"/> <attribute name="downloadURL" attributeType="URI"/>
<attribute name="iconURL" attributeType="URI"/> <attribute name="iconURL" attributeType="URI"/>
<attribute name="isBeta" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/> <attribute name="isBeta" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="isHiddenWithoutPledge" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="isPledged" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="isPledgeRequired" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="localizedDescription" attributeType="String"/> <attribute name="localizedDescription" attributeType="String"/>
<attribute name="name" attributeType="String"/> <attribute name="name" attributeType="String"/>
<attribute name="pledgeAmount" optional="YES" attributeType="Decimal"/>
<attribute name="pledgeCurrency" optional="YES" attributeType="String"/>
<attribute name="screenshotURLs" attributeType="Transformable" valueTransformerName="ALTSecureValueTransformer"/> <attribute name="screenshotURLs" attributeType="Transformable" valueTransformerName="ALTSecureValueTransformer"/>
<attribute name="size" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="size" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="sortIndex" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/> <attribute name="sortIndex" attributeType="Integer 32" defaultValueString="0" usesScalarValueType="YES"/>

View File

@@ -226,7 +226,7 @@ public extension DatabaseManager
let predicate = NSPredicate(format: "%K == %@", #keyPath(PatreonAccount.identifier), patreonAccountID) 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 return patreonAccount
} }
} }

View File

@@ -194,13 +194,19 @@ public extension InstalledApp
// We have to also check !(latestSupportedVersion.buildVersion == '' && installedApp.storeBuildVersion == nil) // We have to also check !(latestSupportedVersion.buildVersion == '' && installedApp.storeBuildVersion == nil)
// because latestSupportedVersion.buildVersion stores an empty string for nil, while installedApp.storeBuildVersion uses NULL. // 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)))", "(%K != %K OR (%K != %K AND NOT (%K == '' AND %K == nil)))",
"AND",
// !isPledgeRequired || isPledged
"(%K == NO OR %K == YES)"
].joined(separator: " ") ].joined(separator: " ")
fetchRequest.predicate = NSPredicate(format: predicateFormat, fetchRequest.predicate = NSPredicate(format: predicateFormat,
#keyPath(InstalledApp.isActive), #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.latestSupportedVersion), #keyPath(InstalledApp.isActive), #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.latestSupportedVersion),
#keyPath(InstalledApp.storeApp.latestSupportedVersion.version), #keyPath(InstalledApp.version), #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.latestSupportedVersion._buildVersion), #keyPath(InstalledApp.storeBuildVersion),
#keyPath(InstalledApp.storeApp.isPledgeRequired), #keyPath(InstalledApp.storeApp.isPledged))
return fetchRequest return fetchRequest
} }
@@ -227,17 +233,12 @@ public extension InstalledApp
class func fetchAppsForRefreshingAll(in context: NSManagedObjectContext) -> [InstalledApp] class func fetchAppsForRefreshingAll(in context: NSManagedObjectContext) -> [InstalledApp]
{ {
var predicate = NSPredicate(format: "%K == YES AND %K != %@", #keyPath(InstalledApp.isActive), #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID) let predicate = NSPredicate(format: "(%K == YES AND %K != %@) AND (%K == nil OR %K == NO OR %K == YES)",
#keyPath(InstalledApp.isActive),
if let patreonAccount = DatabaseManager.shared.patreonAccount(in: context), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID,
{ #keyPath(InstalledApp.storeApp),
// No additional predicate #keyPath(InstalledApp.storeApp.isPledgeRequired),
} #keyPath(InstalledApp.storeApp.isPledged))
else
{
predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate,
NSPredicate(format: "%K == nil OR %K == NO", #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.isBeta))])
}
var installedApps = InstalledApp.all(satisfying: predicate, var installedApps = InstalledApp.all(satisfying: predicate,
sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)], sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)],
@@ -246,7 +247,20 @@ public extension InstalledApp
if let altStoreApp = InstalledApp.fetchAltStore(in: context) if let altStoreApp = InstalledApp.fetchAltStore(in: context)
{ {
// Refresh AltStore last since it causes app to quit. // 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 return installedApps
@@ -257,20 +271,14 @@ public extension InstalledApp
// Date 6 hours before now. // Date 6 hours before now.
let date = Date().addingTimeInterval(-1 * 6 * 60 * 60) 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.isActive),
#keyPath(InstalledApp.refreshedDate), date as NSDate, #keyPath(InstalledApp.refreshedDate), date as NSDate,
#keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID) #keyPath(InstalledApp.bundleIdentifier), StoreApp.altstoreAppID,
#keyPath(InstalledApp.storeApp),
if let patreonAccount = DatabaseManager.shared.patreonAccount(in: context), patreonAccount.isPatron, PatreonAPI.shared.isAuthenticated #keyPath(InstalledApp.storeApp.isPledgeRequired),
{ #keyPath(InstalledApp.storeApp.isPledged)
// No additional predicate )
}
else
{
predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate,
NSPredicate(format: "%K == nil OR %K == NO", #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.storeApp.isBeta))])
}
var installedApps = InstalledApp.all(satisfying: predicate, var installedApps = InstalledApp.all(satisfying: predicate,
sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)], sortedBy: [NSSortDescriptor(keyPath: \InstalledApp.expirationDate, ascending: true)],
@@ -278,8 +286,19 @@ public extension InstalledApp
if let altStoreApp = InstalledApp.fetchAltStore(in: context), altStoreApp.refreshedDate < date if let altStoreApp = InstalledApp.fetchAltStore(in: context), altStoreApp.refreshedDate < date
{ {
// Refresh AltStore last since it may cause app to quit. if let storeApp = altStoreApp.storeApp
installedApps.append(altStoreApp) {
if !storeApp.isPledgeRequired || storeApp.isPledged
{
// Only add AltStore if it's the public version OR if it's the beta and we're pledged to it.
installedApps.append(altStoreApp)
}
}
else
{
// No associated storeApp, so add it just to be safe.
installedApps.append(altStoreApp)
}
} }
return installedApps return installedApps
@@ -298,6 +317,13 @@ public extension InstalledApp
let openAppURL = URL(string: "altstore-" + app.bundleIdentifier + "://")! let openAppURL = URL(string: "altstore-" + app.bundleIdentifier + "://")!
return openAppURL 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 public extension InstalledApp

View File

@@ -107,7 +107,7 @@ public extension LoggedError
{ {
var app: AppProtocol { var app: AppProtocol {
// `as AppProtocol` needed to fix "cannot convert AnyApp to StoreApp" compiler error with Xcode 14. // `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 return app
} }

View File

@@ -19,7 +19,7 @@ public class ManagedPatron: NSManagedObject, Fetchable
super.init(entity: entity, insertInto: context) 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. // Only cache Patrons with non-nil names.
guard let name = patron.name else { return nil } guard let name = patron.name else { return nil }

View File

@@ -262,7 +262,36 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
{ {
featuredAppIDsBySourceID[databaseObject.identifier] = contextSource.featuredApps?.map { $0.bundleIdentifier } 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 default: break
} }
} }

View File

@@ -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<Pledge> { _pledges as! Set<Pledge> }
@NSManaged @objc(pledges) internal var _pledges: NSSet
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
{
super.init(entity: entity, insertInto: context)
}
init(account: PatreonAPI.UserAccount, context: NSManagedObjectContext)
{
super.init(entity: PatreonAccount.entity(), insertInto: context)
self.identifier = account.identifier
self.name = account.name
self.firstName = account.firstName
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<PatreonAccount>
{
return NSFetchRequest<PatreonAccount>(entityName: "PatreonAccount")
}
}

View File

@@ -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<PledgeTier> { _tiers as! Set<PledgeTier> }
@NSManaged @objc(tiers) internal var _tiers: NSSet
@nonobjc public var rewards: Set<PledgeReward> { _rewards as! Set<PledgeReward> }
@NSManaged @objc(rewards) internal var _rewards: NSSet
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
{
super.init(entity: entity, insertInto: context)
}
init?(patron: PatreonAPI.Patron, context: NSManagedObjectContext)
{
guard let amount = patron.pledgeAmount, let campaignURL = patron.campaign?.url else { return nil }
super.init(entity: Pledge.entity(), insertInto: context)
self.identifier = patron.identifier
self._amount = amount as NSDecimalNumber
self.campaignURL = campaignURL
}
}
public extension Pledge
{
@nonobjc class func fetchRequest() -> NSFetchRequest<Pledge>
{
return NSFetchRequest<Pledge>(entityName: "Pledge")
}
}

View File

@@ -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<PledgeReward>
{
return NSFetchRequest<PledgeReward>(entityName: "PledgeReward")
}
}

View File

@@ -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<PledgeTier>
{
return NSFetchRequest<PledgeTier>(entityName: "PledgeTier")
}
}

View File

@@ -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<PatreonAccount>
{
return NSFetchRequest<PatreonAccount>(entityName: "PatreonAccount")
}
}

View File

@@ -63,8 +63,9 @@ public class Source: NSManagedObject, Fetchable, Decodable
/* Source Detail */ /* Source Detail */
@NSManaged public var subtitle: String? @NSManaged public var subtitle: String?
@NSManaged public var websiteURL: URL?
@NSManaged public var localizedDescription: String? @NSManaged public var localizedDescription: String?
@NSManaged public var websiteURL: URL?
@NSManaged public var patreonURL: URL?
// Optional properties with fallbacks. // Optional properties with fallbacks.
// `private` to prevent accidentally using instead of `effective[PropertyName]` // `private` to prevent accidentally using instead of `effective[PropertyName]`
@@ -117,6 +118,7 @@ public class Source: NSManagedObject, Fetchable, Decodable
case headerImageURL = "headerURL" case headerImageURL = "headerURL"
case websiteURL = "website" case websiteURL = "website"
case tintColor case tintColor
case patreonURL
case apps case apps
case news case news
@@ -147,6 +149,7 @@ public class Source: NSManagedObject, Fetchable, Decodable
self.localizedDescription = try container.decodeIfPresent(String.self, forKey: .localizedDescription) self.localizedDescription = try container.decodeIfPresent(String.self, forKey: .localizedDescription)
self.iconURL = try container.decodeIfPresent(URL.self, forKey: .iconURL) self.iconURL = try container.decodeIfPresent(URL.self, forKey: .iconURL)
self.headerImageURL = try container.decodeIfPresent(URL.self, forKey: .headerImageURL) 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) 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 class func sourceID(from sourceURL: URL) throws -> String
{ {
// Based on https://encyclopedia.pub/entry/29841 let sourceID = try sourceURL.normalized()
return sourceID
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
} }
func setFeaturedApps(_ featuredApps: [StoreApp]?) func setFeaturedApps(_ featuredApps: [StoreApp]?)

View File

@@ -25,6 +25,18 @@ public extension StoreApp
static let dolphinAppID = "me.oatmealdome.dolphinios-njb" static let dolphinAppID = "me.oatmealdome.dolphinios-njb"
} }
extension StoreApp
{
private struct PatreonParameters: Decodable
{
var pledge: Decimal?
var currency: String?
var tiers: Set<String>?
var benefit: String?
var hidden: Bool?
}
}
@objc(StoreApp) @objc(StoreApp)
public class StoreApp: NSManagedObject, Decodable, Fetchable 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 tintColor: UIColor?
@NSManaged public private(set) var isBeta: Bool @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 @NSManaged public var sortIndex: Int32
@objc public internal(set) var sourceIdentifier: String? { @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<LoggedError> */ // Use NSSet to avoid eagerly fetching values. @NSManaged public private(set) var loggedErrors: NSSet /* Set<LoggedError> */ // Use NSSet to avoid eagerly fetching values.
/* Non-Core Data Properties */
// Used to set isPledged after fetching source.
public var _tierIDs: Set<String>?
public var _rewardID: String?
@nonobjc public var source: Source? { @nonobjc public var source: Source? {
set { set {
self._source = newValue self._source = newValue
@@ -137,6 +163,7 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
case size case size
case isBeta = "beta" case isBeta = "beta"
case versions case versions
case patreon
// Legacy // Legacy
case version case version
@@ -247,6 +274,39 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
in: context) in: context)
try self.setVersions([appVersion]) 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 catch
{ {
@@ -376,6 +436,18 @@ public extension StoreApp
let globallyUniqueID = self.bundleIdentifier + "|" + sourceIdentifier let globallyUniqueID = self.bundleIdentifier + "|" + sourceIdentifier
return globallyUniqueID 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<StoreApp> @nonobjc class func fetchRequest() -> NSFetchRequest<StoreApp>
{ {

View File

@@ -10,18 +10,25 @@ import Foundation
extension PatreonAPI extension PatreonAPI
{ {
struct BenefitResponse: Decodable typealias BenefitResponse = DataResponse<BenefitAttributes, AnyRelationships>
struct BenefitAttributes: Decodable
{ {
var id: String var title: String
} }
} }
public struct Benefit: Hashable extension PatreonAPI
{ {
public var type: ALTPatreonBenefitType public struct Benefit: Hashable
init(response: PatreonAPI.BenefitResponse)
{ {
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)
}
} }
} }

View File

@@ -10,18 +10,25 @@ import Foundation
extension PatreonAPI extension PatreonAPI
{ {
struct CampaignResponse: Decodable typealias CampaignResponse = DataResponse<CampaignAttributes, AnyRelationships>
struct CampaignAttributes: Decodable
{ {
var id: String var url: URL
} }
} }
public struct Campaign extension PatreonAPI
{ {
public var identifier: String public struct Campaign
init(response: PatreonAPI.CampaignResponse)
{ {
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
}
} }
} }

View File

@@ -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<Data: ResponseData>: Decodable
{
var data: Data
var included: IncludedResponses?
var links: [String: URL]?
}
struct AnyItemResponse: ItemResponse
{
var id: String
var type: String
}
struct DataResponse<Attributes: Decodable, Relationships: Decodable>: ItemResponse
{
var id: String
var type: String
var attributes: Attributes
var relationships: Relationships?
}
// `Never` only conforms to Decodable from iOS 17 onwards,
// so use our own "Empty" type for DataResponses without relationships.
struct AnyRelationships: Decodable
{
}
struct IncludedResponses: Decodable
{
var items: [IncludedItem]
var campaigns: [String: CampaignResponse]
var patrons: [String: PatronResponse]
var tiers: [String: TierResponse]
var benefits: [String: BenefitResponse]
init(from decoder: Decoder) throws
{
let container = try decoder.singleValueContainer()
self.items = try container.decode([IncludedItem].self)
var campaignsByID = [String: PatreonAPI.CampaignResponse]()
var patronsByID = [String: PatreonAPI.PatronResponse]()
var tiersByID = [String: PatreonAPI.TierResponse]()
var benefitsByID = [String: PatreonAPI.BenefitResponse]()
for response in self.items
{
switch response
{
case .campaign(let response): campaignsByID[response.id] = response
case .patron(let response): patronsByID[response.id] = response
case .tier(let response): tiersByID[response.id] = response
case .benefit(let response): benefitsByID[response.id] = response
case .unknown: break // Ignore
}
}
self.campaigns = campaignsByID
self.patrons = patronsByID
self.tiers = tiersByID
self.benefits = benefitsByID
}
}
enum IncludedItem: ItemResponse
{
case tier(TierResponse)
case benefit(BenefitResponse)
case patron(PatronResponse)
case campaign(CampaignResponse)
case unknown(AnyItemResponse)
var id: String {
switch self
{
case .tier(let response): return response.id
case .benefit(let response): return response.id
case .patron(let response): return response.id
case .campaign(let response): return response.id
case .unknown(let response): return response.id
}
}
var type: String {
switch self
{
case .tier(let response): return response.type
case .benefit(let response): return response.type
case .patron(let response): return response.type
case .campaign(let response): return response.type
case .unknown(let response): return response.type
}
}
private enum CodingKeys: String, CodingKey
{
case type
}
init(from decoder: Decoder) throws
{
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type
{
case "tier":
let response = try TierResponse(from: decoder)
self = .tier(response)
case "benefit":
let response = try BenefitResponse(from: decoder)
self = .benefit(response)
case "member":
let response = try PatronResponse(from: decoder)
self = .patron(response)
case "campaign":
let response = try CampaignResponse(from: decoder)
self = .campaign(response)
default:
Logger.main.error("Unrecognized PatreonAPI response type: \(type, privacy: .public).")
let response = try AnyItemResponse(from: decoder)
self = .unknown(response)
}
}
}
}

View File

@@ -9,12 +9,11 @@
import Foundation import Foundation
import AuthenticationServices import AuthenticationServices
import CoreData import CoreData
import WebKit
private let clientID = "ZMx0EGUWe4TVWYXNZZwK_fbIK5jHFVWoUf1Qb-sqNXmT-YzAGwDPxxq7ak3_W5Q2" private let clientID = "ZMx0EGUWe4TVWYXNZZwK_fbIK5jHFVWoUf1Qb-sqNXmT-YzAGwDPxxq7ak3_W5Q2"
private let clientSecret = "1hktsZB89QyN69cB4R0tu55R4TCPQGXxvebYUUh7Y-5TLSnRswuxs6OUjdJ74IJt" private let clientSecret = "1hktsZB89QyN69cB4R0tu55R4TCPQGXxvebYUUh7Y-5TLSnRswuxs6OUjdJ74IJt"
private let campaignID = "2863968"
typealias PatreonAPIError = PatreonAPIErrorCode.Error typealias PatreonAPIError = PatreonAPIErrorCode.Error
enum PatreonAPIErrorCode: Int, ALTErrorEnum, CaseIterable enum PatreonAPIErrorCode: Int, ALTErrorEnum, CaseIterable
{ {
@@ -34,42 +33,17 @@ enum PatreonAPIErrorCode: Int, ALTErrorEnum, CaseIterable
extension PatreonAPI extension PatreonAPI
{ {
static let altstoreCampaignID = "2863968"
typealias FetchAccountResponse = Response<UserAccountResponse>
typealias FriendZonePatronsResponse = Response<[PatronResponse]>
enum AuthorizationType enum AuthorizationType
{ {
case none case none
case user case user
case creator 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 public class PatreonAPI: NSObject
@@ -85,6 +59,10 @@ public class PatreonAPI: NSObject
private let session = URLSession(configuration: .ephemeral) private let session = URLSession(configuration: .ephemeral)
private let baseURL = URL(string: "https://www.patreon.com/")! private let baseURL = URL(string: "https://www.patreon.com/")!
private var authHandlers = [(Result<PatreonAccount, Swift.Error>) -> Void]()
private var authContinuation: CheckedContinuation<URL, Error>?
private weak var webViewController: WebViewController?
private override init() private override init()
{ {
super.init() super.init()
@@ -93,19 +71,40 @@ public class PatreonAPI: NSObject
public extension PatreonAPI public extension PatreonAPI
{ {
func authenticate(completion: @escaping (Result<PatreonAccount, Swift.Error>) -> Void) func authenticate(presentingViewController: UIViewController, completion: @escaping (Result<PatreonAccount, Swift.Error>) -> Void)
{ {
var components = URLComponents(string: "/oauth2/authorize")! Task<Void, Never>.detached { @MainActor in
components.queryItems = [URLQueryItem(name: "response_type", value: "code"), guard self.authHandlers.isEmpty else {
URLQueryItem(name: "client_id", value: clientID), self.authHandlers.append(completion)
URLQueryItem(name: "redirect_uri", value: "https://rileytestut.com/patreon/altstore")] return
}
let requestURL = components.url(relativeTo: self.baseURL)!
self.authHandlers.append(completion)
self.authenticationSession = ASWebAuthenticationSession(url: requestURL, callbackURLScheme: "altstore") { (callbackURL, error) in
do 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 guard
let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false), let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
@@ -113,39 +112,61 @@ public extension PatreonAPI
let code = codeQueryItem.value let code = codeQueryItem.value
else { throw PatreonAPIError(.unknown) } else { throw PatreonAPIError(.unknown) }
self.fetchAccessToken(oauthCode: code) { (result) in let (accessToken, refreshToken) = try await withCheckedThrowingContinuation { continuation in
switch result 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)) callback(.success(patreonAccount))
case .success((let accessToken, let refreshToken)):
Keychain.shared.patreonAccessToken = accessToken
Keychain.shared.patreonRefreshToken = refreshToken
self.fetchAccount(completion: completion)
} }
} }
} }
catch 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<PatreonAccount, Swift.Error>) -> Void) func fetchAccount(completion: @escaping (Result<PatreonAccount, Swift.Error>) -> Void)
{ {
var components = URLComponents(string: "/api/oauth2/v2/identity")! 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[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 requestURL = components.url(relativeTo: self.baseURL)!
let request = URLRequest(url: requestURL) let request = URLRequest(url: requestURL)
self.send(request, authorizationType: .user) { (result: Result<AccountResponse, Swift.Error>) in self.send(request, authorizationType: .user) { (result: Result<FetchAccountResponse, Swift.Error>) in
switch result switch result
{ {
case .failure(~PatreonAPIErrorCode.notAuthenticated): case .failure(~PatreonAPIErrorCode.notAuthenticated):
@@ -153,10 +174,15 @@ public extension PatreonAPI
completion(.failure(PatreonAPIError(.notAuthenticated))) 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): case .success(let response):
let account = PatreonAPI.UserAccount(response: response.data, including: response.included)
DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in 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 Keychain.shared.patreonAccountID = account.identifier
completion(.success(account)) completion(.success(account))
} }
@@ -166,57 +192,34 @@ public extension PatreonAPI
func fetchPatrons(completion: @escaping (Result<[Patron], Swift.Error>) -> Void) 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"), components.queryItems = [URLQueryItem(name: "include", value: "currently_entitled_tiers,currently_entitled_tiers.benefits"),
URLQueryItem(name: "fields[tier]", value: "title"), URLQueryItem(name: "fields[tier]", value: "title,amount_cents"),
URLQueryItem(name: "fields[member]", value: "full_name,patron_status"), 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")] URLQueryItem(name: "page[size]", value: "1000")]
let requestURL = components.url(relativeTo: self.baseURL)! let requestURL = components.url(relativeTo: self.baseURL)!
struct Response: Decodable
{
var data: [PatronResponse]
var included: [AnyResponse]
var links: [String: URL]?
}
var allPatrons = [Patron]() var allPatrons = [Patron]()
func fetchPatrons(url: URL) func fetchPatrons(url: URL)
{ {
let request = URLRequest(url: url) let request = URLRequest(url: url)
self.send(request, authorizationType: .creator) { (result: Result<Response, Swift.Error>) in self.send(request, authorizationType: .creator) { (result: Result<FriendZonePatronsResponse, Swift.Error>) in
switch result switch result
{ {
case .failure(let error): completion(.failure(error)) case .failure(let error): completion(.failure(error))
case .success(let response): case .success(let patronsResponse):
let tiers = response.included.compactMap { (response) -> Tier? in let patrons = patronsResponse.data.map { (response) -> Patron in
switch response let patron = Patron(response: response, including: patronsResponse.included)
{
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)
}
return patron return patron
}.filter { $0.benefits.contains(where: { $0.type == .credits }) } }.filter { $0.benefits.contains(where: { $0.identifier == .credits }) }
allPatrons.append(contentsOf: patrons) allPatrons.append(contentsOf: patrons)
if let nextURL = response.links?["next"] if let nextURL = patronsResponse.links?["next"]
{ {
fetchPatrons(url: nextURL) fetchPatrons(url: nextURL)
} }
@@ -239,7 +242,8 @@ public extension PatreonAPI
let accounts = PatreonAccount.all(in: context, requestProperties: [\.returnsObjectsAsFaults: true]) let accounts = PatreonAccount.all(in: context, requestProperties: [\.returnsObjectsAsFaults: true])
accounts.forEach(context.delete(_:)) 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() try context.save()
@@ -247,7 +251,10 @@ public extension PatreonAPI
Keychain.shared.patreonRefreshToken = nil Keychain.shared.patreonRefreshToken = nil
Keychain.shared.patreonAccountID = nil Keychain.shared.patreonAccountID = nil
completion(.success(())) Task<Void, Never>.detached {
await self.deleteAuthCookies()
completion(.success(()))
}
} }
catch catch
{ {
@@ -264,13 +271,6 @@ public extension PatreonAPI
do do
{ {
let account = try result.get() 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() try account.managedObjectContext?.save()
} }
catch 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 private extension PatreonAPI
{ {
func fetchAccessToken(oauthCode: String, completion: @escaping (Result<(String, String), Swift.Error>) -> Void) func fetchAccessToken(oauthCode: String, completion: @escaping (Result<(String, String), Swift.Error>) -> Void)
@@ -397,36 +447,27 @@ private extension PatreonAPI
task.resume() 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 authContinuation else { return }
self.authContinuation = nil
guard let windowScene = UIApplication.alt_shared?.connectedScenes.lazy.compactMap({ $0 as? UIWindowScene }).first else { return UIWindow() }
if let callbackURL = urlSchemeTask.request.url
if #available(iOS 15, *), let keyWindow = windowScene.keyWindow
{ {
return keyWindow authContinuation.resume(returning: callbackURL)
} }
else if let delegate = windowScene.delegate as? UIWindowSceneDelegate, else
let optionalWindow = delegate.window,
let window = optionalWindow
{ {
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.")
} }
} }

View File

@@ -10,38 +10,23 @@ import Foundation
extension PatreonAPI extension PatreonAPI
{ {
struct PatronResponse: Decodable typealias PatronResponse = DataResponse<PatronAttributes, PatronRelationships>
struct PatronAttributes: Decodable
{ {
struct Attributes: Decodable var full_name: String?
{ var patron_status: String?
var full_name: String? var currently_entitled_amount_cents: Int32 // In campaign's currency
var patron_status: String? }
}
struct PatronRelationships: Decodable
struct Relationships: Decodable {
{ var campaign: Response<AnyItemResponse>?
struct Tiers: Decodable var currently_entitled_tiers: Response<[AnyItemResponse]>?
{
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?
} }
} }
extension Patron extension PatreonAPI
{ {
public enum Status: String, Decodable public enum Status: String, Decodable
{ {
@@ -50,29 +35,48 @@ extension Patron
case former = "former_patron" case former = "former_patron"
case unknown = "unknown" case unknown = "unknown"
} }
}
public class Patron
{
public var name: String?
public var identifier: String
public var status: Status // Roughly equivalent to AltStoreCore.Pledge
public class Patron
public var benefits: Set<Benefit> = []
init(response: PatreonAPI.PatronResponse)
{ {
self.name = response.attributes.full_name public var name: String?
self.identifier = response.id 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<Tier> = []
public var benefits: Set<Benefit> = []
internal init(response: PatronResponse, including included: IncludedResponses?)
{ {
self.status = Status(rawValue: status) ?? .unknown self.name = response.attributes.full_name
} self.identifier = response.id
else self.pledgeAmount = Decimal(response.attributes.currently_entitled_amount_cents) / 100
{
self.status = .unknown 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)
} }
} }
} }

View File

@@ -10,41 +10,43 @@ import Foundation
extension PatreonAPI extension PatreonAPI
{ {
struct TierResponse: Decodable typealias TierResponse = DataResponse<TierAttributes, TierRelationships>
struct TierAttributes: Decodable
{ {
struct Attributes: Decodable var title: String
{ var amount_cents: Int32 // In USD
var title: String }
}
struct TierRelationships: Decodable
struct Relationships: Decodable {
{ var benefits: Response<[AnyItemResponse]>?
struct Benefits: Decodable
{
var data: [BenefitResponse]
}
var benefits: Benefits
}
var id: String
var attributes: Attributes
var relationships: Relationships
} }
} }
public struct Tier extension PatreonAPI
{ {
public var name: String public struct Tier: Hashable
public var identifier: String
public var benefits: [Benefit] = []
init(response: PatreonAPI.TierResponse)
{ {
self.name = response.attributes.title public var name: String
self.identifier = response.id public var identifier: String
self.benefits = response.relationships.benefits.data.map(Benefit.init(response:)) 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
}
} }
} }

View File

@@ -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<UserAccountAttributes, AnyRelationships>
struct UserAccountAttributes: Decodable
{
var first_name: String?
var full_name: String
}
}
extension PatreonAPI
{
public struct UserAccount
{
var name: String
var firstName: String?
var identifier: String
// Relationships
var pledges: [Patron]?
init(response: UserAccountResponse, including included: IncludedResponses?)
{
self.identifier = response.id
self.name = response.attributes.full_name
self.firstName = response.attributes.first_name
guard let included else { return }
let patrons = included.patrons.values.compactMap { response -> Patron? in
let patron = Patron(response: response, including: included)
return patron
}
self.pledges = patrons
}
}
}

View File

@@ -14,6 +14,8 @@ public protocol AppProtocol
var name: String { get } var name: String { get }
var bundleIdentifier: String { get } var bundleIdentifier: String { get }
var url: URL? { get } var url: URL? { get }
var storeApp: StoreApp? { get }
} }
public struct AnyApp: AppProtocol public struct AnyApp: AppProtocol
@@ -21,12 +23,14 @@ public struct AnyApp: AppProtocol
public var name: String public var name: String
public var bundleIdentifier: String public var bundleIdentifier: String
public var url: URL? 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.name = name
self.bundleIdentifier = bundleIdentifier self.bundleIdentifier = bundleIdentifier
self.url = url self.url = url
self.storeApp = storeApp
} }
} }
@@ -35,6 +39,10 @@ extension ALTApplication: AppProtocol
public var url: URL? { public var url: URL? {
return self.fileURL return self.fileURL
} }
public var storeApp: StoreApp? {
return nil
}
} }
extension StoreApp: AppProtocol extension StoreApp: AppProtocol
@@ -42,6 +50,10 @@ extension StoreApp: AppProtocol
public var url: URL? { public var url: URL? {
return self.latestAvailableVersion?.downloadURL return self.latestAvailableVersion?.downloadURL
} }
public var storeApp: StoreApp? {
return self
}
} }
extension InstalledApp: AppProtocol extension InstalledApp: AppProtocol
@@ -64,4 +76,8 @@ extension AppVersion: AppProtocol
public var url: URL? { public var url: URL? {
return self.downloadURL return self.downloadURL
} }
public var storeApp: StoreApp? {
return self.app
}
} }

View File

@@ -0,0 +1,13 @@
//
// ALTPatreonBenefitType.h
// AltStore
//
// Created by Riley Testut on 8/27/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
#import <Foundation/Foundation.h>
typedef NSString *ALTPatreonBenefitID NS_TYPED_EXTENSIBLE_ENUM;
extern ALTPatreonBenefitID const ALTPatreonBenefitIDBetaAccess;
extern ALTPatreonBenefitID const ALTPatreonBenefitIDCredits;

View File

@@ -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";

View File

@@ -1,13 +0,0 @@
//
// ALTPatreonBenefitType.h
// AltStore
//
// Created by Riley Testut on 8/27/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
#import <Foundation/Foundation.h>
typedef NSString *ALTPatreonBenefitType NS_TYPED_EXTENSIBLE_ENUM;
extern ALTPatreonBenefitType const ALTPatreonBenefitTypeBetaAccess;
extern ALTPatreonBenefitType const ALTPatreonBenefitTypeCredits;

View File

@@ -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";

View File

@@ -10,3 +10,4 @@
typedef NSString *ALTSourceUserInfoKey NS_TYPED_EXTENSIBLE_ENUM; typedef NSString *ALTSourceUserInfoKey NS_TYPED_EXTENSIBLE_ENUM;
extern ALTSourceUserInfoKey const ALTSourceUserInfoKeyPatreonAccessToken; extern ALTSourceUserInfoKey const ALTSourceUserInfoKeyPatreonAccessToken;
extern ALTSourceUserInfoKey const ALTSourceUserInfoKeySkipPatreonDownloads;

View File

@@ -9,3 +9,4 @@
#import "ALTSourceUserInfoKey.h" #import "ALTSourceUserInfoKey.h"
ALTSourceUserInfoKey const ALTSourceUserInfoKeyPatreonAccessToken = @"patreonAccessToken"; ALTSourceUserInfoKey const ALTSourceUserInfoKeyPatreonAccessToken = @"patreonAccessToken";
ALTSourceUserInfoKey const ALTSourceUserInfoKeySkipPatreonDownloads = @"skipPatreonDownloads";

View File

@@ -237,6 +237,8 @@ extension OperationError
case .connectionFailed: return .connectionFailed case .connectionFailed: return .connectionFailed
case .connectionDropped: return .connectionDropped case .connectionDropped: return .connectionDropped
case .forbidden: return .forbidden() case .forbidden: return .forbidden()
case .pledgeRequired: return .pledgeRequired(appName: "Delta")
case .pledgeInactive: return .pledgeInactive(appName: "Delta")
} }
} }
} }