mirror of
https://github.com/SideStore/SideStore.git
synced 2026-04-05 02:05:40 +02:00
Merge branch 'patreon'
This commit is contained in:
@@ -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 */,
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
14
AltStore/Extensions/UTType+AltStore.swift
Normal file
14
AltStore/Extensions/UTType+AltStore.swift
Normal 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")
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
356
AltStoreCore/Components/WebViewController.swift
Normal file
356
AltStoreCore/Components/WebViewController.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
AltStoreCore/Extensions/URL+Normalized.swift
Normal file
60
AltStoreCore/Extensions/URL+Normalized.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
75
AltStoreCore/Model/Patreon/PatreonAccount.swift
Normal file
75
AltStoreCore/Model/Patreon/PatreonAccount.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
54
AltStoreCore/Model/Patreon/Pledge.swift
Normal file
54
AltStoreCore/Model/Patreon/Pledge.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
42
AltStoreCore/Model/Patreon/PledgeReward.swift
Normal file
42
AltStoreCore/Model/Patreon/PledgeReward.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
46
AltStoreCore/Model/Patreon/PledgeTier.swift
Normal file
46
AltStoreCore/Model/Patreon/PledgeTier.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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]?)
|
||||||
|
|||||||
@@ -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>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
161
AltStoreCore/Patreon/PatreonAPI+Responses.swift
Normal file
161
AltStoreCore/Patreon/PatreonAPI+Responses.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
AltStoreCore/Patreon/UserAccount.swift
Normal file
49
AltStoreCore/Patreon/UserAccount.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
AltStoreCore/Types/ALTPatreonBenefitID.h
Normal file
13
AltStoreCore/Types/ALTPatreonBenefitID.h
Normal 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;
|
||||||
12
AltStoreCore/Types/ALTPatreonBenefitID.m
Normal file
12
AltStoreCore/Types/ALTPatreonBenefitID.m
Normal 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";
|
||||||
@@ -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;
|
|
||||||
@@ -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";
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -9,3 +9,4 @@
|
|||||||
#import "ALTSourceUserInfoKey.h"
|
#import "ALTSourceUserInfoKey.h"
|
||||||
|
|
||||||
ALTSourceUserInfoKey const ALTSourceUserInfoKeyPatreonAccessToken = @"patreonAccessToken";
|
ALTSourceUserInfoKey const ALTSourceUserInfoKeyPatreonAccessToken = @"patreonAccessToken";
|
||||||
|
ALTSourceUserInfoKey const ALTSourceUserInfoKeySkipPatreonDownloads = @"skipPatreonDownloads";
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user