Merge branch 'revised_source_json'

# Conflicts:
#	AltStore.xcodeproj/project.pbxproj
#	AltStore/App Detail/AppContentViewController.swift
#	AltStore/App Detail/AppViewController.swift
#	AltStore/Base.lproj/Main.storyboard
#	AltStoreCore/Model/DatabaseManager.swift
This commit is contained in:
Riley Testut
2023-10-19 16:43:50 -05:00
29 changed files with 1869 additions and 482 deletions

View File

@@ -202,7 +202,6 @@
BF989184250AACFC002ACF50 /* Date+RelativeDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB5B1522EE90D300F74113 /* Date+RelativeDate.swift */; };
BF989185250AAD1D002ACF50 /* UIColor+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD2479E2284FBD000981D42 /* UIColor+AltStore.swift */; };
BF9ABA4522DCFF43008935CF /* BrowseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4422DCFF43008935CF /* BrowseViewController.swift */; };
BF9ABA4722DD0638008935CF /* BrowseCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4622DD0638008935CF /* BrowseCollectionViewCell.swift */; };
BF9ABA4922DD0742008935CF /* ScreenshotCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4822DD0742008935CF /* ScreenshotCollectionViewCell.swift */; };
BF9ABA4B22DD1380008935CF /* NavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4A22DD137F008935CF /* NavigationBar.swift */; };
BF9ABA4D22DD16DE008935CF /* PillButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4C22DD16DE008935CF /* PillButton.swift */; };
@@ -285,7 +284,6 @@
BFD52C2122A1A9EC000B7ED1 /* node_list.c in Sources */ = {isa = PBXBuildFile; fileRef = BFD52C1E22A1A9EC000B7ED1 /* node_list.c */; };
BFD52C2222A1A9EC000B7ED1 /* cnary.c in Sources */ = {isa = PBXBuildFile; fileRef = BFD52C1F22A1A9EC000B7ED1 /* cnary.c */; };
BFD6B03322DFF20800B86064 /* MyAppsComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD6B03222DFF20800B86064 /* MyAppsComponents.swift */; };
BFDB5B2622EFBBEA00F74113 /* BrowseCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = BFDB5B2522EFBBEA00F74113 /* BrowseCollectionViewCell.xib */; };
BFDB6A0822AAED73007EA6D6 /* ResignAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0722AAED73007EA6D6 /* ResignAppOperation.swift */; };
BFDB6A0B22AAEDB7007EA6D6 /* Operation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0A22AAEDB7007EA6D6 /* Operation.swift */; };
BFDB6A0D22AAFC1A007EA6D6 /* OperationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0C22AAFC19007EA6D6 /* OperationError.swift */; };
@@ -370,12 +368,15 @@
D54058B92A1D6269008CCC58 /* AppPermissionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54058B82A1D6269008CCC58 /* AppPermissionProtocol.swift */; };
D54058BB2A1D8FE3008CCC58 /* UIColor+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */; };
D540E93828EE1BDE000F1B0F /* ErrorDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D540E93728EE1BDE000F1B0F /* ErrorDetailsViewController.swift */; };
D5418F172AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */; };
D54DED1428CBC44B008B27A0 /* ErrorLogTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */; };
D552B1D82A042A740066216F /* AppPermissionsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D552B1D72A042A740066216F /* AppPermissionsCard.swift */; };
D55467B82A8D5E2600F4CE90 /* AppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */; };
D55467C52A8D72C300F4CE90 /* ActiveAppsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */; };
D561B2EB28EF5A4F006752E4 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; };
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 */; };
D56915092AD5F3E800A2B747 /* AltTests+Sources.swift in Sources */ = {isa = PBXBuildFile; fileRef = D56915082AD5F3E800A2B747 /* AltTests+Sources.swift */; };
D5708417292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; };
D570841A2924680D00D42D34 /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; };
D571ADD02A02FC7200B24B63 /* ALTAppPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = D571ADCF2A02FC7200B24B63 /* ALTAppPermission.swift */; };
@@ -417,14 +418,15 @@
D5A299892AAB9E5900A3988D /* AppProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59A6B7D2AA9226C00F61259 /* AppProcess.swift */; };
D5ACE84528E3B8450021CAB9 /* ClearAppCacheOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */; };
D5B6F6A92AD75D01007EED5A /* ProcessInfo+Previews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */; };
D5B6F6AB2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */; };
D5BA9E9B2A9FE1E8007C0661 /* JITManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5BA9E9A2A9FE1E8007C0661 /* JITManager.swift */; };
D5C0E7672AD9C75900530CA4 /* AppCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C0E7662AD9C75900530CA4 /* AppCardCollectionViewCell.swift */; };
D5C8ACDB2A956B2B00669F92 /* Process+STPrivilegedTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C8ACDA2A956B2B00669F92 /* Process+STPrivilegedTask.swift */; };
D5CA0C4B280E141900469595 /* ManagedPatron.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CA0C4A280E141900469595 /* ManagedPatron.swift */; };
D5CA0C4E280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = D5CA0C4D280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel */; };
D5CD805D29CA2C1E00E591B0 /* HeaderContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CD805C29CA2C1E00E591B0 /* HeaderContentViewController.swift */; };
D5CD805F29CA755E00E591B0 /* SourceDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CD805E29CA755E00E591B0 /* SourceDetailViewController.swift */; };
D5CF568C2A0D8EEC006D93E2 /* VerificationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CF56812A0D83F9006D93E2 /* VerificationError.swift */; };
D5DAE0942804B0B80034D8D4 /* ScreenshotProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */; };
D5DAE0962804DF430034D8D4 /* UpdatePatronsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */; };
D5DB145A28F9DC5A00A8F606 /* ALTLocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */; };
D5DB145B28F9DC5C00A8F606 /* ALTLocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */; };
@@ -435,6 +437,8 @@
D5F48B4C29CD0C48002B52A4 /* AsyncManaged.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F48B4929CD0B67002B52A4 /* AsyncManaged.swift */; };
D5F5AF2E28FDD2EC00C938F5 /* TestErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F5AF2D28FDD2EC00C938F5 /* TestErrors.swift */; };
D5F5AF7D28ECEA990067C736 /* ErrorDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */; };
D5F9821D2AB900060045751F /* AppScreenshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F9821C2AB900060045751F /* AppScreenshot.swift */; };
D5F982212AB910180045751F /* AppScreenshotsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F982202AB910180045751F /* AppScreenshotsViewController.swift */; };
D5F99A1828D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */; };
D5F99A1A28D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */; };
D5FB28EC2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */; };
@@ -834,7 +838,6 @@
BF989190250AAE86002ACF50 /* ViewAppIntentHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewAppIntentHandler.swift; sourceTree = "<group>"; };
BF989191250AAE86002ACF50 /* ViewApp.intentdefinition */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.intentdefinition; path = ViewApp.intentdefinition; sourceTree = "<group>"; };
BF9ABA4422DCFF43008935CF /* BrowseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewController.swift; sourceTree = "<group>"; };
BF9ABA4622DD0638008935CF /* BrowseCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseCollectionViewCell.swift; sourceTree = "<group>"; };
BF9ABA4822DD0742008935CF /* ScreenshotCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotCollectionViewCell.swift; sourceTree = "<group>"; };
BF9ABA4A22DD137F008935CF /* NavigationBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationBar.swift; sourceTree = "<group>"; };
BF9ABA4C22DD16DE008935CF /* PillButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillButton.swift; sourceTree = "<group>"; };
@@ -912,7 +915,6 @@
BFD52C1F22A1A9EC000B7ED1 /* cnary.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; name = cnary.c; path = Dependencies/libplist/libcnary/cnary.c; sourceTree = SOURCE_ROOT; };
BFD6B03222DFF20800B86064 /* MyAppsComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAppsComponents.swift; sourceTree = "<group>"; };
BFDB5B1522EE90D300F74113 /* Date+RelativeDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+RelativeDate.swift"; sourceTree = "<group>"; };
BFDB5B2522EFBBEA00F74113 /* BrowseCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BrowseCollectionViewCell.xib; sourceTree = "<group>"; };
BFDB6A0722AAED73007EA6D6 /* ResignAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResignAppOperation.swift; sourceTree = "<group>"; };
BFDB6A0A22AAEDB7007EA6D6 /* Operation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Operation.swift; sourceTree = "<group>"; };
BFDB6A0C22AAFC19007EA6D6 /* OperationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationError.swift; sourceTree = "<group>"; };
@@ -976,10 +978,13 @@
D54058B82A1D6269008CCC58 /* AppPermissionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionProtocol.swift; sourceTree = "<group>"; };
D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+AltStore.swift"; sourceTree = "<group>"; };
D540E93728EE1BDE000F1B0F /* ErrorDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsViewController.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>"; };
D552B1D72A042A740066216F /* AppPermissionsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionsCard.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>"; };
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>"; };
D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Comparable.swift"; sourceTree = "<group>"; };
D571ADCD2A02FA7400B24B63 /* SourceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceError.swift; sourceTree = "<group>"; };
D571ADCF2A02FC7200B24B63 /* ALTAppPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ALTAppPermission.swift; sourceTree = "<group>"; };
@@ -1020,7 +1025,9 @@
D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeprecatedAPIs.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>"; };
D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewAppScreenshotsViewController.swift; sourceTree = "<group>"; };
D5BA9E9A2A9FE1E8007C0661 /* JITManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JITManager.swift; sourceTree = "<group>"; };
D5C0E7662AD9C75900530CA4 /* AppCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCardCollectionViewCell.swift; sourceTree = "<group>"; };
D5C8ACDA2A956B2B00669F92 /* Process+STPrivilegedTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Process+STPrivilegedTask.swift"; sourceTree = "<group>"; };
D5CA0C4A280E141900469595 /* ManagedPatron.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedPatron.swift; sourceTree = "<group>"; };
D5CA0C4C280E242500469595 /* AltStore 10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 10.xcdatamodel"; sourceTree = "<group>"; };
@@ -1028,7 +1035,6 @@
D5CD805C29CA2C1E00E591B0 /* HeaderContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderContentViewController.swift; sourceTree = "<group>"; };
D5CD805E29CA755E00E591B0 /* SourceDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceDetailViewController.swift; sourceTree = "<group>"; };
D5CF56812A0D83F9006D93E2 /* VerificationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationError.swift; sourceTree = "<group>"; };
D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotProcessor.swift; sourceTree = "<group>"; };
D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePatronsOperation.swift; sourceTree = "<group>"; };
D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ALTLocalizedError.swift; sourceTree = "<group>"; };
D5E1E7C028077DE90016FC96 /* UpdateKnownSourcesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateKnownSourcesOperation.swift; sourceTree = "<group>"; };
@@ -1037,6 +1043,8 @@
D5F48B4929CD0B67002B52A4 /* AsyncManaged.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncManaged.swift; sourceTree = "<group>"; };
D5F5AF2D28FDD2EC00C938F5 /* TestErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestErrors.swift; sourceTree = "<group>"; };
D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsViewController.swift; sourceTree = "<group>"; };
D5F9821C2AB900060045751F /* AppScreenshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshot.swift; sourceTree = "<group>"; };
D5F982202AB910180045751F /* AppScreenshotsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshotsViewController.swift; sourceTree = "<group>"; };
D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = AltStore10ToAltStore11.xcmappingmodel; sourceTree = "<group>"; };
D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreApp10ToStoreApp11Policy.swift; sourceTree = "<group>"; };
D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFontDescriptor+Bold.swift"; sourceTree = "<group>"; };
@@ -1236,7 +1244,6 @@
isa = PBXGroup;
children = (
BF41B807233433C100C593A3 /* LoadingState.swift */,
D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */,
D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */,
);
path = Types;
@@ -1250,6 +1257,7 @@
BF3D64AF22E8D4B800E9056B /* AppContentViewControllerCells.swift */,
D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */,
D552B1D72A042A740066216F /* AppPermissionsCard.swift */,
D5418F152AD740750014ABD6 /* Screenshots */,
);
path = "App Detail";
sourceTree = "<group>";
@@ -1521,6 +1529,7 @@
BF66EEC92501AECA007EE018 /* Account.swift */,
BF66EEC72501AECA007EE018 /* AppID.swift */,
BF66EEC62501AECA007EE018 /* AppPermission.swift */,
D5F9821C2AB900060045751F /* AppScreenshot.swift */,
D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */,
BF66EECA2501AECA007EE018 /* DatabaseManager.swift */,
D5FD4ECA2A9532960097BEE8 /* DatabaseManager+Async.swift */,
@@ -1591,6 +1600,7 @@
D5F48B4729CCF21B002B52A4 /* AltStore+Async.swift */,
D5893F7E2A14183200E767CD /* NSManagedObjectContext+Conveniences.swift */,
D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */,
D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */,
D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */,
);
path = Extensions;
@@ -1687,8 +1697,6 @@
isa = PBXGroup;
children = (
BF9ABA4422DCFF43008935CF /* BrowseViewController.swift */,
BF9ABA4622DD0638008935CF /* BrowseCollectionViewCell.swift */,
BFDB5B2522EFBBEA00F74113 /* BrowseCollectionViewCell.xib */,
BF9ABA4822DD0742008935CF /* ScreenshotCollectionViewCell.swift */,
);
path = Browse;
@@ -1872,6 +1880,7 @@
BF6C8FAD2429597900125131 /* AppBannerCollectionViewCell.swift */,
BF6C8FAF2429599900125131 /* TextCollectionReusableView.swift */,
D5CD805C29CA2C1E00E591B0 /* HeaderContentViewController.swift */,
D5C0E7662AD9C75900530CA4 /* AppCardCollectionViewCell.swift */,
);
path = Components;
sourceTree = "<group>";
@@ -2132,6 +2141,16 @@
path = Previews;
sourceTree = "<group>";
};
D5418F152AD740750014ABD6 /* Screenshots */ = {
isa = PBXGroup;
children = (
D5F982202AB910180045751F /* AppScreenshotsViewController.swift */,
D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */,
D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */,
);
path = Screenshots;
sourceTree = "<group>";
};
D55467B02A8D5E2600F4CE90 /* App Intents */ = {
isa = PBXGroup;
children = (
@@ -2172,6 +2191,7 @@
isa = PBXGroup;
children = (
D586D39A28EF58B0000E101F /* AltTests.swift */,
D56915082AD5F3E800A2B747 /* AltTests+Sources.swift */,
D5F5AF2D28FDD2EC00C938F5 /* TestErrors.swift */,
);
path = AltTests;
@@ -2700,7 +2720,6 @@
BFF0B6922321A305007A79E1 /* AboutPatreonHeaderView.xib in Resources */,
BFB6B22423187A3D0022A802 /* NewsCollectionViewCell.xib in Resources */,
BFD247752284B9A500981D42 /* Main.storyboard in Resources */,
BFDB5B2622EFBBEA00F74113 /* BrowseCollectionViewCell.xib in Resources */,
BFE6073C231AE1E7002B0E8E /* SettingsHeaderFooterView.xib in Resources */,
BF29012F2318F6B100D88A45 /* AppBannerView.xib in Resources */,
BFE6325A22A83BEB00F30809 /* Authentication.storyboard in Resources */,
@@ -3017,6 +3036,7 @@
BF66EEE82501AED0007EE018 /* UserDefaults+AltStore.swift in Sources */,
BF340E9A250AD39500A192CB /* ViewApp.intentdefinition in Sources */,
BFAECC522501B0A400528F27 /* CodableError.swift in Sources */,
D5F9821D2AB900060045751F /* AppScreenshot.swift in Sources */,
BF66EE9E2501AEC1007EE018 /* Fetchable.swift in Sources */,
BF66EEDF2501AECA007EE018 /* PatreonAccount.swift in Sources */,
BFAECC532501B0A400528F27 /* ServerProtocol.swift in Sources */,
@@ -3065,6 +3085,7 @@
D5F99A1A28D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift in Sources */,
D5177B0D2A26944600270065 /* AltStore12ToAltStore13.xcmappingmodel in Sources */,
BFAECC562501B0A400528F27 /* ALTServerError+Conveniences.swift in Sources */,
D56915072AD5E91B00A2B747 /* Regex+Permissions.swift in Sources */,
BFAECC592501B0A400528F27 /* Result+Conveniences.swift in Sources */,
D571ADD02A02FC7200B24B63 /* ALTAppPermission.swift in Sources */,
D5E3FB9828FDFAD90034B72C /* NSError+AltStore.swift in Sources */,
@@ -3135,6 +3156,7 @@
BF3BEFC124086A1E00DE7D55 /* RefreshAppOperation.swift in Sources */,
BFE60740231AFD2A002B0E8E /* InsetGroupTableViewCell.swift in Sources */,
BF0DCA662433BDF500E3A595 /* AnalyticsManager.swift in Sources */,
D5418F172AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift in Sources */,
BFCCB51A245E3401001853EA /* VerifyAppOperation.swift in Sources */,
BFF0B6982322CAB8007A79E1 /* InstructionsViewController.swift in Sources */,
BF9ABA4522DCFF43008935CF /* BrowseViewController.swift in Sources */,
@@ -3146,7 +3168,7 @@
BFF0B68E23219520007A79E1 /* PatreonViewController.swift in Sources */,
BFF00D302501BD7D00746320 /* Intents.intentdefinition in Sources */,
D5CF568C2A0D8EEC006D93E2 /* VerificationError.swift in Sources */,
D5DAE0942804B0B80034D8D4 /* ScreenshotProcessor.swift in Sources */,
D5B6F6AB2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift in Sources */,
BFD2476E2284B9A500981D42 /* AppDelegate.swift in Sources */,
BF41B806233423AE00C593A3 /* TabBarController.swift in Sources */,
BFE00A202503097F00EB4D0C /* INInteraction+AltStore.swift in Sources */,
@@ -3162,7 +3184,6 @@
BFE60742231B07E6002B0E8E /* SettingsHeaderFooterView.swift in Sources */,
BFE338E822F10E56002E24B9 /* LaunchViewController.swift in Sources */,
BFA8172B23C5633D001B5953 /* FetchAnisetteDataOperation.swift in Sources */,
BF9ABA4722DD0638008935CF /* BrowseCollectionViewCell.swift in Sources */,
D552B1D82A042A740066216F /* AppPermissionsCard.swift in Sources */,
BFD6B03322DFF20800B86064 /* MyAppsComponents.swift in Sources */,
BF41B808233433C100C593A3 /* LoadingState.swift in Sources */,
@@ -3200,6 +3221,7 @@
BF3432FB246B894F0052F4A1 /* BackupAppOperation.swift in Sources */,
BF9ABA4922DD0742008935CF /* ScreenshotCollectionViewCell.swift in Sources */,
BF9ABA4D22DD16DE008935CF /* PillButton.swift in Sources */,
D5C0E7672AD9C75900530CA4 /* AppCardCollectionViewCell.swift in Sources */,
D5DAE0962804DF430034D8D4 /* UpdatePatronsOperation.swift in Sources */,
D54058BB2A1D8FE3008CCC58 /* UIColor+AltStore.swift in Sources */,
BFE6326C22A86FF300F30809 /* AuthenticationOperation.swift in Sources */,
@@ -3213,6 +3235,7 @@
BFB3645A2325985F00CD0EB1 /* FindServerOperation.swift in Sources */,
D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */,
BF3BEFBF2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift in Sources */,
D5F982212AB910180045751F /* AppScreenshotsViewController.swift in Sources */,
BFF0B69023219C6D007A79E1 /* PatreonComponents.swift in Sources */,
BFBE0004250ACFFB0080826E /* ViewApp.intentdefinition in Sources */,
BF770E5622BC3C03002A40FE /* Server.swift in Sources */,
@@ -3237,6 +3260,7 @@
buildActionMask = 2147483647;
files = (
D586D39B28EF58B0000E101F /* AltTests.swift in Sources */,
D56915092AD5F3E800A2B747 /* AltTests+Sources.swift in Sources */,
D5F5AF2E28FDD2EC00C938F5 /* TestErrors.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View File

@@ -29,7 +29,12 @@ class AppContentViewController: UITableViewController
{
var app: StoreApp!
private lazy var screenshotsDataSource = self.makeScreenshotsDataSource()
private lazy var dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
dateFormatter.timeStyle = .none
return dateFormatter
}()
private lazy var byteCountFormatter: ByteCountFormatter = {
let formatter = ByteCountFormatter()
@@ -43,32 +48,17 @@ class AppContentViewController: UITableViewController
@IBOutlet private var versionDateLabel: UILabel!
@IBOutlet private var sizeLabel: UILabel!
@IBOutlet private var screenshotsCollectionView: UICollectionView!
@IBOutlet private(set) var appScreenshotsViewController: AppScreenshotsViewController!
@IBOutlet private var appScreenshotsHeightConstraint: NSLayoutConstraint!
@IBOutlet private(set) var appDetailCollectionViewController: AppDetailCollectionViewController!
@IBOutlet private var appDetailCollectionViewHeightConstraint: NSLayoutConstraint!
var preferredScreenshotSize: CGSize? {
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
let aspectRatio: CGFloat = 16.0 / 9.0 // Hardcoded for now.
let width = self.screenshotsCollectionView.bounds.width - (layout.minimumInteritemSpacing * 2)
let itemWidth = width / 1.5
let itemHeight = itemWidth * aspectRatio
return CGSize(width: itemWidth, height: itemHeight)
}
override func viewDidLoad()
{
super.viewDidLoad()
self.tableView.contentInset.bottom = 20
self.screenshotsCollectionView.dataSource = self.screenshotsDataSource
self.screenshotsCollectionView.prefetchDataSource = self.screenshotsDataSource
self.subtitleLabel.text = self.app.subtitle
self.descriptionTextView.text = self.app.localizedDescription
@@ -99,17 +89,24 @@ class AppContentViewController: UITableViewController
{
super.viewDidLayoutSubviews()
guard var size = self.preferredScreenshotSize else { return }
size.height = min(size.height, self.screenshotsCollectionView.bounds.height) // Silence temporary "item too tall" warning.
var needsTableViewUpdate = false
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
layout.itemSize = size
let screenshotsHeight = self.appScreenshotsViewController.collectionView.contentSize.height
if self.appScreenshotsHeightConstraint.constant != screenshotsHeight && screenshotsHeight > 0
{
self.appScreenshotsHeightConstraint.constant = screenshotsHeight
needsTableViewUpdate = true
}
let permissionsHeight = self.appDetailCollectionViewController.collectionView.contentSize.height
if self.appDetailCollectionViewHeightConstraint.constant != permissionsHeight && permissionsHeight > 0
{
self.appDetailCollectionViewHeightConstraint.constant = permissionsHeight
needsTableViewUpdate = true
}
if needsTableViewUpdate
{
UIView.performWithoutAnimation {
// Update row height without animation.
self.tableView.beginUpdates()
@@ -121,40 +118,12 @@ class AppContentViewController: UITableViewController
private extension AppContentViewController
{
func makeScreenshotsDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>
@IBSegueAction
func makeAppScreenshotsViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
{
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: self.app.screenshotURLs as [NSURL])
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
let cell = cell as! ScreenshotCollectionViewCell
cell.imageView.image = nil
cell.imageView.isIndicatingActivity = true
}
dataSource.prefetchHandler = { (imageURL, indexPath, completionHandler) in
return RSTAsyncBlockOperation() { (operation) in
let request = ImageRequest(url: imageURL as URL, processors: [.screenshot])
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
guard !operation.isCancelled else { return operation.finish() }
switch result
{
case .success(let response): completionHandler(response.image, nil)
case .failure(let error): completionHandler(nil, error)
}
}
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! ScreenshotCollectionViewCell
cell.imageView.isIndicatingActivity = false
cell.imageView.image = image
if let error = error
{
print("Error loading image:", error)
}
}
return dataSource
let appScreenshotsViewController = AppScreenshotsViewController(app: self.app, coder: coder)
self.appScreenshotsViewController = appScreenshotsViewController
return appScreenshotsViewController
}
@IBSegueAction
@@ -198,8 +167,8 @@ extension AppContentViewController
switch Row.allCases[indexPath.row]
{
case .screenshots:
guard let size = self.preferredScreenshotSize else { return 0.0 }
return size.height
guard !self.app.allScreenshots.isEmpty else { return 0.0 }
return UITableView.automaticDimension
case .permissions:
guard !self.app.permissions.isEmpty else { return 0.0 }

View File

@@ -142,6 +142,14 @@ class AppViewController: UIViewController
self.view.layoutIfNeeded()
}
override func viewIsAppearing(_ animated: Bool)
{
super.viewIsAppearing(animated)
// Prevent banner temporarily flashing a color due to being added back to self.view.
self.bannerView.backgroundEffectView.backgroundColor = .clear
}
override func viewDidAppear(_ animated: Bool)
{
super.viewDidAppear(animated)
@@ -197,7 +205,7 @@ class AppViewController: UIViewController
{
statusBarHeight = 20
}
else if let statusBarManager = self.view.window?.windowScene?.statusBarManager
else if let statusBarManager = (self.view.window ?? self.presentedViewController?.view.window)?.windowScene?.statusBarManager
{
statusBarHeight = statusBarManager.statusBarFrame.height
}

View File

@@ -0,0 +1,155 @@
//
// AppScreenshotCollectionViewCell.swift
// AltStore
//
// Created by Riley Testut on 10/11/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import UIKit
import AltStoreCore
extension AppScreenshotCollectionViewCell
{
private class ImageView: UIImageView
{
override func layoutSubviews()
{
super.layoutSubviews()
// Explicitly layout cell to ensure rounded corners are accurate.
self.superview?.superview?.setNeedsLayout()
}
}
}
class AppScreenshotCollectionViewCell: UICollectionViewCell
{
let imageView: UIImageView
var aspectRatio: CGSize = AppScreenshot.defaultAspectRatio {
didSet {
self.updateAspectRatio()
}
}
private var isRounded: Bool = false {
didSet {
self.setNeedsLayout()
self.layoutIfNeeded()
}
}
private var aspectRatioConstraint: NSLayoutConstraint?
override init(frame: CGRect)
{
self.imageView = ImageView(frame: .zero)
self.imageView.clipsToBounds = true
self.imageView.layer.cornerCurve = .continuous
self.imageView.layer.borderColor = UIColor.tertiaryLabel.cgColor
super.init(frame: frame)
self.imageView.translatesAutoresizingMaskIntoConstraints = false
self.contentView.addSubview(self.imageView)
let widthConstraint = self.imageView.widthAnchor.constraint(equalTo: self.contentView.widthAnchor)
widthConstraint.priority = .defaultHigh
let heightConstraint = self.imageView.heightAnchor.constraint(equalTo: self.contentView.heightAnchor)
heightConstraint.priority = .defaultHigh
NSLayoutConstraint.activate([
widthConstraint,
heightConstraint,
self.imageView.widthAnchor.constraint(lessThanOrEqualTo: self.contentView.widthAnchor),
self.imageView.heightAnchor.constraint(lessThanOrEqualTo: self.contentView.heightAnchor),
self.imageView.centerXAnchor.constraint(equalTo: self.contentView.centerXAnchor),
self.imageView.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor)
])
self.updateAspectRatio()
self.updateTraits()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?)
{
super.traitCollectionDidChange(previousTraitCollection)
self.updateTraits()
}
override func layoutSubviews()
{
super.layoutSubviews()
if self.isRounded
{
let cornerRadius = self.imageView.bounds.width / 9.0 // Based on iPhone 15
self.imageView.layer.cornerRadius = cornerRadius
}
else
{
let cornerRadius = self.imageView.bounds.width / 25.0 // Based on iPhone 8
self.imageView.layer.cornerRadius = cornerRadius
}
}
}
extension AppScreenshotCollectionViewCell
{
func setImage(_ image: UIImage?)
{
guard var image, let cgImage = image.cgImage else {
self.imageView.image = image
return
}
if image.size.width > image.size.height && self.aspectRatio.width < self.aspectRatio.height
{
// Image is landscape, but cell has portrait aspect ratio, so rotate image to match.
image = UIImage(cgImage: cgImage, scale: image.scale, orientation: .right)
}
self.imageView.image = image
}
}
private extension AppScreenshotCollectionViewCell
{
func updateAspectRatio()
{
self.aspectRatioConstraint?.isActive = false
self.aspectRatioConstraint = self.imageView.widthAnchor.constraint(equalTo: self.imageView.heightAnchor, multiplier: self.aspectRatio.width / self.aspectRatio.height)
self.aspectRatioConstraint?.isActive = true
let aspectRatio: Double
if self.aspectRatio.width > self.aspectRatio.height
{
aspectRatio = self.aspectRatio.height / self.aspectRatio.width
}
else
{
aspectRatio = self.aspectRatio.width / self.aspectRatio.height
}
let tolerance = 0.001 as Double
let modernAspectRatio = AppScreenshot.defaultAspectRatio.width / AppScreenshot.defaultAspectRatio.height
let isRounded = (aspectRatio >= modernAspectRatio - tolerance) && (aspectRatio <= modernAspectRatio + tolerance)
self.isRounded = isRounded
}
func updateTraits()
{
let displayScale = (self.traitCollection.displayScale == 0.0) ? 1.0 : self.traitCollection.displayScale
self.imageView.layer.borderWidth = 1.0 / displayScale
}
}

View File

@@ -0,0 +1,186 @@
//
// AppScreenshotsViewController.swift
// AltStore
//
// Created by Riley Testut on 9/18/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import UIKit
import AltStoreCore
import Roxas
import Nuke
class AppScreenshotsViewController: UICollectionViewController
{
let app: StoreApp
private lazy var dataSource = self.makeDataSource()
init?(app: StoreApp, coder: NSCoder)
{
self.app = app
super.init(coder: coder)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad()
{
super.viewDidLoad()
self.collectionView.showsHorizontalScrollIndicator = false
// Allow parent background color to show through.
self.collectionView.backgroundColor = nil
// Match the parent table view margins.
self.collectionView.directionalLayoutMargins.top = 0
self.collectionView.directionalLayoutMargins.bottom = 0
self.collectionView.directionalLayoutMargins.leading = 20
self.collectionView.directionalLayoutMargins.trailing = 20
let collectionViewLayout = self.makeLayout()
self.collectionView.collectionViewLayout = collectionViewLayout
self.collectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
self.collectionView.dataSource = self.dataSource
self.collectionView.prefetchDataSource = self.dataSource
}
}
private extension AppScreenshotsViewController
{
func makeLayout() -> UICollectionViewCompositionalLayout
{
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
layoutConfig.contentInsetsReference = .layoutMargins
let preferredHeight = 400.0
let estimatedWidth = preferredHeight * (AppScreenshot.defaultAspectRatio.width / AppScreenshot.defaultAspectRatio.height)
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [dataSource] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
let screenshotWidths = dataSource.items.map { screenshot in
var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio
if aspectRatio.width > aspectRatio.height
{
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
}
let screenshotWidth = (preferredHeight * (aspectRatio.width / aspectRatio.height)).rounded()
return screenshotWidth
}
let smallestWidth = screenshotWidths.sorted().first
let itemWidth = smallestWidth ?? estimatedWidth // Use smallestWidth to ensure we never overshoot an item when paging.
let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(itemWidth), heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .estimated(itemWidth), heightDimension: .absolute(preferredHeight))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.interGroupSpacing = 10
layoutSection.orthogonalScrollingBehavior = .groupPaging
return layoutSection
}, configuration: layoutConfig)
return layout
}
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>
{
let screenshots = self.app.preferredScreenshots()
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>(items: screenshots)
dataSource.cellConfigurationHandler = { [weak self] (cell, screenshot, indexPath) in
let cell = cell as! AppScreenshotCollectionViewCell
cell.imageView.isIndicatingActivity = true
cell.setImage(nil)
var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio
if aspectRatio.width > aspectRatio.height
{
switch screenshot.deviceType
{
case .iphone:
// Always rotate landscape iPhone screenshots regardless of horizontal size class.
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
case .ipad where self?.traitCollection.horizontalSizeClass == .compact:
// Only rotate landscape iPad screenshots if we're in horizontally compact environment.
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
default: break
}
}
cell.aspectRatio = aspectRatio
}
dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in
let imageURL = screenshot.imageURL
return RSTAsyncBlockOperation() { (operation) in
let request = ImageRequest(url: imageURL)
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
guard !operation.isCancelled else { return operation.finish() }
switch result
{
case .success(let response): completionHandler(response.image, nil)
case .failure(let error): completionHandler(nil, error)
}
}
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! AppScreenshotCollectionViewCell
cell.imageView.isIndicatingActivity = false
cell.setImage(image)
if let error = error
{
print("Error loading image:", error)
}
}
return dataSource
}
}
extension AppScreenshotsViewController
{
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
{
let screenshot = self.dataSource.item(at: indexPath)
let previewViewController = PreviewAppScreenshotsViewController(app: self.app)
previewViewController.currentScreenshot = screenshot
let navigationController = UINavigationController(rootViewController: previewViewController)
navigationController.modalPresentationStyle = .fullScreen
self.present(navigationController, animated: true)
}
}
@available(iOS 17, *)
#Preview(traits: .portrait) {
DatabaseManager.shared.startForPreview()
let fetchRequest = StoreApp.fetchRequest()
let storeApp = try! DatabaseManager.shared.viewContext.fetch(fetchRequest).first!
let storyboard = UIStoryboard(name: "Main", bundle: .main)
let appViewConttroller = storyboard.instantiateViewController(withIdentifier: "appViewController") as! AppViewController
appViewConttroller.app = storeApp
let navigationController = UINavigationController(rootViewController: appViewConttroller)
return navigationController
}

View File

@@ -0,0 +1,177 @@
//
// PreviewAppScreenshotsViewController.swift
// AltStore
//
// Created by Riley Testut on 9/19/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import UIKit
import AltStoreCore
import Roxas
import Nuke
class PreviewAppScreenshotsViewController: UICollectionViewController
{
let app: StoreApp
var currentScreenshot: AppScreenshot?
private lazy var dataSource = self.makeDataSource()
init(app: StoreApp)
{
self.app = app
super.init(collectionViewLayout: UICollectionViewFlowLayout())
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad()
{
super.viewDidLoad()
let tintColor = self.app.tintColor ?? .altPrimary
self.navigationController?.view.tintColor = tintColor
self.view.backgroundColor = .systemBackground
self.collectionView.backgroundColor = nil
let collectionViewLayout = self.makeLayout()
self.collectionView.collectionViewLayout = collectionViewLayout
self.collectionView.directionalLayoutMargins.leading = 20
self.collectionView.directionalLayoutMargins.trailing = 20
self.collectionView.preservesSuperviewLayoutMargins = true
self.collectionView.insetsLayoutMarginsFromSafeArea = true
self.collectionView.alwaysBounceVertical = false
self.collectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
self.collectionView.dataSource = self.dataSource
self.collectionView.prefetchDataSource = self.dataSource
let doneButton = UIBarButtonItem(systemItem: .done, primaryAction: UIAction { [weak self] _ in
self?.dismiss(animated: true)
})
self.navigationItem.rightBarButtonItem = doneButton
}
override func viewIsAppearing(_ animated: Bool)
{
super.viewIsAppearing(animated)
if let screenshot = self.currentScreenshot, let index = self.dataSource.items.firstIndex(of: screenshot)
{
let indexPath = IndexPath(item: index, section: 0)
self.collectionView.scrollToItem(at: indexPath, at: .centeredHorizontally, animated: false)
}
}
}
private extension PreviewAppScreenshotsViewController
{
func makeLayout() -> UICollectionViewCompositionalLayout
{
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
layoutConfig.contentInsetsReference = .none
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
guard let self else { return nil }
let contentInsets = self.collectionView.directionalLayoutMargins
let groupWidth = layoutEnvironment.container.contentSize.width - (contentInsets.leading + contentInsets.trailing)
let groupHeight = layoutEnvironment.container.contentSize.height - (contentInsets.top + contentInsets.bottom)
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(groupWidth), heightDimension: .absolute(groupHeight))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
layoutSection.interGroupSpacing = 10
return layoutSection
}, configuration: layoutConfig)
return layout
}
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>
{
let screenshots = self.app.preferredScreenshots()
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>(items: screenshots)
dataSource.cellConfigurationHandler = { [weak self] (cell, screenshot, indexPath) in
let cell = cell as! AppScreenshotCollectionViewCell
cell.imageView.isIndicatingActivity = true
cell.setImage(nil)
var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio
if aspectRatio.width > aspectRatio.height
{
switch screenshot.deviceType
{
case .iphone:
// Always rotate landscape iPhone screenshots regardless of horizontal size class.
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
case .ipad where self?.traitCollection.horizontalSizeClass == .compact:
// Only rotate landscape iPad screenshots if we're in horizontally compact environment.
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
default: break
}
}
cell.aspectRatio = aspectRatio
}
dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in
let imageURL = screenshot.imageURL
return RSTAsyncBlockOperation() { (operation) in
let request = ImageRequest(url: imageURL)
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
guard !operation.isCancelled else { return operation.finish() }
switch result
{
case .success(let response): completionHandler(response.image, nil)
case .failure(let error): completionHandler(nil, error)
}
}
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! AppScreenshotCollectionViewCell
cell.imageView.isIndicatingActivity = false
cell.setImage(image)
if let error = error
{
print("Error loading image:", error)
}
}
return dataSource
}
}
@available(iOS 17, *)
#Preview(traits: .portrait) {
DatabaseManager.shared.startForPreview()
let fetchRequest = StoreApp.fetchRequest()
let storeApp = try! DatabaseManager.shared.viewContext.fetch(fetchRequest).first!
let previewViewController = PreviewAppScreenshotsViewController(app: storeApp)
let navigationController = UINavigationController(rootViewController: previewViewController)
return navigationController
}

View File

@@ -49,7 +49,7 @@
<!--Browse-->
<scene sceneID="rXq-UR-qQp">
<objects>
<collectionViewController id="e3L-BF-iXp" customClass="BrowseViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionViewController storyboardIdentifier="browseViewController" id="e3L-BF-iXp" customClass="BrowseViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" dataMode="prototypes" id="CaT-1q-qnx">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
@@ -252,45 +252,34 @@
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="nI6-wC-H2d">
<rect key="frame" x="0.0" y="107" width="375" height="44"/>
<rect key="frame" x="0.0" y="107" width="375" height="300"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="nI6-wC-H2d" id="Z4y-vb-Z4Q">
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" dataMode="prototypes" translatesAutoresizingMaskIntoConstraints="NO" id="ppk-lL-at8">
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
<color key="backgroundColor" name="Background"/>
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" minimumLineSpacing="10" minimumInteritemSpacing="15" id="ace-Ns-Jd2">
<size key="itemSize" width="189" height="406"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/>
<inset key="sectionInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
</collectionViewFlowLayout>
<cells>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="Cell" id="2U6-d3-e4r" customClass="ScreenshotCollectionViewCell">
<rect key="frame" x="15" y="-181" width="189" height="406"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
<rect key="frame" x="0.0" y="0.0" width="189" height="406"/>
<autoresizingMask key="autoresizingMask"/>
</view>
</collectionViewCell>
</cells>
</collectionView>
<containerView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5yj-Nb-f5H">
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
<constraints>
<constraint firstAttribute="height" priority="999" constant="300" id="dpf-ba-NNr"/>
</constraints>
<connections>
<segue destination="nX2-hQ-qjX" kind="embed" destinationCreationSelector="makeAppScreenshotsViewController:sender:" id="VxG-Pu-Kf1"/>
</connections>
</containerView>
</subviews>
<constraints>
<constraint firstAttribute="trailing" secondItem="ppk-lL-at8" secondAttribute="trailing" id="3QR-Y2-v26"/>
<constraint firstAttribute="bottom" secondItem="ppk-lL-at8" secondAttribute="bottom" id="EgJ-Uw-5ta"/>
<constraint firstItem="ppk-lL-at8" firstAttribute="leading" secondItem="Z4y-vb-Z4Q" secondAttribute="leading" id="wHf-S9-gMV"/>
<constraint firstItem="ppk-lL-at8" firstAttribute="top" secondItem="Z4y-vb-Z4Q" secondAttribute="top" id="xY5-w8-roA"/>
<constraint firstAttribute="trailing" secondItem="5yj-Nb-f5H" secondAttribute="trailing" id="2DI-44-pC1"/>
<constraint firstItem="5yj-Nb-f5H" firstAttribute="top" secondItem="Z4y-vb-Z4Q" secondAttribute="top" id="URh-5T-73x"/>
<constraint firstAttribute="bottom" secondItem="5yj-Nb-f5H" secondAttribute="bottom" id="Yb6-aZ-qNF"/>
<constraint firstItem="5yj-Nb-f5H" firstAttribute="leading" secondItem="Z4y-vb-Z4Q" secondAttribute="leading" id="rpG-Ip-qZU"/>
</constraints>
</tableViewCellContentView>
<color key="backgroundColor" name="Background"/>
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="EL5-UC-RIw" customClass="AppContentTableViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="151" width="375" height="98"/>
<rect key="frame" x="0.0" y="407" width="375" height="98"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="EL5-UC-RIw" id="D1G-nK-G0Z">
<rect key="frame" x="0.0" y="0.0" width="375" height="98"/>
@@ -314,7 +303,7 @@
<inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="47M-El-a4G" customClass="AppContentTableViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="249" width="375" height="137.5"/>
<rect key="frame" x="0.0" y="505" width="375" height="137.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="47M-El-a4G" id="f9D-OR-oGE">
<rect key="frame" x="0.0" y="0.0" width="375" height="137.5"/>
@@ -383,7 +372,7 @@
<inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" rowHeight="300" id="nM7-vJ-W8b">
<rect key="frame" x="0.0" y="386.5" width="375" height="300"/>
<rect key="frame" x="0.0" y="642.5" width="375" height="300"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="nM7-vJ-W8b" id="cQ2-Jd-pRK">
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
@@ -437,8 +426,8 @@
<size key="freeformSize" width="375" height="667"/>
<connections>
<outlet property="appDetailCollectionViewHeightConstraint" destination="HFx-PP-dAt" id="ti3-q6-ku1"/>
<outlet property="appScreenshotsHeightConstraint" destination="dpf-ba-NNr" id="shO-Kq-Y90"/>
<outlet property="descriptionTextView" destination="Pyt-8D-BZA" id="cgV-Hg-LrH"/>
<outlet property="screenshotsCollectionView" destination="ppk-lL-at8" id="YoQ-Z6-WTP"/>
<outlet property="sizeLabel" destination="DgM-bD-bBY" id="Oky-ax-u20"/>
<outlet property="subtitleLabel" destination="BsL-O2-UjD" id="cfe-cf-4a9"/>
<outlet property="versionDateLabel" destination="wGD-mS-8fO" id="icB-lC-g9x"/>
@@ -450,6 +439,31 @@
</objects>
<point key="canvasLocation" x="3302" y="-18"/>
</scene>
<!--App Screenshots View Controller-->
<scene sceneID="E6k-TI-c4N">
<objects>
<collectionViewController storyboardIdentifier="appScreenshotsViewController" id="nX2-hQ-qjX" customClass="AppScreenshotsViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" contentInsetAdjustmentBehavior="never" dataMode="prototypes" id="zXl-if-KtH">
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" automaticEstimatedItemSize="YES" minimumLineSpacing="10" minimumInteritemSpacing="10" id="MGS-YY-5g9">
<size key="itemSize" width="150" height="300"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/>
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
</collectionViewFlowLayout>
<cells/>
<connections>
<outlet property="dataSource" destination="nX2-hQ-qjX" id="QRj-01-ddR"/>
<outlet property="delegate" destination="nX2-hQ-qjX" id="Ha5-Xa-Q6e"/>
</connections>
</collectionView>
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="np0-Hj-vy7" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="4096.8000000000002" y="-437.18140929535235"/>
</scene>
<!--App Detail Collection View Controller-->
<scene sceneID="Pcn-h5-5fk">
<objects>
@@ -468,7 +482,7 @@
</collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="fxm-bB-W29" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="4070" y="-18"/>
<point key="canvasLocation" x="4097" y="-19"/>
</scene>
<!--Settings-->
<scene sceneID="KlD-j0-ROn">

View File

@@ -1,96 +0,0 @@
//
// BrowseCollectionViewCell.swift
// AltStore
//
// Created by Riley Testut on 7/15/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import UIKit
import Roxas
import Nuke
@objc class BrowseCollectionViewCell: UICollectionViewCell
{
var imageURLs: [URL] = [] {
didSet {
self.dataSource.items = self.imageURLs as [NSURL]
}
}
private lazy var dataSource = self.makeDataSource()
@IBOutlet var bannerView: AppBannerView!
@IBOutlet var subtitleLabel: UILabel!
@IBOutlet private(set) var screenshotsCollectionView: UICollectionView!
override func awakeFromNib()
{
super.awakeFromNib()
self.contentView.preservesSuperviewLayoutMargins = true
// Must be registered programmatically, not in BrowseCollectionViewCell.xib, or else it'll throw an exception 🤷.
self.screenshotsCollectionView.register(ScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
self.screenshotsCollectionView.delegate = self
self.screenshotsCollectionView.dataSource = self.dataSource
self.screenshotsCollectionView.prefetchDataSource = self.dataSource
}
}
private extension BrowseCollectionViewCell
{
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>
{
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: [])
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
let cell = cell as! ScreenshotCollectionViewCell
cell.imageView.image = nil
cell.imageView.isIndicatingActivity = true
}
dataSource.prefetchHandler = { (imageURL, indexPath, completionHandler) in
return RSTAsyncBlockOperation() { (operation) in
let request = ImageRequest(url: imageURL as URL, processors: [.screenshot])
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
guard !operation.isCancelled else { return operation.finish() }
switch result
{
case .success(let response): completionHandler(response.image, nil)
case .failure(let error): completionHandler(nil, error)
}
}
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! ScreenshotCollectionViewCell
cell.imageView.isIndicatingActivity = false
cell.imageView.image = image
if let error = error
{
print("Error loading image:", error)
}
}
return dataSource
}
}
extension BrowseCollectionViewCell: UICollectionViewDelegateFlowLayout
{
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
{
// Assuming 9.0 / 16.0 ratio for now.
let aspectRatio: CGFloat = 9.0 / 16.0
let itemHeight = collectionView.bounds.height
let itemWidth = itemHeight * aspectRatio
let size = CGSize(width: itemWidth.rounded(.down), height: itemHeight.rounded(.down))
return size
}
}

View File

@@ -1,64 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15404"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" reuseIdentifier="Cell" id="ln4-pC-7KY" customClass="BrowseCollectionViewCell" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="369"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
<rect key="frame" x="0.0" y="0.0" width="375" height="369"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<stackView verifyAmbiguity="off" opaque="NO" contentMode="scaleToFill" ambiguous="YES" axis="vertical" spacing="15" translatesAutoresizingMaskIntoConstraints="NO" id="5gU-g3-Fsy">
<rect key="frame" x="16" y="0.0" width="343" height="369"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ziA-mP-AY2" customClass="AppBannerView" customModule="AltStore" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="343" height="88"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstAttribute="height" constant="88" id="z3D-cI-jhp"/>
</constraints>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Classic Nintendo games in your pocket." textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumScaleFactor="0.5" translatesAutoresizingMaskIntoConstraints="NO" id="Imx-Le-bcy">
<rect key="frame" x="0.0" y="103" width="343" height="17"/>
<fontDescription key="fontDescription" type="system" pointSize="14"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" userInteractionEnabled="NO" contentMode="scaleToFill" scrollEnabled="NO" contentInsetAdjustmentBehavior="never" dataMode="none" translatesAutoresizingMaskIntoConstraints="NO" id="RFs-qp-Ca4">
<rect key="frame" x="0.0" y="135" width="343" height="234"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="0.0" minimumInteritemSpacing="15" id="jH9-Jo-IHA">
<size key="itemSize" width="120" height="213"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" width="0.0" height="0.0"/>
<inset key="sectionInset" minX="8" minY="0.0" maxX="8" maxY="0.0"/>
</collectionViewFlowLayout>
<cells/>
</collectionView>
</subviews>
</stackView>
</subviews>
</view>
<constraints>
<constraint firstItem="5gU-g3-Fsy" firstAttribute="top" secondItem="ln4-pC-7KY" secondAttribute="top" id="DnT-vq-BOc"/>
<constraint firstItem="5gU-g3-Fsy" firstAttribute="leading" secondItem="ln4-pC-7KY" secondAttribute="leadingMargin" id="YPy-xL-iUn"/>
<constraint firstAttribute="bottom" secondItem="5gU-g3-Fsy" secondAttribute="bottom" id="gRu-Hz-CNL"/>
<constraint firstAttribute="trailingMargin" secondItem="5gU-g3-Fsy" secondAttribute="trailing" id="vf4-ql-4Vq"/>
</constraints>
<connections>
<outlet property="bannerView" destination="ziA-mP-AY2" id="yxo-ar-Cha"/>
<outlet property="screenshotsCollectionView" destination="RFs-qp-Ca4" id="xfi-AN-l17"/>
<outlet property="subtitleLabel" destination="Imx-Le-bcy" id="JVW-ZZ-51O"/>
</connections>
<point key="canvasLocation" x="136.95652173913044" y="152.67857142857142"/>
</collectionViewCell>
</objects>
</document>

View File

@@ -21,7 +21,7 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing
private lazy var dataSource = self.makeDataSource()
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
private let prototypeCell = BrowseCollectionViewCell.instantiate(with: BrowseCollectionViewCell.nib!)!
private let prototypeCell = AppCardCollectionViewCell(frame: .zero)
private var loadingState: LoadingState = .loading {
didSet {
@@ -58,11 +58,14 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing
self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
self.collectionView.register(BrowseCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
self.collectionView.register(AppCardCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
self.collectionView.dataSource = self.dataSource
self.collectionView.prefetchDataSource = self.dataSource
let collectionViewLayout = self.collectionViewLayout as! UICollectionViewFlowLayout
collectionViewLayout.minimumLineSpacing = 30
(self as PeekPopPreviewing).registerForPreviewing(with: self, sourceView: self.collectionView)
if let source = self.source
@@ -120,14 +123,11 @@ private extension BrowseViewController
let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: context)
dataSource.cellConfigurationHandler = { (cell, app, indexPath) in
let cell = cell as! BrowseCollectionViewCell
let cell = cell as! AppCardCollectionViewCell
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
cell.subtitleLabel.text = app.subtitle
cell.imageURLs = Array(app.screenshotURLs.prefix(2))
cell.bannerView.configure(for: app)
cell.configure(for: app)
cell.bannerView.iconImageView.image = nil
cell.bannerView.iconImageView.isIndicatingActivity = true
@@ -187,7 +187,7 @@ private extension BrowseViewController
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! BrowseCollectionViewCell
let cell = cell as! AppCardCollectionViewCell
cell.bannerView.iconImageView.isIndicatingActivity = false
cell.bannerView.iconImageView.image = image
@@ -366,21 +366,18 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
{
let item = self.dataSource.item(at: indexPath)
let itemID = item.globallyUniqueID ?? item.bundleIdentifier
if let previousSize = self.cachedItemSizes[item.bundleIdentifier]
if let previousSize = self.cachedItemSizes[itemID]
{
return previousSize
}
let maxVisibleScreenshots = 2 as CGFloat
let aspectRatio: CGFloat = 16.0 / 9.0
let layout = self.prototypeCell.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
let padding = (layout.minimumInteritemSpacing * (maxVisibleScreenshots - 1)) + layout.sectionInset.left + layout.sectionInset.right
self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath)
let insets = (self.view.layoutMargins.left + self.view.layoutMargins.right)
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width - insets)
widthConstraint.isActive = true
defer { widthConstraint.isActive = false }
@@ -388,17 +385,8 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout
self.prototypeCell.frame.size.width = widthConstraint.constant
self.prototypeCell.layoutIfNeeded()
let collectionViewWidth = self.prototypeCell.screenshotsCollectionView.bounds.width
let screenshotWidth = ((collectionViewWidth - padding) / maxVisibleScreenshots).rounded(.down)
let screenshotHeight = screenshotWidth * aspectRatio
let heightConstraint = self.prototypeCell.screenshotsCollectionView.heightAnchor.constraint(equalToConstant: screenshotHeight)
heightConstraint.priority = .defaultHigh // Prevent temporary unsatisfiable constraints error.
heightConstraint.isActive = true
defer { heightConstraint.isActive = false }
let itemSize = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
self.cachedItemSizes[item.bundleIdentifier] = itemSize
self.cachedItemSizes[itemID] = itemSize
return itemSize
}
@@ -435,3 +423,16 @@ extension BrowseViewController: UIViewControllerPreviewingDelegate
self.navigationController?.pushViewController(viewControllerToCommit, animated: true)
}
}
@available(iOS 17, *)
#Preview(traits: .portrait) {
DatabaseManager.shared.startForPreview()
let storyboard = UIStoryboard(name: "Main", bundle: .main)
let browseViewController = storyboard.instantiateViewController(identifier: "browseViewController") { coder in
BrowseViewController(source: nil, coder: coder)
}
let navigationController = UINavigationController(rootViewController: browseViewController)
return navigationController
}

View File

@@ -0,0 +1,276 @@
//
// AppCardCollectionViewCell.swift
// AltStore
//
// Created by Riley Testut on 10/13/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import UIKit
import AltStoreCore
import Roxas
import Nuke
private let minimumItemSpacing = 8.0
class AppCardCollectionViewCell: UICollectionViewCell
{
let bannerView: AppBannerView
private let screenshotsCollectionView: UICollectionView
private let stackView: UIStackView
private lazy var dataSource = self.makeDataSource()
private var screenshots: [AppScreenshot] = [] {
didSet {
self.dataSource.items = self.screenshots
}
}
override init(frame: CGRect)
{
self.bannerView = AppBannerView(frame: .zero)
self.screenshotsCollectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())
self.screenshotsCollectionView.backgroundColor = nil
self.screenshotsCollectionView.alwaysBounceVertical = false
self.screenshotsCollectionView.alwaysBounceHorizontal = true
self.screenshotsCollectionView.showsHorizontalScrollIndicator = false
self.screenshotsCollectionView.showsVerticalScrollIndicator = false
self.stackView = UIStackView(arrangedSubviews: [self.bannerView, self.screenshotsCollectionView])
self.stackView.translatesAutoresizingMaskIntoConstraints = false
self.stackView.spacing = 0
self.stackView.axis = .vertical
self.stackView.alignment = .fill
self.stackView.distribution = .equalSpacing
super.init(frame: frame)
self.contentView.clipsToBounds = true
self.contentView.layer.cornerCurve = .continuous
self.contentView.addSubview(self.bannerView.backgroundEffectView, pinningEdgesWith: .zero)
self.contentView.addSubview(self.stackView, pinningEdgesWith: .zero)
self.screenshotsCollectionView.collectionViewLayout = self.makeLayout()
self.screenshotsCollectionView.dataSource = self.dataSource
self.screenshotsCollectionView.prefetchDataSource = self.dataSource
// Adding screenshotsCollectionView's gesture recognizers to self.contentView breaks paging,
// so instead we intercept taps and pass them onto delegate.
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(AppCardCollectionViewCell.handleTapGesture(_:)))
tapGestureRecognizer.cancelsTouchesInView = false
tapGestureRecognizer.delaysTouchesBegan = false
tapGestureRecognizer.delaysTouchesEnded = false
self.screenshotsCollectionView.addGestureRecognizer(tapGestureRecognizer)
self.screenshotsCollectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
let inset = 14.0 //TODO: Assign from bannerView's layoutMargins
self.stackView.isLayoutMarginsRelativeArrangement = true
self.stackView.layoutMargins.bottom = inset
self.contentView.preservesSuperviewLayoutMargins = true
self.screenshotsCollectionView.directionalLayoutMargins = NSDirectionalEdgeInsets(top: 0, leading: inset, bottom: 0, trailing: inset)
// Aspect ratio constraint to fit exactly 3 modern portrait iPhone screenshots side-by-side (with spacing).
let multiplier = (AppScreenshot.defaultAspectRatio.width * 3) / AppScreenshot.defaultAspectRatio.height
let spacing = (inset * 2) + (minimumItemSpacing * 2)
let aspectRatioConstraint = self.screenshotsCollectionView.widthAnchor.constraint(equalTo: self.screenshotsCollectionView.heightAnchor, multiplier: multiplier, constant: spacing)
NSLayoutConstraint.activate([
aspectRatioConstraint,
self.bannerView.heightAnchor.constraint(equalToConstant: 88)
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func layoutSubviews()
{
super.layoutSubviews()
self.contentView.layer.cornerRadius = self.bannerView.layer.cornerRadius
}
}
private extension AppCardCollectionViewCell
{
func makeLayout() -> UICollectionViewCompositionalLayout
{
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
layoutConfig.contentInsetsReference = .layoutMargins
let layout = UICollectionViewCompositionalLayout(sectionProvider: { [weak self] (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
guard let self else { return nil }
var contentWidth = 0.0
var numberOfVisibleScreenshots = 0
for screenshot in self.screenshots
{
var aspectRatio = screenshot.aspectRatio
if aspectRatio.width > aspectRatio.height
{
switch screenshot.deviceType
{
case .iphone:
// Always rotate landscape iPhone screenshots
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
case .ipad:
// Never rotate iPad screenshots
break
default: break
}
}
let screenshotWidth = (layoutEnvironment.container.effectiveContentSize.height * (aspectRatio.width / aspectRatio.height)).rounded(.up) // Round to ensure we over-estimate contentWidth.
let totalContentWidth = contentWidth + (screenshotWidth + minimumItemSpacing)
if totalContentWidth > layoutEnvironment.container.effectiveContentSize.width
{
// totalContentWidth is larger than visible width.
break
}
contentWidth = totalContentWidth
numberOfVisibleScreenshots += 1
}
// Use .estimated(1) to ensure we don't over-estimate widths, which can cause incorrect layouts for the last group.
let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(1), heightDimension: .fractionalHeight(1.0))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(0), top: nil, trailing: nil, bottom: nil)
let groupItem = NSCollectionLayoutItem(layoutSize: itemSize)
let trailingGroup = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [groupItem])
trailingGroup.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: nil, trailing: .flexible(0), bottom: nil)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalHeight(1.0))
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item, trailingGroup])
group.interItemSpacing = .fixed(minimumItemSpacing)
if numberOfVisibleScreenshots < self.screenshots.count
{
// There are more screenshots than what is displayed, so no need to manually center them.
}
else
{
// We're showing all screenshots initially, so make sure they're centered.
let insetWidth = (layoutEnvironment.container.effectiveContentSize.width - contentWidth) / 2.0
group.contentInsets.leading = (insetWidth - 1).rounded(.down) // Subtract 1 to avoid overflowing/clipping
}
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
layoutSection.interGroupSpacing = self.screenshotsCollectionView.directionalLayoutMargins.leading + self.screenshotsCollectionView.directionalLayoutMargins.trailing
return layoutSection
}, configuration: layoutConfig)
return layout
}
func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>
{
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<AppScreenshot, UIImage>(items: [])
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
let cell = cell as! AppScreenshotCollectionViewCell
cell.imageView.image = nil
cell.imageView.isIndicatingActivity = true
var aspectRatio = screenshot.aspectRatio
if aspectRatio.width > aspectRatio.height
{
switch screenshot.deviceType
{
case .iphone:
// Always rotate landscape iPhone screenshots
aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width)
case .ipad:
// Never rotate iPad screenshots
break
default: break
}
}
cell.aspectRatio = aspectRatio
}
dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in
let imageURL = screenshot.imageURL
return RSTAsyncBlockOperation() { (operation) in
let request = ImageRequest(url: imageURL)
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
guard !operation.isCancelled else { return operation.finish() }
switch result
{
case .success(let response): completionHandler(response.image, nil)
case .failure(let error): completionHandler(nil, error)
}
}
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! AppScreenshotCollectionViewCell
cell.imageView.isIndicatingActivity = false
cell.setImage(image)
if let error = error
{
print("Error loading image:", error)
}
}
return dataSource
}
@objc func handleTapGesture(_ tapGesture: UITapGestureRecognizer)
{
var superview: UIView? = self.superview
var collectionView: UICollectionView? = nil
while case let view? = superview
{
if let cv = view as? UICollectionView
{
collectionView = cv
break
}
superview = view.superview
}
if let collectionView, let indexPath = collectionView.indexPath(for: self)
{
collectionView.delegate?.collectionView?(collectionView, didSelectItemAt: indexPath)
}
}
}
extension AppCardCollectionViewCell
{
func configure(for storeApp: StoreApp)
{
self.screenshots = storeApp.preferredScreenshots()
self.bannerView.tintColor = storeApp.tintColor
self.bannerView.configure(for: storeApp)
self.bannerView.subtitleLabel.numberOfLines = 1
self.bannerView.subtitleLabel.lineBreakMode = .byTruncatingTail
self.bannerView.subtitleLabel.minimumScaleFactor = 0.8
self.bannerView.subtitleLabel.text = storeApp.subtitle ?? storeApp.developerName
}
}

View File

@@ -23,6 +23,7 @@ extension SourceError
case duplicate
case missingPermissionUsageDescription
case missingScreenshotSize
}
static func unsupported(_ source: Source) -> SourceError { SourceError(code: .unsupported, source: source) }
@@ -36,6 +37,10 @@ extension SourceError
static func missingPermissionUsageDescription(for permission: any ALTAppPermission, app: StoreApp, source: Source) -> SourceError {
SourceError(code: .missingPermissionUsageDescription, source: source, app: app, permission: permission)
}
static func missingScreenshotSize(for screenshot: AppScreenshot, source: Source) -> SourceError {
SourceError(code: .missingScreenshotSize, source: source, app: screenshot.app, screenshotURL: screenshot.imageURL)
}
}
struct SourceError: ALTLocalizedError
@@ -59,6 +64,9 @@ struct SourceError: ALTLocalizedError
@UserInfoValue
var permission: (any ALTAppPermission)?
@UserInfoValue
var screenshotURL: URL?
var errorFailureReason: String {
switch self.code
{
@@ -112,6 +120,14 @@ struct SourceError: ALTLocalizedError
let permissionType = permission.type.localizedName ?? NSLocalizedString("Permission", comment: "")
let failureReason = String(format: NSLocalizedString("The %@ '%@' for %@ is missing a usage description.", comment: ""), permissionType.lowercased(), permission.rawValue, appName)
return failureReason
case .missingScreenshotSize:
let appName = self.$app.name ?? String(format: NSLocalizedString("an app in source “%@”", comment: ""), self.$source.name)
let baseMessage = String(format: NSLocalizedString("An iPad screenshot for %@ does not specify its size", comment: ""), appName)
guard let screenshotURL else { return baseMessage + "." }
let failureReason = baseMessage + ": \(screenshotURL.absoluteString)"
return failureReason
}
}

View File

@@ -122,7 +122,35 @@ class FetchSourceOperation: ResultOperation<Source>
decoder.managedObjectContext = childContext
decoder.sourceURL = self.sourceURL
let source = try decoder.decode(Source.self, from: data)
let source: Source
do
{
source = try decoder.decode(Source.self, from: data)
}
catch let error as DecodingError
{
let nsError = error as NSError
guard let codingPath = nsError.userInfo[ALTNSCodingPathKey] as? [CodingKey] else { throw error }
let rawComponents = codingPath.map { $0.intValue?.description ?? $0.stringValue }
let pathDescription = rawComponents.joined(separator: " > ")
var userInfo = nsError.userInfo
if let debugDescription = nsError.localizedDebugDescription
{
let detailedDescription = debugDescription + "\n\n" + pathDescription
userInfo[NSDebugDescriptionErrorKey] = detailedDescription
}
else
{
userInfo[NSDebugDescriptionErrorKey] = pathDescription
}
throw NSError(domain: nsError.domain, code: nsError.code, userInfo: userInfo)
}
let identifier = source.identifier
try self.verify(source, response: response)
@@ -181,6 +209,12 @@ private extension FetchSourceOperation
// Privacy permissions MUST have a usage description.
guard permission.usageDescription != nil else { throw SourceError.missingPermissionUsageDescription(for: permission.permission, app: app, source: source) }
}
for screenshot in app.screenshots(for: .ipad)
{
// All iPad screenshots MUST have an explicit size.
guard screenshot.size != nil else { throw SourceError.missingScreenshotSize(for: screenshot, source: source) }
}
}
if let previousSourceID = self.$source.identifier

View File

@@ -212,41 +212,23 @@ private extension VerifyAppOperation
// Privacy
let allPrivacyPermissions: Set<ALTAppPrivacyPermission>
if #available(iOS 16, *)
{
let regex = Regex {
"NS"
// Capture permission "name"
Capture {
OneOrMore(.anyGraphemeCluster)
let allPrivacyPermissions = ([app] + app.appExtensions).flatMap { (app) in
let permissions = app.bundle.infoDictionary?.keys.compactMap { key -> ALTAppPrivacyPermission? in
if #available(iOS 16, *)
{
guard key.wholeMatch(of: Regex.privacyPermission) != nil else { return nil }
}
else
{
guard key.contains("UsageDescription") else { return nil }
}
"UsageDescription"
// Optional suffix
Optionally(OneOrMore(.anyGraphemeCluster))
}
let permission = ALTAppPrivacyPermission(rawValue: key)
return permission
} ?? []
let privacyPermissions = ([app] + app.appExtensions).flatMap { (app) in
let permissions = app.bundle.infoDictionary?.keys.compactMap { key -> ALTAppPrivacyPermission? in
guard let match = key.wholeMatch(of: regex) else { return nil }
let permission = ALTAppPrivacyPermission(rawValue: String(match.1))
return permission
} ?? []
return permissions
}
allPrivacyPermissions = Set(privacyPermissions)
return permissions
}
else
{
allPrivacyPermissions = []
}
// Verify permissions.
let sourcePermissions: Set<AnyHashable> = Set(await $storeApp.perform { $0.permissions.map { AnyHashable($0.permission) } })
@@ -254,8 +236,37 @@ private extension VerifyAppOperation
// To pass: EVERY permission in localPermissions must also appear in sourcePermissions.
// If there is a single missing permission, throw error.
let missingPermissions: [any ALTAppPermission] = localPermissions.filter { !sourcePermissions.contains(AnyHashable($0)) }
guard missingPermissions.isEmpty else { throw VerificationError.undeclaredPermissions(missingPermissions, app: app) }
let missingPermissions: [any ALTAppPermission] = localPermissions.filter { permission in
if sourcePermissions.contains(AnyHashable(permission))
{
// `permission` exists in source, so return false.
return false
}
else if permission.type == .privacy
{
guard #available(iOS 16, *) else {
// Assume all privacy permissions _are_ included in source on pre-iOS 16 devices.
return false
}
// Special-handling for legacy privacy permissions.
if let match = permission.rawValue.firstMatch(of: Regex.privacyPermission),
case let legacyPermission = ALTAppPrivacyPermission(rawValue: String(match.1)),
sourcePermissions.contains(AnyHashable(legacyPermission))
{
// The legacy name of this permission exists in the source, so return false.
return false
}
}
// Source doesn't contain permission or its legacy name, so assume it is missing.
return true
}
guard missingPermissions.isEmpty else {
// There is at least one undeclared permission, so throw error.
throw VerificationError.undeclaredPermissions(missingPermissions, app: app)
}
return localPermissions
}

View File

@@ -1,27 +0,0 @@
//
// ScreenshotProcessor.swift
// AltStore
//
// Created by Riley Testut on 4/11/22.
// Copyright © 2022 Riley Testut. All rights reserved.
//
import Nuke
struct ScreenshotProcessor: ImageProcessing
{
var identifier: String { "io.altstore.ScreenshotProcessor" }
func process(_ image: PlatformImage) -> PlatformImage?
{
guard let cgImage = image.cgImage, image.size.width > image.size.height else { return image }
let rotatedImage = UIImage(cgImage: cgImage, scale: image.scale, orientation: .right)
return rotatedImage
}
}
extension ImageProcessing where Self == ScreenshotProcessor
{
static var screenshot: Self { Self() }
}

View File

@@ -0,0 +1,31 @@
//
// Regex+Permissions.swift
// AltStore
//
// Created by Riley Testut on 10/10/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import RegexBuilder
@available(iOS 16, *)
public extension Regex where Output == (Substring, Substring)
{
static var privacyPermission: some RegexComponent<(Substring, Substring)> {
Regex {
Optionally {
"NS"
}
// Capture permission "name"
Capture {
OneOrMore(.anyGraphemeCluster)
}
"UsageDescription"
// Optional suffix
Optionally(OneOrMore(.anyGraphemeCluster))
}
}
}

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D68" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22222" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="Account" syncable="YES">
<attribute name="appleID" attributeType="String"/>
<attribute name="firstName" attributeType="String"/>
@@ -42,6 +42,23 @@
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="AppScreenshot" representedClassName="AppScreenshot" syncable="YES">
<attribute name="appBundleID" attributeType="String"/>
<attribute name="deviceType" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="height" optional="YES" attributeType="Integer 16" usesScalarValueType="NO"/>
<attribute name="imageURL" attributeType="URI"/>
<attribute name="sourceID" attributeType="String"/>
<attribute name="width" optional="YES" attributeType="Integer 16" usesScalarValueType="NO"/>
<relationship name="app" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="StoreApp" inverseName="screenshots" inverseEntity="StoreApp"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="imageURL"/>
<constraint value="deviceType"/>
<constraint value="appBundleID"/>
<constraint value="sourceID"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="AppVersion" representedClassName="AppVersion" syncable="YES">
<attribute name="appBundleID" attributeType="String"/>
<attribute name="buildVersion" attributeType="String"/>
@@ -207,6 +224,7 @@
<relationship name="loggedErrors" toMany="YES" deletionRule="Nullify" destinationEntity="LoggedError" inverseName="storeApp" inverseEntity="LoggedError"/>
<relationship name="newsItems" toMany="YES" deletionRule="Nullify" destinationEntity="NewsItem" inverseName="storeApp" inverseEntity="NewsItem"/>
<relationship name="permissions" toMany="YES" deletionRule="Cascade" destinationEntity="AppPermission" inverseName="app" inverseEntity="AppPermission"/>
<relationship name="screenshots" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="AppScreenshot" inverseName="app" inverseEntity="AppScreenshot"/>
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="apps" inverseEntity="Source"/>
<relationship name="versions" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="AppVersion" inverseName="app" inverseEntity="AppVersion"/>
<uniquenessConstraints>

View File

@@ -12,7 +12,7 @@ import UIKit
import AltSign
@objc(AppPermission) @dynamicMemberLookup
public class AppPermission: NSManagedObject, Decodable, Fetchable
public class AppPermission: NSManagedObject, Fetchable
{
/* Properties */
@NSManaged public var type: ALTAppPermissionType
@@ -47,37 +47,13 @@ public class AppPermission: NSManagedObject, Decodable, Fetchable
super.init(entity: entity, insertInto: context)
}
private enum CodingKeys: String, CodingKey
convenience init(permission: String, usageDescription: String?, type: ALTAppPermissionType, context: NSManagedObjectContext)
{
case name
case usageDescription
}
public required init(from decoder: Decoder) throws
{
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
self.init(entity: AppPermission.entity(), insertInto: context)
super.init(entity: AppPermission.entity(), insertInto: context)
do
{
let container = try decoder.container(keyedBy: CodingKeys.self)
self._permission = try container.decode(String.self, forKey: .name)
self.usageDescription = try container.decodeIfPresent(String.self, forKey: .usageDescription)
// Will be updated from StoreApp.
self.type = .unknown
}
catch
{
if let context = self.managedObjectContext
{
context.delete(self)
}
throw error
}
self._permission = permission
self.usageDescription = usageDescription
self.type = type
}
}
@@ -99,3 +75,114 @@ public extension AppPermission
}
}
}
private struct AnyDecodable: Decodable
{
init(from decoder: Decoder) throws
{
}
}
internal struct AppPermissions: Decodable
{
var entitlements: [AppPermission] = []
var privacy: [AppPermission] = []
private enum CodingKeys: String, CodingKey, Decodable
{
case entitlements
case privacy
// Legacy
case name
case usageDescription
}
init(from decoder: Decoder) throws
{
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
let container = try decoder.container(keyedBy: CodingKeys.self)
self.entitlements = try self.parseEntitlements(from: container, into: context)
self.privacy = try self.parsePrivacyPermissions(from: container, into: context)
}
private func parseEntitlements(from container: KeyedDecodingContainer<CodingKeys>, into context: NSManagedObjectContext) throws -> [AppPermission]
{
guard container.contains(.entitlements) else { return [] }
do
{
do
{
// Legacy
// Must parse as [String: String], NOT [CodingKeys: String], to avoid incorrect DecodingError.typeMismatch error.
let rawEntitlements = try container.decode([[String: String]].self, forKey: .entitlements)
let entitlements = try rawEntitlements.compactMap { (dictionary) -> AppPermission? in
guard let name = dictionary[CodingKeys.name.rawValue] else {
let context = DecodingError.Context(codingPath: container.codingPath, debugDescription: "Legacy entitlements must have `name` key.")
throw DecodingError.keyNotFound(CodingKeys.name, context)
}
let entitlement = AppPermission(permission: name, usageDescription: nil, type: .entitlement, context: context)
return entitlement
}
return entitlements
}
catch DecodingError.typeMismatch
{
// Detailed
// AnyDecodable ensures we're forward-compatible with any values we may later require for entitlement permissions.
let rawEntitlements = try container.decode([String: AnyDecodable?].self, forKey: .entitlements)
let entitlements = rawEntitlements.map { AppPermission(permission: $0.key, usageDescription: nil, type: .entitlement, context: context) }
return entitlements
}
}
catch DecodingError.typeMismatch
{
// Default
let rawEntitlements = try container.decode([String].self, forKey: .entitlements)
let entitlements = rawEntitlements.map { AppPermission(permission: $0, usageDescription: nil, type: .entitlement, context: context) }
return entitlements
}
}
private func parsePrivacyPermissions(from container: KeyedDecodingContainer<CodingKeys>, into context: NSManagedObjectContext) throws -> [AppPermission]
{
guard container.contains(.privacy) else { return [] }
do
{
// Legacy
// Must parse as [String: String], NOT [CodingKeys: String], to avoid incorrect DecodingError.typeMismatch error.
let rawPermissions = try container.decode([[String: String]].self, forKey: .privacy)
let permissions = try rawPermissions.compactMap { (dictionary) -> AppPermission? in
guard let name = dictionary[CodingKeys.name.rawValue] else {
let context = DecodingError.Context(codingPath: container.codingPath, debugDescription: "Legacy privacy permissions must have `name` key.")
throw DecodingError.keyNotFound(CodingKeys.name, context)
}
let usageDescription = dictionary[CodingKeys.usageDescription.rawValue]
let convertedName = "NS" + name + "UsageDescription" // Convert legacy privacy permissions to their NS[Privacy]UsageDescription equivalent.
let permission = AppPermission(permission: convertedName, usageDescription: usageDescription, type: .privacy, context: context)
return permission
}
return permissions
}
catch DecodingError.typeMismatch
{
// Default
let rawPermissions = try container.decode([String: String?].self, forKey: .privacy)
let permissions = rawPermissions.map { AppPermission(permission: $0, usageDescription: $1, type: .privacy, context: context) }
return permissions
}
}
}

View File

@@ -0,0 +1,208 @@
//
// AppScreenshot.swift
// AltStoreCore
//
// Created by Riley Testut on 9/18/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import CoreData
import AltSign
public extension AppScreenshot
{
static let defaultAspectRatio = CGSize(width: 9, height: 19.5)
}
@objc(AppScreenshot)
public class AppScreenshot: NSManagedObject, Fetchable, Decodable
{
/* Properties */
@NSManaged public private(set) var imageURL: URL
public private(set) var size: CGSize? {
get {
guard let width = self.width?.doubleValue, let height = self.height?.doubleValue else { return nil }
return CGSize(width: width, height: height)
}
set {
if let newValue
{
self.width = NSNumber(value: newValue.width)
self.height = NSNumber(value: newValue.height)
}
else
{
self.width = nil
self.height = nil
}
}
}
@NSManaged private var width: NSNumber?
@NSManaged private var height: NSNumber?
// Defaults to .iphone
@nonobjc public var deviceType: ALTDeviceType {
get { ALTDeviceType(rawValue: Int(_deviceType)) }
set { _deviceType = Int16(newValue.rawValue) }
}
@NSManaged @objc(deviceType) private var _deviceType: Int16
@NSManaged public internal(set) var appBundleID: String
@NSManaged public internal(set) var sourceID: String
/* Relationships */
@NSManaged public internal(set) var app: StoreApp?
private enum CodingKeys: String, CodingKey
{
case imageURL
case width
case height
}
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
{
super.init(entity: entity, insertInto: context)
}
internal init(imageURL: URL, size: CGSize?, deviceType: ALTDeviceType, context: NSManagedObjectContext)
{
super.init(entity: AppScreenshot.entity(), insertInto: context)
self.imageURL = imageURL
self.size = size
self.deviceType = deviceType
}
public required init(from decoder: Decoder) throws
{
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
super.init(entity: AppScreenshot.entity(), insertInto: context)
let container = try decoder.container(keyedBy: CodingKeys.self)
self.imageURL = try container.decode(URL.self, forKey: .imageURL)
self.width = try container.decodeIfPresent(Int16.self, forKey: .width).map { NSNumber(value: $0) }
self.height = try container.decodeIfPresent(Int16.self, forKey: .height).map { NSNumber(value: $0) }
}
public override func awakeFromInsert()
{
super.awakeFromInsert()
self.deviceType = .iphone
}
}
public extension AppScreenshot
{
var aspectRatio: CGSize {
return self.size ?? AppScreenshot.defaultAspectRatio
}
}
extension AppScreenshot
{
var screenshotID: String {
let screenshotID = "\(self.imageURL.absoluteString)|\(self.deviceType)"
return screenshotID
}
}
public extension AppScreenshot
{
@nonobjc class func fetchRequest() -> NSFetchRequest<AppScreenshot>
{
return NSFetchRequest<AppScreenshot>(entityName: "AppScreenshot")
}
}
internal struct AppScreenshots: Decodable
{
var screenshots: [AppScreenshot] = []
enum CodingKeys: String, CodingKey
{
case iphone
case ipad
}
init(from decoder: Decoder) throws
{
let container: KeyedDecodingContainer<CodingKeys>
do
{
container = try decoder.container(keyedBy: CodingKeys.self)
}
catch DecodingError.typeMismatch
{
// ONLY catch the container's DecodingError.typeMismatch, not the below decodeIfPresent()'s
// Fallback to single array.
var collection = try Collection(from: decoder)
collection.deviceType = .iphone
self.screenshots = collection.screenshots
return
}
if var collection = try container.decodeIfPresent(Collection.self, forKey: .iphone)
{
collection.deviceType = .iphone
self.screenshots += collection.screenshots
}
if var collection = try container.decodeIfPresent(Collection.self, forKey: .ipad)
{
collection.deviceType = .ipad
self.screenshots += collection.screenshots
}
}
}
extension AppScreenshots
{
struct Collection: Decodable
{
var screenshots: [AppScreenshot] = []
var deviceType: ALTDeviceType = .iphone {
didSet {
self.screenshots.forEach { $0.deviceType = self.deviceType }
}
}
init(from decoder: Decoder) throws
{
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
var container = try decoder.unkeyedContainer()
while !container.isAtEnd
{
do
{
// Attempt parsing as URL first.
let imageURL = try container.decode(URL.self)
let screenshot = AppScreenshot(imageURL: imageURL, size: nil, deviceType: self.deviceType, context: context)
self.screenshots.append(screenshot)
}
catch DecodingError.typeMismatch
{
// Fall back to parsing full AppScreenshot (preferred).
let screenshot = try container.decode(AppScreenshot.self)
self.screenshots.append(screenshot)
}
}
}
}
}

View File

@@ -253,7 +253,7 @@ private extension DatabaseManager
}
// Make sure to always update source URL to be current.
altStoreSource.sourceURL = Source.altStoreSourceURL
try! altStoreSource.setSourceURL(Source.altStoreSourceURL)
let storeApp: StoreApp

View File

@@ -156,6 +156,12 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
{
appVersion.managedObjectContext?.delete(appVersion)
}
// Delete previous screenshots (different than below).
for case let appScreenshot as AppScreenshot in previousApp._screenshots where appScreenshot.app == nil
{
appScreenshot.managedObjectContext?.delete(appScreenshot)
}
}
case is AppVersion where conflict.conflictingObjects.count == 2:
@@ -181,8 +187,9 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
return
}
var sortedVersionIDsByGlobalAppID = [String: NSOrderedSet]()
var permissionsByGlobalAppID = [String: Set<AnyHashable>]()
var sortedVersionIDsByGlobalAppID = [String: NSOrderedSet]()
var sortedScreenshotIDsByGlobalAppID = [String: NSOrderedSet]()
var featuredAppIDsBySourceID = [String: [String]]()
@@ -212,10 +219,19 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
databaseVersion.managedObjectContext?.delete(databaseVersion)
}
// Screenshots
let contextScreenshotIDs = NSOrderedSet(array: contextApp._screenshots.lazy.compactMap { $0 as? AppScreenshot }.map { $0.screenshotID })
for case let databaseScreenshot as AppScreenshot in databaseObject._screenshots where !contextScreenshotIDs.contains(databaseScreenshot.screenshotID)
{
// Screenshot ID does NOT exist in context, so delete existing databaseScreenshot.
databaseScreenshot.managedObjectContext?.delete(databaseScreenshot)
}
if let globallyUniqueID = contextApp.globallyUniqueID
{
permissionsByGlobalAppID[globallyUniqueID] = contextPermissions
sortedVersionIDsByGlobalAppID[globallyUniqueID] = contextVersionIDs
sortedScreenshotIDsByGlobalAppID[globallyUniqueID] = contextScreenshotIDs
}
case let databaseObject as Source:
@@ -295,6 +311,31 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
appVersions = fixedAppVersions
}
// Screenshots
if let sortedScreenshotIDs = sortedScreenshotIDsByGlobalAppID[globallyUniqueID],
let sortedScreenshotIDsArray = sortedScreenshotIDs.array as? [String],
case let databaseScreenshotIDs = databaseObject.allScreenshots.map({ $0.screenshotID }),
databaseScreenshotIDs != sortedScreenshotIDsArray
{
// Screenshot order is incorrect, so attempt to fix by re-sorting.
let fixedScreenshots = databaseObject.allScreenshots.sorted { (screenshotA, screenshotB) in
let indexA = sortedScreenshotIDs.index(of: screenshotA.screenshotID)
let indexB = sortedScreenshotIDs.index(of: screenshotB.screenshotID)
return indexA < indexB
}
let appScreenshotIDs = fixedScreenshots.map { $0.screenshotID }
if appScreenshotIDs == sortedScreenshotIDsArray
{
databaseObject.setScreenshots(fixedScreenshots)
}
else
{
// Screenshots are still not in correct order, but not worth throwing error so ignore.
print("Failed to re-sort screenshots into correct order. Expected:", sortedScreenshotIDsArray)
}
}
}
// Always update versions post-merging to make sure latestSupportedVersion is correct.

View File

@@ -11,11 +11,7 @@ import UIKit
public extension Source
{
#if ALPHA
static let altStoreIdentifier = "com.rileytestut.AltStore.Alpha"
#else
static let altStoreIdentifier = "com.rileytestut.AltStore"
#endif
static let altStoreIdentifier = try! Source.sourceID(from: Source.altStoreSourceURL)
#if STAGING
@@ -62,7 +58,7 @@ public class Source: NSManagedObject, Fetchable, Decodable
{
/* Properties */
@NSManaged public var name: String
@NSManaged public var identifier: String
@NSManaged public private(set) var identifier: String
@NSManaged public var sourceURL: URL
/* Source Detail */
@@ -114,7 +110,6 @@ public class Source: NSManagedObject, Fetchable, Decodable
private enum CodingKeys: String, CodingKey
{
case name
case identifier
case sourceURL
case subtitle
case localizedDescription = "description"
@@ -143,11 +138,8 @@ public class Source: NSManagedObject, Fetchable, Decodable
do
{
self.sourceURL = sourceURL
let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name)
self.identifier = try container.decode(String.self, forKey: .identifier)
// Optional Values
self.subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle)
@@ -173,7 +165,6 @@ public class Source: NSManagedObject, Fetchable, Decodable
for (index, app) in apps.enumerated()
{
app.sourceIdentifier = self.identifier
app.sortIndex = Int32(index)
}
self._apps = NSMutableOrderedSet(array: apps)
@@ -181,7 +172,6 @@ public class Source: NSManagedObject, Fetchable, Decodable
let newsItems = try container.decodeIfPresent([NewsItem].self, forKey: .news) ?? []
for (index, item) in newsItems.enumerated()
{
item.sourceIdentifier = self.identifier
item.sortIndex = Int32(index)
}
@@ -203,6 +193,9 @@ public class Source: NSManagedObject, Fetchable, Decodable
let featuredAppBundleIDs = try container.decodeIfPresent([String].self, forKey: .featuredApps)
let featuredApps = featuredAppBundleIDs?.compactMap { appsByID[$0] }
self.setFeaturedApps(featuredApps)
// Updates identifier + apps & newsItems
try self.setSourceURL(sourceURL)
}
catch
{
@@ -257,6 +250,54 @@ public extension Source
internal extension Source
{
class func sourceID(from sourceURL: URL) throws -> String
{
// Based on https://encyclopedia.pub/entry/29841
guard var components = URLComponents(url: sourceURL, resolvingAgainstBaseURL: true) else { throw URLError(.badURL, userInfo: [NSURLErrorKey: sourceURL]) }
if components.scheme == nil && components.host == nil
{
// Special handling for URLs without explicit scheme & incorrectly assumed to have nil host (e.g. "altstore.io/my/path")
guard let updatedComponents = URLComponents(string: "https://" + sourceURL.absoluteString) else { throw URLError(.cannotFindHost, userInfo: [NSURLErrorKey: sourceURL]) }
components = updatedComponents
}
// 1. Don't use percent encoding
guard let host = components.host else { throw URLError(.cannotFindHost, userInfo: [NSURLErrorKey: sourceURL]) }
// 2. Ignore scheme
var standardizedID = host
// 3. Add port (if not default)
if let port = components.port, port != 80 && port != 443
{
standardizedID += ":" + String(port)
}
// 4. Add path without fragment or query parameters
// 5. Remove duplicate slashes
let path = components.path.replacingOccurrences(of: "//", with: "/") // Only remove duplicate slashes from path, not entire URL.
standardizedID += path // path has leading `/`
// 6. Convert to lowercase
standardizedID = standardizedID.lowercased()
// 7. Remove trailing `/`
if standardizedID.hasSuffix("/")
{
standardizedID.removeLast()
}
// 8. Remove leading "www"
if standardizedID.hasPrefix("www.")
{
standardizedID.removeFirst(4)
}
return standardizedID
}
func setFeaturedApps(_ featuredApps: [StoreApp]?)
{
// Explicitly update relationships for all apps to ensure featuredApps merges correctly.
@@ -278,6 +319,27 @@ internal extension Source
}
}
public extension Source
{
func setSourceURL(_ sourceURL: URL) throws
{
let identifier = try Source.sourceID(from: sourceURL)
self.identifier = identifier
self.sourceURL = sourceURL
for app in self.apps
{
app.sourceIdentifier = identifier
}
for newsItem in self.newsItems
{
newsItem.sourceIdentifier = identifier
}
}
}
public extension Source
{
@nonobjc class func fetchRequest() -> NSFetchRequest<Source>
@@ -289,8 +351,7 @@ public extension Source
{
let source = Source(context: context)
source.name = "AltStore"
source.identifier = Source.altStoreIdentifier
source.sourceURL = Source.altStoreSourceURL
try! source.setSourceURL(Source.altStoreSourceURL)
return source
}

View File

@@ -23,12 +23,6 @@ public extension StoreApp
#endif
static let dolphinAppID = "me.oatmealdome.dolphinios-njb"
private struct AppPermissions: Decodable
{
var entitlements: [AppPermission]?
var privacy: [AppPermission]?
}
}
@objc(StoreApp)
@@ -74,6 +68,11 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
{
permission.sourceID = self.sourceIdentifier ?? ""
}
for screenshot in self.allScreenshots
{
screenshot.sourceID = self.sourceIdentifier ?? ""
}
}
}
@NSManaged private var primitiveSourceIdentifier: String?
@@ -114,6 +113,11 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
return self._versions.array as! [AppVersion]
}
@nonobjc public var allScreenshots: [AppScreenshot] {
return self._screenshots.array as! [AppScreenshot]
}
@NSManaged @objc(screenshots) private(set) var _screenshots: NSOrderedSet
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
{
super.init(entity: entity, insertInto: context)
@@ -125,18 +129,21 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
case bundleIdentifier
case developerName
case localizedDescription
case version
case versionDescription
case versionDate
case iconURL
case screenshotURLs
case downloadURL
case screenshots
case tintColor
case subtitle
case permissions = "appPermissions"
case size
case isBeta = "beta"
case versions
// Legacy
case version
case versionDescription
case versionDate
case downloadURL
case screenshotURLs
}
public required init(from decoder: Decoder) throws
@@ -157,7 +164,6 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
self.subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle)
self.iconURL = try container.decode(URL.self, forKey: .iconURL)
self.screenshotURLs = try container.decodeIfPresent([URL].self, forKey: .screenshotURLs) ?? []
if let tintColorHex = try container.decodeIfPresent(String.self, forKey: .tintColor)
{
@@ -170,12 +176,37 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
self.isBeta = try container.decodeIfPresent(Bool.self, forKey: .isBeta) ?? false
let appScreenshots: [AppScreenshot]
if let screenshots = try container.decodeIfPresent(AppScreenshots.self, forKey: .screenshots)
{
appScreenshots = screenshots.screenshots
}
else if let screenshotURLs = try container.decodeIfPresent([URL].self, forKey: .screenshotURLs)
{
// Assume 9:16 iPhone 8 screen dimensions for legacy screenshotURLs.
let legacyAspectRatio = CGSize(width: 750, height: 1334)
appScreenshots = screenshotURLs.map { imageURL in
let screenshot = AppScreenshot(imageURL: imageURL, size: legacyAspectRatio, deviceType: .iphone, context: context)
return screenshot
}
}
else
{
appScreenshots = []
}
for screenshot in appScreenshots
{
screenshot.appBundleID = self.bundleIdentifier
}
self.setScreenshots(appScreenshots)
if let appPermissions = try container.decodeIfPresent(AppPermissions.self, forKey: .permissions)
{
appPermissions.entitlements?.forEach { $0.type = .entitlement }
appPermissions.privacy?.forEach { $0.type = .privacy }
let allPermissions = (appPermissions.entitlements ?? []) + (appPermissions.privacy ?? [])
let allPermissions = appPermissions.entitlements + appPermissions.privacy
for permission in allPermissions
{
permission.appBundleID = self.bundleIdentifier
@@ -279,6 +310,58 @@ internal extension StoreApp
self._permissions = permissions as NSSet
}
func setScreenshots(_ screenshots: [AppScreenshot])
{
for case let screenshot as AppScreenshot in self._screenshots
{
if screenshots.contains(screenshot)
{
screenshot.app = self
}
else
{
screenshot.app = nil
}
}
self._screenshots = NSOrderedSet(array: screenshots)
// Backwards compatibility
self.screenshotURLs = screenshots.map { $0.imageURL }
}
}
public extension StoreApp
{
func screenshots(for deviceType: ALTDeviceType) -> [AppScreenshot]
{
//TODO: Support multiple device types
let filteredScreenshots = self.allScreenshots.filter { $0.deviceType == deviceType }
return filteredScreenshots
}
func preferredScreenshots() -> [AppScreenshot]
{
let deviceType: ALTDeviceType
if UIDevice.current.model.contains("iPad")
{
deviceType = .ipad
}
else
{
deviceType = .iphone
}
let preferredScreenshots = self.screenshots(for: deviceType)
guard !preferredScreenshots.isEmpty else {
// There are no screenshots for deviceType, so return _all_ screenshots instead.
return self.allScreenshots
}
return preferredScreenshots
}
}
public extension StoreApp

View File

@@ -6,6 +6,8 @@
// Copyright © 2023 Riley Testut. All rights reserved.
//
import RegexBuilder
import AltSign
extension ALTAppPermissionType
@@ -156,5 +158,10 @@ extension ALTAppPrivacyPermission: ALTAppPermission
{
public var type: ALTAppPermissionType { .privacy }
public var synthesizedName: String? { nil }
public var synthesizedName: String? {
guard #available(iOS 16, *), let match = self.rawValue.wholeMatch(of: Regex.privacyPermission) else { return nil }
let synthesizedNamed = String(match.1)
return synthesizedNamed
}
}

View File

@@ -865,291 +865,291 @@
</dict>
<key>privacy</key>
<dict>
<key>BluetoothAlways</key>
<key>NSBluetoothAlwaysUsageDescription</key>
<dict>
<key>name</key>
<string>Bluetooth</string>
<key>key</key>
<string>BluetoothAlways</string>
<string>NSBluetoothAlwaysUsageDescription</string>
<key>symbol</key>
<string>antenna.radiowaves.left.and.right</string>
</dict>
<key>BluetoothPeripheral</key>
<key>NSBluetoothPeripheralUsageDescription</key>
<dict>
<key>name</key>
<string>Bluetooth (Peripherals)</string>
<key>key</key>
<string>BluetoothPeripheral</string>
<string>NSBluetoothPeripheralUsageDescription</string>
<key>symbol</key>
<string>antenna.radiowaves.left.and.right</string>
</dict>
<key>Calendars</key>
<key>NSCalendarsUsageDescription</key>
<dict>
<key>name</key>
<string>Calendars</string>
<key>key</key>
<string>Calendars</string>
<string>NSCalendarsUsageDescription</string>
<key>symbol</key>
<string>calendar</string>
</dict>
<key>Reminders</key>
<key>NSRemindersUsageDescription</key>
<dict>
<key>name</key>
<string>Reminders</string>
<key>key</key>
<string>Reminders</string>
<string>NSRemindersUsageDescription</string>
<key>symbol</key>
<string>note.text</string>
</dict>
<key>Camera</key>
<key>NSCameraUsageDescription</key>
<dict>
<key>name</key>
<string>Camera</string>
<key>key</key>
<string>Camera</string>
<string>NSCameraUsageDescription</string>
<key>symbol</key>
<string>camera</string>
</dict>
<key>Microphone</key>
<key>NSMicrophoneUsageDescription</key>
<dict>
<key>name</key>
<string>Microphone</string>
<key>key</key>
<string>Microphone</string>
<string>NSMicrophoneUsageDescription</string>
<key>symbol</key>
<string>mic</string>
</dict>
<key>Contacts</key>
<key>NSContactsUsageDescription</key>
<dict>
<key>name</key>
<string>Contacts</string>
<key>key</key>
<string>Contacts</string>
<string>NSContactsUsageDescription</string>
<key>symbol</key>
<string>person.crop.circle</string>
</dict>
<key>FaceID</key>
<key>NSFaceIDUsageDescription</key>
<dict>
<key>name</key>
<string>Face ID</string>
<key>key</key>
<string>FaceID</string>
<string>NSFaceIDUsageDescription</string>
<key>symbol</key>
<string>faceid</string>
</dict>
<key>GKFriendList</key>
<key>NSGKFriendListUsageDescription</key>
<dict>
<key>name</key>
<string>Game Center Friends List</string>
<key>key</key>
<string>GKFriendList</string>
<string>NSGKFriendListUsageDescription</string>
<key>symbol</key>
<string>gamecontroller</string>
</dict>
<key>HealthClinicalHealthRecordsShare</key>
<key>NSHealthClinicalHealthRecordsShareUsageDescription</key>
<dict>
<key>name</key>
<string>Health (Clinical Records)</string>
<key>key</key>
<string>HealthClinicalHealthRecordsShare</string>
<string>NSHealthClinicalHealthRecordsShareUsageDescription</string>
<key>symbol</key>
<string>heart.text.square</string>
</dict>
<key>HealthShare</key>
<key>NSHealthShareUsageDescription</key>
<dict>
<key>name</key>
<string>Health</string>
<key>key</key>
<string>HealthShare</string>
<string>NSHealthShareUsageDescription</string>
<key>symbol</key>
<string>heart.text.square</string>
</dict>
<key>HealthUpdate</key>
<key>NSHealthUpdateUsageDescription</key>
<dict>
<key>name</key>
<string>Health (Add)</string>
<key>key</key>
<string>HealthUpdate</string>
<string>NSHealthUpdateUsageDescription</string>
<key>symbol</key>
<string>heart.text.square</string>
</dict>
<key>HomeKit</key>
<key>NSHomeKitUsageDescription</key>
<dict>
<key>name</key>
<string>HomeKit</string>
<key>key</key>
<string>HomeKit</string>
<string>NSHomeKitUsageDescription</string>
<key>symbol</key>
<string>homekit</string>
</dict>
<key>LocationAlways</key>
<key>NSLocationAlwaysUsageDescription</key>
<dict>
<key>name</key>
<string>Location (Background)</string>
<key>key</key>
<string>LocationAlways</string>
<string>NSLocationAlwaysUsageDescription</string>
<key>symbol</key>
<string>location.fill</string>
</dict>
<key>LocationAlwaysAndWhenInUse</key>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<dict>
<key>name</key>
<string>Location (Always)</string>
<key>key</key>
<string>LocationAlwaysAndWhenInUse</string>
<string>NSLocationAlwaysAndWhenInUseUsageDescription</string>
<key>symbol</key>
<string>location.fill</string>
</dict>
<key>Location</key>
<key>NSLocationUsageDescription</key>
<dict>
<key>name</key>
<string>Location</string>
<key>key</key>
<string>Location</string>
<string>NSLocationUsageDescription</string>
<key>symbol</key>
<string>location.fill</string>
</dict>
<key>LocationWhenInUse</key>
<key>NSLocationWhenInUseUsageDescription</key>
<dict>
<key>name</key>
<string>Location (When Using)</string>
<key>key</key>
<string>LocationWhenInUse</string>
<string>NSLocationWhenInUseUsageDescription</string>
<key>symbol</key>
<string>location</string>
</dict>
<key>LocationTemporary</key>
<key>NSLocationTemporaryUsageDescription</key>
<dict>
<key>name</key>
<string>Location (Temporary)</string>
<key>key</key>
<string>LocationTemporary</string>
<string>NSLocationTemporaryUsageDescription</string>
<key>symbol</key>
<string>location</string>
</dict>
<key>AppleMusic</key>
<key>NSAppleMusicUsageDescription</key>
<dict>
<key>name</key>
<string>Apple Music</string>
<key>key</key>
<string>AppleMusic</string>
<string>NSAppleMusicUsageDescription</string>
<key>symbol</key>
<string>music.note</string>
</dict>
<key>Motion</key>
<key>NSMotionUsageDescription</key>
<dict>
<key>name</key>
<string>Motion</string>
<key>key</key>
<string>Motion</string>
<string>NSMotionUsageDescription</string>
<key>symbol</key>
<string>figure.run</string>
</dict>
<key>FallDetection</key>
<key>NSFallDetectionUsageDescription</key>
<dict>
<key>name</key>
<string>Fall Detection</string>
<key>key</key>
<string>FallDetection</string>
<string>NSFallDetectionUsageDescription</string>
<key>symbol</key>
<string>figure.fall</string>
</dict>
<key>LocalNetwork</key>
<key>NSLocalNetworkUsageDescription</key>
<dict>
<key>name</key>
<string>Local Network</string>
<key>key</key>
<string>LocalNetwork</string>
<string>NSLocalNetworkUsageDescription</string>
<key>symbol</key>
<string>network</string>
</dict>
<key>NearbyInteraction</key>
<key>NSNearbyInteractionUsageDescription</key>
<dict>
<key>name</key>
<string>Nearby Interaction</string>
<key>key</key>
<string>NearbyInteraction</string>
<string>NSNearbyInteractionUsageDescription</string>
<key>symbol</key>
<string>dot.radiowaves.left.and.right</string>
</dict>
<key>NearbyInteractionAllowOnce</key>
<key>NSNearbyInteractionAllowOnceUsageDescription</key>
<dict>
<key>name</key>
<string>Nearby Interaction (Once)</string>
<key>key</key>
<string>NearbyInteractionAllowOnce</string>
<string>NSNearbyInteractionAllowOnceUsageDescription</string>
<key>symbol</key>
<string>dot.radiowaves.left.and.right</string>
</dict>
<key>NFCReader</key>
<key>NFCReaderUsageDescription</key>
<dict>
<key>name</key>
<string>NFC</string>
<key>key</key>
<string>NFCReader</string>
<string>NSNFCReaderUsageDescription</string>
<key>symbol</key>
<string>sensor.tag.radiowaves.forward</string>
</dict>
<key>PhotoLibraryAdd</key>
<key>NSPhotoLibraryAddUsageDescription</key>
<dict>
<key>name</key>
<string>Photos (Add)</string>
<key>key</key>
<string>PhotoLibraryAdd</string>
<string>NSPhotoLibraryAddUsageDescription</string>
<key>symbol</key>
<string>photo</string>
</dict>
<key>PhotoLibrary</key>
<key>NSPhotoLibraryUsageDescription</key>
<dict>
<key>name</key>
<string>Photos</string>
<key>key</key>
<string>PhotoLibrary</string>
<string>NSPhotoLibraryUsageDescription</string>
<key>symbol</key>
<string>photo.stack</string>
</dict>
<key>UserTracking</key>
<key>NSUserTrackingUsageDescription</key>
<dict>
<key>name</key>
<string>User Tracking</string>
<key>key</key>
<string>UserTracking</string>
<string>NSUserTrackingUsageDescription</string>
<key>symbol</key>
<string>location.magnifyingglass</string>
</dict>
<key>SensorKit</key>
<key>NSSensorKitUsageDescription</key>
<dict>
<key>name</key>
<string>SensorKit</string>
<key>key</key>
<string>SensorKit</string>
<string>NSSensorKitUsageDescription</string>
<key>symbol</key>
<string>sensor</string>
</dict>
<key>Siri</key>
<key>NSSiriUsageDescription</key>
<dict>
<key>name</key>
<string>Siri</string>
<key>key</key>
<string>Siri</string>
<string>NSSiriUsageDescription</string>
<key>symbol</key>
<string>mic.circle</string>
</dict>
<key>SpeechRecognition</key>
<key>NSSpeechRecognitionUsageDescription</key>
<dict>
<key>name</key>
<string>Speech Recognition</string>
<key>key</key>
<string>SpeechRecognition</string>
<string>NSSpeechRecognitionUsageDescription</string>
<key>symbol</key>
<string>waveform.and.mic</string>
</dict>
<key>Identity</key>
<key>NSIdentityUsageDescription</key>
<dict>
<key>name</key>
<string>ID Cards</string>
<key>key</key>
<string>Identity</string>
<string>NSIdentityUsageDescription</string>
<key>symbol</key>
<string>wallet.pass</string>
</dict>

View File

@@ -0,0 +1,159 @@
//
// AltTests+Sources.swift
// AltTests
//
// Created by Riley Testut on 10/10/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import XCTest
@testable import AltStoreCore
extension AltTests
{
func testSourceID() throws
{
let url = Source.altStoreSourceURL
let sourceID = try Source.sourceID(from: url)
XCTAssertEqual(sourceID, "apps.altstore.io")
}
@available(iOS 17, *)
func testSourceIDWithPercentEncoding() throws
{
let url = URL(string: "apple.com/MY invalid•path/")!
let sourceID = try Source.sourceID(from: url)
XCTAssertEqual(sourceID, "apple.com/my invalid•path")
}
func testSourceIDWithDifferentSchemes() throws
{
let url1 = URL(string: "http://rileytestut.com")!
let url2 = URL(string: "https://rileytestut.com")!
let sourceID = try Source.sourceID(from: url1)
XCTAssertEqual(sourceID, "rileytestut.com")
let sourceID2 = try Source.sourceID(from: url2)
XCTAssertEqual(sourceID, sourceID2)
}
func testSourceIDWithNonDefaultPort() throws
{
let url = URL(string: "http://localhost:8008/apps.json")!
let sourceID = try Source.sourceID(from: url)
XCTAssertEqual(sourceID, "localhost:8008/apps.json")
}
func testSourceIDWithFragmentsAndQueries() throws
{
var components = URLComponents(string: "https://disney.com/altstore/apps")!
components.fragment = "get started"
components.queryItems = [URLQueryItem(name: "id", value: "1234")]
let url1 = components.url!
components.queryItems = [URLQueryItem(name: "id", value: "5678")]
let url2 = components.url!
XCTAssertNotEqual(url1, url2)
let sourceID = try Source.sourceID(from: url1)
XCTAssertEqual(sourceID, "disney.com/altstore/apps")
let sourceID2 = try Source.sourceID(from: url2)
XCTAssertEqual(sourceID, sourceID2)
}
func testSourceIDWithDuplicateSlashes() throws
{
let url1 = URL(string: "http://rileytestut.co.nz//secret/altstore//apps.json")!
let url2 = URL(string: "http://rileytestut.co.nz/secret/altstore/apps.json//")!
let sourceID = try Source.sourceID(from: url1)
XCTAssertEqual(sourceID, "rileytestut.co.nz/secret/altstore/apps.json")
let sourceID2 = try Source.sourceID(from: url2)
XCTAssertEqual(sourceID, sourceID2)
}
func testSourceIDWithMixedCase() throws
{
let href = "https://rileyTESTUT.co.nz/test/PATH/ApPs.json"
let url1 = URL(string: href)!
let url2 = URL(string: href.lowercased())!
let sourceID = try Source.sourceID(from: url1)
XCTAssertEqual(sourceID, "rileytestut.co.nz/test/path/apps.json")
let sourceID2 = try Source.sourceID(from: url2)
XCTAssertEqual(sourceID, sourceID2)
}
func testSourceIDWithTrailingSlash() throws
{
let url1 = URL(string: "http://apps.altstore.io/")!
let url2 = URL(string: "http://apps.altstore.io")!
let sourceID = try Source.sourceID(from: url1)
XCTAssertEqual(sourceID, "apps.altstore.io")
let sourceID2 = try Source.sourceID(from: url2)
XCTAssertEqual(sourceID, sourceID2)
}
func testSourceIDWithLeadingWWW() throws
{
let url1 = URL(string: "http://www.GBA4iOSApp.com")!
let url2 = URL(string: "http://gba4iosapp.com")!
let sourceID = try Source.sourceID(from: url1)
XCTAssertEqual(sourceID, "gba4iosapp.com")
let sourceID2 = try Source.sourceID(from: url2)
XCTAssertEqual(sourceID, sourceID2)
}
func testSourceIDWithAllRules() throws
{
let url1 = URL(string: "fTp://WWW.apps.APPLE.com:4004//altstore apps/source.JSON?user=test@altstore.io#welcome//")!
let url2 = URL(string: "ftp://apps.apple.com:4004/altstore apps/source.json?user=anothertest@altstore.io#welcome")!
let sourceID = try Source.sourceID(from: url1)
XCTAssertEqual(sourceID, "apps.apple.com:4004/altstore apps/source.json")
let sourceID2 = try Source.sourceID(from: url2)
XCTAssertEqual(sourceID, sourceID2)
}
func testSourceIDWithEmoji() throws
{
let url1 = URL(string: "http://xn--g5h5981o.com")! // 🤷.com
let sourceID1 = try Source.sourceID(from: url1)
XCTAssertEqual(sourceID1, "🤷♂.com")
let url2 = URL(string: "http://www.xn--7r8h.io")! // www.💜.io
let sourceID2 = try Source.sourceID(from: url2)
XCTAssertEqual(sourceID2, "💜.io")
}
func testSourceIDWithRelativeURL() throws
{
let baseURL = URL(string: "https://rileytestut.com")!
let path = "altstore/apps.json"
let url1 = URL(string: path, relativeTo: baseURL)!
let url2 = baseURL.appendingPathComponent(path)
let sourceID = try Source.sourceID(from: url1)
XCTAssertEqual(sourceID, "rileytestut.com/altstore/apps.json")
let sourceID2 = try Source.sourceID(from: url2)
XCTAssertEqual(sourceID, sourceID2)
}
}

View File

@@ -31,7 +31,7 @@ enum TestErrorCode: Int, ALTErrorEnum, CaseIterable
}
}
extension TestError
extension DefaultLocalizedError<TestErrorCode>
{
static var allErrors: [TestError] {
return Code.allCases.map { TestError($0) }
@@ -70,12 +70,16 @@ extension VerificationError
static var testErrors: [VerificationError] {
let app = ALTApplication(fileURL: Bundle.main.bundleURL)!
return VerificationError.Code.allCases.map { code -> VerificationError in
return VerificationError.Code.allCases.compactMap { code -> VerificationError? in
switch code
{
case .privateEntitlements: return VerificationError.privateEntitlements(["dynamic-codesigning": true], app: app)
case .mismatchedBundleIdentifiers: return VerificationError.mismatchedBundleIdentifiers(sourceBundleID: "com.rileytestut.App", app: app)
case .iOSVersionNotSupported: return VerificationError.iOSVersionNotSupported(app: app, requiredOSVersion: OperatingSystemVersion(majorVersion: 21, minorVersion: 1, patchVersion: 0))
case .mismatchedHash: return VerificationError.mismatchedHash("12345", expectedHash: "67890", app: app)
case .mismatchedVersion: return VerificationError.mismatchedVersion("1.0", expectedVersion: "1.1", app: app)
case .mismatchedBuildVersion: return VerificationError.mismatchedBuildVersion("1", expectedVersion: "28", app: app)
case .undeclaredPermissions: return VerificationError.undeclaredPermissions([ALTEntitlement.appGroups, ALTAppPrivacyPermission.bluetooth], app: app)
case .addedPermissions: return nil //VerificationError.addedPermissions([ALTAppPrivacyPermission.appleMusic, ALTEntitlement.interAppAudio], appVersion: app)
}
}
}
@@ -219,7 +223,6 @@ extension OperationError
{
case .unknown: return .unknown()
case .unknownResult: return .unknownResult
case .cancelled: return .cancelled
case .timedOut: return .timedOut
case .notAuthenticated: return .notAuthenticated
case .appNotFound: return .appNotFound(name: "Delta")
@@ -233,6 +236,7 @@ extension OperationError
case .serverNotFound: return .serverNotFound
case .connectionFailed: return .connectionFailed
case .connectionDropped: return .connectionDropped
case .forbidden: return .forbidden()
}
}
}

View File

@@ -19,6 +19,8 @@ extern NSErrorUserInfoKey const ALTDeviceNameErrorKey;
extern NSErrorUserInfoKey const ALTOperatingSystemNameErrorKey;
extern NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey;
extern NSErrorUserInfoKey const ALTNSCodingPathKey;
typedef NS_ERROR_ENUM(AltServerErrorDomain, ALTServerError)
{
ALTServerErrorUnderlyingError = -1,

View File

@@ -30,6 +30,8 @@ NSErrorUserInfoKey const ALTDeviceNameErrorKey = @"deviceName";
NSErrorUserInfoKey const ALTOperatingSystemNameErrorKey = @"ALTOperatingSystemName";
NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey = @"ALTOperatingSystemVersion";
NSErrorUserInfoKey const ALTNSCodingPathKey = @"NSCodingPath";
@implementation NSError (ALTServerError)
+ (void)load