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

View File

@@ -29,7 +29,12 @@ class AppContentViewController: UITableViewController
{ {
var app: StoreApp! 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 = { private lazy var byteCountFormatter: ByteCountFormatter = {
let formatter = ByteCountFormatter() let formatter = ByteCountFormatter()
@@ -43,32 +48,17 @@ class AppContentViewController: UITableViewController
@IBOutlet private var versionDateLabel: UILabel! @IBOutlet private var versionDateLabel: UILabel!
@IBOutlet private var sizeLabel: 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(set) var appDetailCollectionViewController: AppDetailCollectionViewController!
@IBOutlet private var appDetailCollectionViewHeightConstraint: NSLayoutConstraint! @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() override func viewDidLoad()
{ {
super.viewDidLoad() super.viewDidLoad()
self.tableView.contentInset.bottom = 20 self.tableView.contentInset.bottom = 20
self.screenshotsCollectionView.dataSource = self.screenshotsDataSource
self.screenshotsCollectionView.prefetchDataSource = self.screenshotsDataSource
self.subtitleLabel.text = self.app.subtitle self.subtitleLabel.text = self.app.subtitle
self.descriptionTextView.text = self.app.localizedDescription self.descriptionTextView.text = self.app.localizedDescription
@@ -99,17 +89,24 @@ class AppContentViewController: UITableViewController
{ {
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
guard var size = self.preferredScreenshotSize else { return } var needsTableViewUpdate = false
size.height = min(size.height, self.screenshotsCollectionView.bounds.height) // Silence temporary "item too tall" warning.
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout let screenshotsHeight = self.appScreenshotsViewController.collectionView.contentSize.height
layout.itemSize = size if self.appScreenshotsHeightConstraint.constant != screenshotsHeight && screenshotsHeight > 0
{
self.appScreenshotsHeightConstraint.constant = screenshotsHeight
needsTableViewUpdate = true
}
let permissionsHeight = self.appDetailCollectionViewController.collectionView.contentSize.height let permissionsHeight = self.appDetailCollectionViewController.collectionView.contentSize.height
if self.appDetailCollectionViewHeightConstraint.constant != permissionsHeight && permissionsHeight > 0 if self.appDetailCollectionViewHeightConstraint.constant != permissionsHeight && permissionsHeight > 0
{ {
self.appDetailCollectionViewHeightConstraint.constant = permissionsHeight self.appDetailCollectionViewHeightConstraint.constant = permissionsHeight
needsTableViewUpdate = true
}
if needsTableViewUpdate
{
UIView.performWithoutAnimation { UIView.performWithoutAnimation {
// Update row height without animation. // Update row height without animation.
self.tableView.beginUpdates() self.tableView.beginUpdates()
@@ -121,40 +118,12 @@ class AppContentViewController: UITableViewController
private extension AppContentViewController 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]) let appScreenshotsViewController = AppScreenshotsViewController(app: self.app, coder: coder)
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in self.appScreenshotsViewController = appScreenshotsViewController
let cell = cell as! ScreenshotCollectionViewCell return appScreenshotsViewController
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
} }
@IBSegueAction @IBSegueAction
@@ -198,8 +167,8 @@ extension AppContentViewController
switch Row.allCases[indexPath.row] switch Row.allCases[indexPath.row]
{ {
case .screenshots: case .screenshots:
guard let size = self.preferredScreenshotSize else { return 0.0 } guard !self.app.allScreenshots.isEmpty else { return 0.0 }
return size.height return UITableView.automaticDimension
case .permissions: case .permissions:
guard !self.app.permissions.isEmpty else { return 0.0 } guard !self.app.permissions.isEmpty else { return 0.0 }

View File

@@ -142,6 +142,14 @@ class AppViewController: UIViewController
self.view.layoutIfNeeded() 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) override func viewDidAppear(_ animated: Bool)
{ {
super.viewDidAppear(animated) super.viewDidAppear(animated)
@@ -197,7 +205,7 @@ class AppViewController: UIViewController
{ {
statusBarHeight = 20 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 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--> <!--Browse-->
<scene sceneID="rXq-UR-qQp"> <scene sceneID="rXq-UR-qQp">
<objects> <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"> <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"/> <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
@@ -252,45 +252,34 @@
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/> <inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
</tableViewCell> </tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="nI6-wC-H2d"> <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"/> <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"> <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"/> <autoresizingMask key="autoresizingMask"/>
<subviews> <subviews>
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" dataMode="prototypes" translatesAutoresizingMaskIntoConstraints="NO" id="ppk-lL-at8"> <containerView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5yj-Nb-f5H">
<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"/>
<color key="backgroundColor" name="Background"/> <constraints>
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" minimumLineSpacing="10" minimumInteritemSpacing="15" id="ace-Ns-Jd2"> <constraint firstAttribute="height" priority="999" constant="300" id="dpf-ba-NNr"/>
<size key="itemSize" width="189" height="406"/> </constraints>
<size key="headerReferenceSize" width="0.0" height="0.0"/> <connections>
<size key="footerReferenceSize" width="0.0" height="0.0"/> <segue destination="nX2-hQ-qjX" kind="embed" destinationCreationSelector="makeAppScreenshotsViewController:sender:" id="VxG-Pu-Kf1"/>
<inset key="sectionInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/> </connections>
</collectionViewFlowLayout> </containerView>
<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>
</subviews> </subviews>
<constraints> <constraints>
<constraint firstAttribute="trailing" secondItem="ppk-lL-at8" secondAttribute="trailing" id="3QR-Y2-v26"/> <constraint firstAttribute="trailing" secondItem="5yj-Nb-f5H" secondAttribute="trailing" id="2DI-44-pC1"/>
<constraint firstAttribute="bottom" secondItem="ppk-lL-at8" secondAttribute="bottom" id="EgJ-Uw-5ta"/> <constraint firstItem="5yj-Nb-f5H" firstAttribute="top" secondItem="Z4y-vb-Z4Q" secondAttribute="top" id="URh-5T-73x"/>
<constraint firstItem="ppk-lL-at8" firstAttribute="leading" secondItem="Z4y-vb-Z4Q" secondAttribute="leading" id="wHf-S9-gMV"/> <constraint firstAttribute="bottom" secondItem="5yj-Nb-f5H" secondAttribute="bottom" id="Yb6-aZ-qNF"/>
<constraint firstItem="ppk-lL-at8" firstAttribute="top" secondItem="Z4y-vb-Z4Q" secondAttribute="top" id="xY5-w8-roA"/> <constraint firstItem="5yj-Nb-f5H" firstAttribute="leading" secondItem="Z4y-vb-Z4Q" secondAttribute="leading" id="rpG-Ip-qZU"/>
</constraints> </constraints>
</tableViewCellContentView> </tableViewCellContentView>
<color key="backgroundColor" name="Background"/> <color key="backgroundColor" name="Background"/>
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/> <inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
</tableViewCell> </tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="EL5-UC-RIw" customClass="AppContentTableViewCell" customModule="AltStore" customModuleProvider="target"> <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"/> <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"> <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"/> <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"/> <inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
</tableViewCell> </tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="47M-El-a4G" customClass="AppContentTableViewCell" customModule="AltStore" customModuleProvider="target"> <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"/> <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"> <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"/> <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"/> <inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
</tableViewCell> </tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" rowHeight="300" id="nM7-vJ-W8b"> <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"/> <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"> <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"/> <rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
@@ -437,8 +426,8 @@
<size key="freeformSize" width="375" height="667"/> <size key="freeformSize" width="375" height="667"/>
<connections> <connections>
<outlet property="appDetailCollectionViewHeightConstraint" destination="HFx-PP-dAt" id="ti3-q6-ku1"/> <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="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="sizeLabel" destination="DgM-bD-bBY" id="Oky-ax-u20"/>
<outlet property="subtitleLabel" destination="BsL-O2-UjD" id="cfe-cf-4a9"/> <outlet property="subtitleLabel" destination="BsL-O2-UjD" id="cfe-cf-4a9"/>
<outlet property="versionDateLabel" destination="wGD-mS-8fO" id="icB-lC-g9x"/> <outlet property="versionDateLabel" destination="wGD-mS-8fO" id="icB-lC-g9x"/>
@@ -450,6 +439,31 @@
</objects> </objects>
<point key="canvasLocation" x="3302" y="-18"/> <point key="canvasLocation" x="3302" y="-18"/>
</scene> </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--> <!--App Detail Collection View Controller-->
<scene sceneID="Pcn-h5-5fk"> <scene sceneID="Pcn-h5-5fk">
<objects> <objects>
@@ -468,7 +482,7 @@
</collectionViewController> </collectionViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="fxm-bB-W29" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/> <placeholder placeholderIdentifier="IBFirstResponder" id="fxm-bB-W29" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects> </objects>
<point key="canvasLocation" x="4070" y="-18"/> <point key="canvasLocation" x="4097" y="-19"/>
</scene> </scene>
<!--Settings--> <!--Settings-->
<scene sceneID="KlD-j0-ROn"> <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 dataSource = self.makeDataSource()
private lazy var placeholderView = RSTPlaceholderView(frame: .zero) 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 { private var loadingState: LoadingState = .loading {
didSet { didSet {
@@ -58,11 +58,14 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing
self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false 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.dataSource = self.dataSource
self.collectionView.prefetchDataSource = 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) (self as PeekPopPreviewing).registerForPreviewing(with: self, sourceView: self.collectionView)
if let source = self.source if let source = self.source
@@ -120,14 +123,11 @@ private extension BrowseViewController
let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: context) let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: context)
dataSource.cellConfigurationHandler = { (cell, app, indexPath) in 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.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right cell.layoutMargins.right = self.view.layoutMargins.right
cell.subtitleLabel.text = app.subtitle cell.configure(for: app)
cell.imageURLs = Array(app.screenshotURLs.prefix(2))
cell.bannerView.configure(for: app)
cell.bannerView.iconImageView.image = nil cell.bannerView.iconImageView.image = nil
cell.bannerView.iconImageView.isIndicatingActivity = true cell.bannerView.iconImageView.isIndicatingActivity = true
@@ -187,7 +187,7 @@ private extension BrowseViewController
} }
} }
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in 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.isIndicatingActivity = false
cell.bannerView.iconImageView.image = image cell.bannerView.iconImageView.image = image
@@ -366,21 +366,18 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
{ {
let item = self.dataSource.item(at: indexPath) 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 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) 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 widthConstraint.isActive = true
defer { widthConstraint.isActive = false } defer { widthConstraint.isActive = false }
@@ -388,17 +385,8 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout
self.prototypeCell.frame.size.width = widthConstraint.constant self.prototypeCell.frame.size.width = widthConstraint.constant
self.prototypeCell.layoutIfNeeded() 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) let itemSize = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
self.cachedItemSizes[item.bundleIdentifier] = itemSize self.cachedItemSizes[itemID] = itemSize
return itemSize return itemSize
} }
@@ -435,3 +423,16 @@ extension BrowseViewController: UIViewControllerPreviewingDelegate
self.navigationController?.pushViewController(viewControllerToCommit, animated: true) 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 duplicate
case missingPermissionUsageDescription case missingPermissionUsageDescription
case missingScreenshotSize
} }
static func unsupported(_ source: Source) -> SourceError { SourceError(code: .unsupported, source: source) } 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 { static func missingPermissionUsageDescription(for permission: any ALTAppPermission, app: StoreApp, source: Source) -> SourceError {
SourceError(code: .missingPermissionUsageDescription, source: source, app: app, permission: permission) 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 struct SourceError: ALTLocalizedError
@@ -59,6 +64,9 @@ struct SourceError: ALTLocalizedError
@UserInfoValue @UserInfoValue
var permission: (any ALTAppPermission)? var permission: (any ALTAppPermission)?
@UserInfoValue
var screenshotURL: URL?
var errorFailureReason: String { var errorFailureReason: String {
switch self.code switch self.code
{ {
@@ -112,6 +120,14 @@ struct SourceError: ALTLocalizedError
let permissionType = permission.type.localizedName ?? NSLocalizedString("Permission", comment: "") 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) let failureReason = String(format: NSLocalizedString("The %@ '%@' for %@ is missing a usage description.", comment: ""), permissionType.lowercased(), permission.rawValue, appName)
return failureReason 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.managedObjectContext = childContext
decoder.sourceURL = self.sourceURL 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 let identifier = source.identifier
try self.verify(source, response: response) try self.verify(source, response: response)
@@ -181,6 +209,12 @@ private extension FetchSourceOperation
// Privacy permissions MUST have a usage description. // Privacy permissions MUST have a usage description.
guard permission.usageDescription != nil else { throw SourceError.missingPermissionUsageDescription(for: permission.permission, app: app, source: source) } 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 if let previousSourceID = self.$source.identifier

View File

@@ -212,41 +212,23 @@ private extension VerifyAppOperation
// Privacy // Privacy
let allPrivacyPermissions: Set<ALTAppPrivacyPermission> let allPrivacyPermissions = ([app] + app.appExtensions).flatMap { (app) in
if #available(iOS 16, *) let permissions = app.bundle.infoDictionary?.keys.compactMap { key -> ALTAppPrivacyPermission? in
{ if #available(iOS 16, *)
let regex = Regex { {
"NS" guard key.wholeMatch(of: Regex.privacyPermission) != nil else { return nil }
}
// Capture permission "name" else
Capture { {
OneOrMore(.anyGraphemeCluster) guard key.contains("UsageDescription") else { return nil }
} }
"UsageDescription" let permission = ALTAppPrivacyPermission(rawValue: key)
return permission
// Optional suffix } ?? []
Optionally(OneOrMore(.anyGraphemeCluster))
}
let privacyPermissions = ([app] + app.appExtensions).flatMap { (app) in return permissions
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)
} }
else
{
allPrivacyPermissions = []
}
// Verify permissions. // Verify permissions.
let sourcePermissions: Set<AnyHashable> = Set(await $storeApp.perform { $0.permissions.map { AnyHashable($0.permission) } }) 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. // To pass: EVERY permission in localPermissions must also appear in sourcePermissions.
// If there is a single missing permission, throw error. // If there is a single missing permission, throw error.
let missingPermissions: [any ALTAppPermission] = localPermissions.filter { !sourcePermissions.contains(AnyHashable($0)) } let missingPermissions: [any ALTAppPermission] = localPermissions.filter { permission in
guard missingPermissions.isEmpty else { throw VerificationError.undeclaredPermissions(missingPermissions, app: app) } 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 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"?> <?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"> <entity name="Account" representedClassName="Account" syncable="YES">
<attribute name="appleID" attributeType="String"/> <attribute name="appleID" attributeType="String"/>
<attribute name="firstName" attributeType="String"/> <attribute name="firstName" attributeType="String"/>
@@ -42,6 +42,23 @@
</uniquenessConstraint> </uniquenessConstraint>
</uniquenessConstraints> </uniquenessConstraints>
</entity> </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"> <entity name="AppVersion" representedClassName="AppVersion" syncable="YES">
<attribute name="appBundleID" attributeType="String"/> <attribute name="appBundleID" attributeType="String"/>
<attribute name="buildVersion" 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="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="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="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="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"/> <relationship name="versions" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="AppVersion" inverseName="app" inverseEntity="AppVersion"/>
<uniquenessConstraints> <uniquenessConstraints>

View File

@@ -12,7 +12,7 @@ import UIKit
import AltSign import AltSign
@objc(AppPermission) @dynamicMemberLookup @objc(AppPermission) @dynamicMemberLookup
public class AppPermission: NSManagedObject, Decodable, Fetchable public class AppPermission: NSManagedObject, Fetchable
{ {
/* Properties */ /* Properties */
@NSManaged public var type: ALTAppPermissionType @NSManaged public var type: ALTAppPermissionType
@@ -47,37 +47,13 @@ public class AppPermission: NSManagedObject, Decodable, Fetchable
super.init(entity: entity, insertInto: context) super.init(entity: entity, insertInto: context)
} }
private enum CodingKeys: String, CodingKey convenience init(permission: String, usageDescription: String?, type: ALTAppPermissionType, context: NSManagedObjectContext)
{ {
case name self.init(entity: AppPermission.entity(), insertInto: context)
case usageDescription
}
public required init(from decoder: Decoder) throws
{
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
super.init(entity: AppPermission.entity(), insertInto: context) self._permission = permission
self.usageDescription = usageDescription
do self.type = type
{
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
}
} }
} }
@@ -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. // Make sure to always update source URL to be current.
altStoreSource.sourceURL = Source.altStoreSourceURL try! altStoreSource.setSourceURL(Source.altStoreSourceURL)
let storeApp: StoreApp let storeApp: StoreApp

View File

@@ -156,6 +156,12 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
{ {
appVersion.managedObjectContext?.delete(appVersion) 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: case is AppVersion where conflict.conflictingObjects.count == 2:
@@ -181,8 +187,9 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
return return
} }
var sortedVersionIDsByGlobalAppID = [String: NSOrderedSet]()
var permissionsByGlobalAppID = [String: Set<AnyHashable>]() var permissionsByGlobalAppID = [String: Set<AnyHashable>]()
var sortedVersionIDsByGlobalAppID = [String: NSOrderedSet]()
var sortedScreenshotIDsByGlobalAppID = [String: NSOrderedSet]()
var featuredAppIDsBySourceID = [String: [String]]() var featuredAppIDsBySourceID = [String: [String]]()
@@ -212,10 +219,19 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
databaseVersion.managedObjectContext?.delete(databaseVersion) 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 if let globallyUniqueID = contextApp.globallyUniqueID
{ {
permissionsByGlobalAppID[globallyUniqueID] = contextPermissions permissionsByGlobalAppID[globallyUniqueID] = contextPermissions
sortedVersionIDsByGlobalAppID[globallyUniqueID] = contextVersionIDs sortedVersionIDsByGlobalAppID[globallyUniqueID] = contextVersionIDs
sortedScreenshotIDsByGlobalAppID[globallyUniqueID] = contextScreenshotIDs
} }
case let databaseObject as Source: case let databaseObject as Source:
@@ -295,6 +311,31 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
appVersions = fixedAppVersions 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. // Always update versions post-merging to make sure latestSupportedVersion is correct.

View File

@@ -11,11 +11,7 @@ import UIKit
public extension Source public extension Source
{ {
#if ALPHA static let altStoreIdentifier = try! Source.sourceID(from: Source.altStoreSourceURL)
static let altStoreIdentifier = "com.rileytestut.AltStore.Alpha"
#else
static let altStoreIdentifier = "com.rileytestut.AltStore"
#endif
#if STAGING #if STAGING
@@ -62,7 +58,7 @@ public class Source: NSManagedObject, Fetchable, Decodable
{ {
/* Properties */ /* Properties */
@NSManaged public var name: String @NSManaged public var name: String
@NSManaged public var identifier: String @NSManaged public private(set) var identifier: String
@NSManaged public var sourceURL: URL @NSManaged public var sourceURL: URL
/* Source Detail */ /* Source Detail */
@@ -114,7 +110,6 @@ public class Source: NSManagedObject, Fetchable, Decodable
private enum CodingKeys: String, CodingKey private enum CodingKeys: String, CodingKey
{ {
case name case name
case identifier
case sourceURL case sourceURL
case subtitle case subtitle
case localizedDescription = "description" case localizedDescription = "description"
@@ -143,11 +138,8 @@ public class Source: NSManagedObject, Fetchable, Decodable
do do
{ {
self.sourceURL = sourceURL
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
self.name = try container.decode(String.self, forKey: .name) self.name = try container.decode(String.self, forKey: .name)
self.identifier = try container.decode(String.self, forKey: .identifier)
// Optional Values // Optional Values
self.subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle) 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() for (index, app) in apps.enumerated()
{ {
app.sourceIdentifier = self.identifier
app.sortIndex = Int32(index) app.sortIndex = Int32(index)
} }
self._apps = NSMutableOrderedSet(array: apps) self._apps = NSMutableOrderedSet(array: apps)
@@ -181,7 +172,6 @@ public class Source: NSManagedObject, Fetchable, Decodable
let newsItems = try container.decodeIfPresent([NewsItem].self, forKey: .news) ?? [] let newsItems = try container.decodeIfPresent([NewsItem].self, forKey: .news) ?? []
for (index, item) in newsItems.enumerated() for (index, item) in newsItems.enumerated()
{ {
item.sourceIdentifier = self.identifier
item.sortIndex = Int32(index) item.sortIndex = Int32(index)
} }
@@ -203,6 +193,9 @@ public class Source: NSManagedObject, Fetchable, Decodable
let featuredAppBundleIDs = try container.decodeIfPresent([String].self, forKey: .featuredApps) let featuredAppBundleIDs = try container.decodeIfPresent([String].self, forKey: .featuredApps)
let featuredApps = featuredAppBundleIDs?.compactMap { appsByID[$0] } let featuredApps = featuredAppBundleIDs?.compactMap { appsByID[$0] }
self.setFeaturedApps(featuredApps) self.setFeaturedApps(featuredApps)
// Updates identifier + apps & newsItems
try self.setSourceURL(sourceURL)
} }
catch catch
{ {
@@ -257,6 +250,54 @@ public extension Source
internal 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]?) func setFeaturedApps(_ featuredApps: [StoreApp]?)
{ {
// Explicitly update relationships for all apps to ensure featuredApps merges correctly. // 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 public extension Source
{ {
@nonobjc class func fetchRequest() -> NSFetchRequest<Source> @nonobjc class func fetchRequest() -> NSFetchRequest<Source>
@@ -289,8 +351,7 @@ public extension Source
{ {
let source = Source(context: context) let source = Source(context: context)
source.name = "AltStore" source.name = "AltStore"
source.identifier = Source.altStoreIdentifier try! source.setSourceURL(Source.altStoreSourceURL)
source.sourceURL = Source.altStoreSourceURL
return source return source
} }

View File

@@ -23,12 +23,6 @@ public extension StoreApp
#endif #endif
static let dolphinAppID = "me.oatmealdome.dolphinios-njb" static let dolphinAppID = "me.oatmealdome.dolphinios-njb"
private struct AppPermissions: Decodable
{
var entitlements: [AppPermission]?
var privacy: [AppPermission]?
}
} }
@objc(StoreApp) @objc(StoreApp)
@@ -74,6 +68,11 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
{ {
permission.sourceID = self.sourceIdentifier ?? "" permission.sourceID = self.sourceIdentifier ?? ""
} }
for screenshot in self.allScreenshots
{
screenshot.sourceID = self.sourceIdentifier ?? ""
}
} }
} }
@NSManaged private var primitiveSourceIdentifier: String? @NSManaged private var primitiveSourceIdentifier: String?
@@ -114,6 +113,11 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
return self._versions.array as! [AppVersion] 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?) private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
{ {
super.init(entity: entity, insertInto: context) super.init(entity: entity, insertInto: context)
@@ -125,18 +129,21 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
case bundleIdentifier case bundleIdentifier
case developerName case developerName
case localizedDescription case localizedDescription
case version
case versionDescription
case versionDate
case iconURL case iconURL
case screenshotURLs case screenshots
case downloadURL
case tintColor case tintColor
case subtitle case subtitle
case permissions = "appPermissions" case permissions = "appPermissions"
case size case size
case isBeta = "beta" case isBeta = "beta"
case versions case versions
// Legacy
case version
case versionDescription
case versionDate
case downloadURL
case screenshotURLs
} }
public required init(from decoder: Decoder) throws 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.subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle)
self.iconURL = try container.decode(URL.self, forKey: .iconURL) 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) 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 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) if let appPermissions = try container.decodeIfPresent(AppPermissions.self, forKey: .permissions)
{ {
appPermissions.entitlements?.forEach { $0.type = .entitlement } let allPermissions = appPermissions.entitlements + appPermissions.privacy
appPermissions.privacy?.forEach { $0.type = .privacy }
let allPermissions = (appPermissions.entitlements ?? []) + (appPermissions.privacy ?? [])
for permission in allPermissions for permission in allPermissions
{ {
permission.appBundleID = self.bundleIdentifier permission.appBundleID = self.bundleIdentifier
@@ -279,6 +310,58 @@ internal extension StoreApp
self._permissions = permissions as NSSet 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 public extension StoreApp

View File

@@ -6,6 +6,8 @@
// Copyright © 2023 Riley Testut. All rights reserved. // Copyright © 2023 Riley Testut. All rights reserved.
// //
import RegexBuilder
import AltSign import AltSign
extension ALTAppPermissionType extension ALTAppPermissionType
@@ -156,5 +158,10 @@ extension ALTAppPrivacyPermission: ALTAppPermission
{ {
public var type: ALTAppPermissionType { .privacy } 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> </dict>
<key>privacy</key> <key>privacy</key>
<dict> <dict>
<key>BluetoothAlways</key> <key>NSBluetoothAlwaysUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Bluetooth</string> <string>Bluetooth</string>
<key>key</key> <key>key</key>
<string>BluetoothAlways</string> <string>NSBluetoothAlwaysUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>antenna.radiowaves.left.and.right</string> <string>antenna.radiowaves.left.and.right</string>
</dict> </dict>
<key>BluetoothPeripheral</key> <key>NSBluetoothPeripheralUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Bluetooth (Peripherals)</string> <string>Bluetooth (Peripherals)</string>
<key>key</key> <key>key</key>
<string>BluetoothPeripheral</string> <string>NSBluetoothPeripheralUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>antenna.radiowaves.left.and.right</string> <string>antenna.radiowaves.left.and.right</string>
</dict> </dict>
<key>Calendars</key> <key>NSCalendarsUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Calendars</string> <string>Calendars</string>
<key>key</key> <key>key</key>
<string>Calendars</string> <string>NSCalendarsUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>calendar</string> <string>calendar</string>
</dict> </dict>
<key>Reminders</key> <key>NSRemindersUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Reminders</string> <string>Reminders</string>
<key>key</key> <key>key</key>
<string>Reminders</string> <string>NSRemindersUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>note.text</string> <string>note.text</string>
</dict> </dict>
<key>Camera</key> <key>NSCameraUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Camera</string> <string>Camera</string>
<key>key</key> <key>key</key>
<string>Camera</string> <string>NSCameraUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>camera</string> <string>camera</string>
</dict> </dict>
<key>Microphone</key> <key>NSMicrophoneUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Microphone</string> <string>Microphone</string>
<key>key</key> <key>key</key>
<string>Microphone</string> <string>NSMicrophoneUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>mic</string> <string>mic</string>
</dict> </dict>
<key>Contacts</key> <key>NSContactsUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Contacts</string> <string>Contacts</string>
<key>key</key> <key>key</key>
<string>Contacts</string> <string>NSContactsUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>person.crop.circle</string> <string>person.crop.circle</string>
</dict> </dict>
<key>FaceID</key> <key>NSFaceIDUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Face ID</string> <string>Face ID</string>
<key>key</key> <key>key</key>
<string>FaceID</string> <string>NSFaceIDUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>faceid</string> <string>faceid</string>
</dict> </dict>
<key>GKFriendList</key> <key>NSGKFriendListUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Game Center Friends List</string> <string>Game Center Friends List</string>
<key>key</key> <key>key</key>
<string>GKFriendList</string> <string>NSGKFriendListUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>gamecontroller</string> <string>gamecontroller</string>
</dict> </dict>
<key>HealthClinicalHealthRecordsShare</key> <key>NSHealthClinicalHealthRecordsShareUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Health (Clinical Records)</string> <string>Health (Clinical Records)</string>
<key>key</key> <key>key</key>
<string>HealthClinicalHealthRecordsShare</string> <string>NSHealthClinicalHealthRecordsShareUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>heart.text.square</string> <string>heart.text.square</string>
</dict> </dict>
<key>HealthShare</key> <key>NSHealthShareUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Health</string> <string>Health</string>
<key>key</key> <key>key</key>
<string>HealthShare</string> <string>NSHealthShareUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>heart.text.square</string> <string>heart.text.square</string>
</dict> </dict>
<key>HealthUpdate</key> <key>NSHealthUpdateUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Health (Add)</string> <string>Health (Add)</string>
<key>key</key> <key>key</key>
<string>HealthUpdate</string> <string>NSHealthUpdateUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>heart.text.square</string> <string>heart.text.square</string>
</dict> </dict>
<key>HomeKit</key> <key>NSHomeKitUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>HomeKit</string> <string>HomeKit</string>
<key>key</key> <key>key</key>
<string>HomeKit</string> <string>NSHomeKitUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>homekit</string> <string>homekit</string>
</dict> </dict>
<key>LocationAlways</key> <key>NSLocationAlwaysUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Location (Background)</string> <string>Location (Background)</string>
<key>key</key> <key>key</key>
<string>LocationAlways</string> <string>NSLocationAlwaysUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>location.fill</string> <string>location.fill</string>
</dict> </dict>
<key>LocationAlwaysAndWhenInUse</key> <key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Location (Always)</string> <string>Location (Always)</string>
<key>key</key> <key>key</key>
<string>LocationAlwaysAndWhenInUse</string> <string>NSLocationAlwaysAndWhenInUseUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>location.fill</string> <string>location.fill</string>
</dict> </dict>
<key>Location</key> <key>NSLocationUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Location</string> <string>Location</string>
<key>key</key> <key>key</key>
<string>Location</string> <string>NSLocationUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>location.fill</string> <string>location.fill</string>
</dict> </dict>
<key>LocationWhenInUse</key> <key>NSLocationWhenInUseUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Location (When Using)</string> <string>Location (When Using)</string>
<key>key</key> <key>key</key>
<string>LocationWhenInUse</string> <string>NSLocationWhenInUseUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>location</string> <string>location</string>
</dict> </dict>
<key>LocationTemporary</key> <key>NSLocationTemporaryUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Location (Temporary)</string> <string>Location (Temporary)</string>
<key>key</key> <key>key</key>
<string>LocationTemporary</string> <string>NSLocationTemporaryUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>location</string> <string>location</string>
</dict> </dict>
<key>AppleMusic</key> <key>NSAppleMusicUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Apple Music</string> <string>Apple Music</string>
<key>key</key> <key>key</key>
<string>AppleMusic</string> <string>NSAppleMusicUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>music.note</string> <string>music.note</string>
</dict> </dict>
<key>Motion</key> <key>NSMotionUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Motion</string> <string>Motion</string>
<key>key</key> <key>key</key>
<string>Motion</string> <string>NSMotionUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>figure.run</string> <string>figure.run</string>
</dict> </dict>
<key>FallDetection</key> <key>NSFallDetectionUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Fall Detection</string> <string>Fall Detection</string>
<key>key</key> <key>key</key>
<string>FallDetection</string> <string>NSFallDetectionUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>figure.fall</string> <string>figure.fall</string>
</dict> </dict>
<key>LocalNetwork</key> <key>NSLocalNetworkUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Local Network</string> <string>Local Network</string>
<key>key</key> <key>key</key>
<string>LocalNetwork</string> <string>NSLocalNetworkUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>network</string> <string>network</string>
</dict> </dict>
<key>NearbyInteraction</key> <key>NSNearbyInteractionUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Nearby Interaction</string> <string>Nearby Interaction</string>
<key>key</key> <key>key</key>
<string>NearbyInteraction</string> <string>NSNearbyInteractionUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>dot.radiowaves.left.and.right</string> <string>dot.radiowaves.left.and.right</string>
</dict> </dict>
<key>NearbyInteractionAllowOnce</key> <key>NSNearbyInteractionAllowOnceUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Nearby Interaction (Once)</string> <string>Nearby Interaction (Once)</string>
<key>key</key> <key>key</key>
<string>NearbyInteractionAllowOnce</string> <string>NSNearbyInteractionAllowOnceUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>dot.radiowaves.left.and.right</string> <string>dot.radiowaves.left.and.right</string>
</dict> </dict>
<key>NFCReader</key> <key>NFCReaderUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>NFC</string> <string>NFC</string>
<key>key</key> <key>key</key>
<string>NFCReader</string> <string>NSNFCReaderUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>sensor.tag.radiowaves.forward</string> <string>sensor.tag.radiowaves.forward</string>
</dict> </dict>
<key>PhotoLibraryAdd</key> <key>NSPhotoLibraryAddUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Photos (Add)</string> <string>Photos (Add)</string>
<key>key</key> <key>key</key>
<string>PhotoLibraryAdd</string> <string>NSPhotoLibraryAddUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>photo</string> <string>photo</string>
</dict> </dict>
<key>PhotoLibrary</key> <key>NSPhotoLibraryUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Photos</string> <string>Photos</string>
<key>key</key> <key>key</key>
<string>PhotoLibrary</string> <string>NSPhotoLibraryUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>photo.stack</string> <string>photo.stack</string>
</dict> </dict>
<key>UserTracking</key> <key>NSUserTrackingUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>User Tracking</string> <string>User Tracking</string>
<key>key</key> <key>key</key>
<string>UserTracking</string> <string>NSUserTrackingUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>location.magnifyingglass</string> <string>location.magnifyingglass</string>
</dict> </dict>
<key>SensorKit</key> <key>NSSensorKitUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>SensorKit</string> <string>SensorKit</string>
<key>key</key> <key>key</key>
<string>SensorKit</string> <string>NSSensorKitUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>sensor</string> <string>sensor</string>
</dict> </dict>
<key>Siri</key> <key>NSSiriUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Siri</string> <string>Siri</string>
<key>key</key> <key>key</key>
<string>Siri</string> <string>NSSiriUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>mic.circle</string> <string>mic.circle</string>
</dict> </dict>
<key>SpeechRecognition</key> <key>NSSpeechRecognitionUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>Speech Recognition</string> <string>Speech Recognition</string>
<key>key</key> <key>key</key>
<string>SpeechRecognition</string> <string>NSSpeechRecognitionUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>waveform.and.mic</string> <string>waveform.and.mic</string>
</dict> </dict>
<key>Identity</key> <key>NSIdentityUsageDescription</key>
<dict> <dict>
<key>name</key> <key>name</key>
<string>ID Cards</string> <string>ID Cards</string>
<key>key</key> <key>key</key>
<string>Identity</string> <string>NSIdentityUsageDescription</string>
<key>symbol</key> <key>symbol</key>
<string>wallet.pass</string> <string>wallet.pass</string>
</dict> </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] { static var allErrors: [TestError] {
return Code.allCases.map { TestError($0) } return Code.allCases.map { TestError($0) }
@@ -70,12 +70,16 @@ extension VerificationError
static var testErrors: [VerificationError] { static var testErrors: [VerificationError] {
let app = ALTApplication(fileURL: Bundle.main.bundleURL)! 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 switch code
{ {
case .privateEntitlements: return VerificationError.privateEntitlements(["dynamic-codesigning": true], app: app)
case .mismatchedBundleIdentifiers: return VerificationError.mismatchedBundleIdentifiers(sourceBundleID: "com.rileytestut.App", 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 .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 .unknown: return .unknown()
case .unknownResult: return .unknownResult case .unknownResult: return .unknownResult
case .cancelled: return .cancelled
case .timedOut: return .timedOut case .timedOut: return .timedOut
case .notAuthenticated: return .notAuthenticated case .notAuthenticated: return .notAuthenticated
case .appNotFound: return .appNotFound(name: "Delta") case .appNotFound: return .appNotFound(name: "Delta")
@@ -233,6 +236,7 @@ extension OperationError
case .serverNotFound: return .serverNotFound case .serverNotFound: return .serverNotFound
case .connectionFailed: return .connectionFailed case .connectionFailed: return .connectionFailed
case .connectionDropped: return .connectionDropped 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 ALTOperatingSystemNameErrorKey;
extern NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey; extern NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey;
extern NSErrorUserInfoKey const ALTNSCodingPathKey;
typedef NS_ERROR_ENUM(AltServerErrorDomain, ALTServerError) typedef NS_ERROR_ENUM(AltServerErrorDomain, ALTServerError)
{ {
ALTServerErrorUnderlyingError = -1, ALTServerErrorUnderlyingError = -1,

View File

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