mirror of
https://github.com/SideStore/SideStore.git
synced 2026-02-10 15:23:27 +01:00
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:
@@ -202,7 +202,6 @@
|
||||
BF989184250AACFC002ACF50 /* Date+RelativeDate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB5B1522EE90D300F74113 /* Date+RelativeDate.swift */; };
|
||||
BF989185250AAD1D002ACF50 /* UIColor+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD2479E2284FBD000981D42 /* UIColor+AltStore.swift */; };
|
||||
BF9ABA4522DCFF43008935CF /* BrowseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4422DCFF43008935CF /* BrowseViewController.swift */; };
|
||||
BF9ABA4722DD0638008935CF /* BrowseCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4622DD0638008935CF /* BrowseCollectionViewCell.swift */; };
|
||||
BF9ABA4922DD0742008935CF /* ScreenshotCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4822DD0742008935CF /* ScreenshotCollectionViewCell.swift */; };
|
||||
BF9ABA4B22DD1380008935CF /* NavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4A22DD137F008935CF /* NavigationBar.swift */; };
|
||||
BF9ABA4D22DD16DE008935CF /* PillButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF9ABA4C22DD16DE008935CF /* PillButton.swift */; };
|
||||
@@ -285,7 +284,6 @@
|
||||
BFD52C2122A1A9EC000B7ED1 /* node_list.c in Sources */ = {isa = PBXBuildFile; fileRef = BFD52C1E22A1A9EC000B7ED1 /* node_list.c */; };
|
||||
BFD52C2222A1A9EC000B7ED1 /* cnary.c in Sources */ = {isa = PBXBuildFile; fileRef = BFD52C1F22A1A9EC000B7ED1 /* cnary.c */; };
|
||||
BFD6B03322DFF20800B86064 /* MyAppsComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFD6B03222DFF20800B86064 /* MyAppsComponents.swift */; };
|
||||
BFDB5B2622EFBBEA00F74113 /* BrowseCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = BFDB5B2522EFBBEA00F74113 /* BrowseCollectionViewCell.xib */; };
|
||||
BFDB6A0822AAED73007EA6D6 /* ResignAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0722AAED73007EA6D6 /* ResignAppOperation.swift */; };
|
||||
BFDB6A0B22AAEDB7007EA6D6 /* Operation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0A22AAEDB7007EA6D6 /* Operation.swift */; };
|
||||
BFDB6A0D22AAFC1A007EA6D6 /* OperationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BFDB6A0C22AAFC19007EA6D6 /* OperationError.swift */; };
|
||||
@@ -370,12 +368,15 @@
|
||||
D54058B92A1D6269008CCC58 /* AppPermissionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54058B82A1D6269008CCC58 /* AppPermissionProtocol.swift */; };
|
||||
D54058BB2A1D8FE3008CCC58 /* UIColor+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */; };
|
||||
D540E93828EE1BDE000F1B0F /* ErrorDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D540E93728EE1BDE000F1B0F /* ErrorDetailsViewController.swift */; };
|
||||
D5418F172AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */; };
|
||||
D54DED1428CBC44B008B27A0 /* ErrorLogTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */; };
|
||||
D552B1D82A042A740066216F /* AppPermissionsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D552B1D72A042A740066216F /* AppPermissionsCard.swift */; };
|
||||
D55467B82A8D5E2600F4CE90 /* AppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */; };
|
||||
D55467C52A8D72C300F4CE90 /* ActiveAppsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */; };
|
||||
D561B2EB28EF5A4F006752E4 /* AltSign-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; };
|
||||
D561B2ED28EF5A4F006752E4 /* AltSign-Dynamic in Embed Frameworks */ = {isa = PBXBuildFile; productRef = D561B2EA28EF5A4F006752E4 /* AltSign-Dynamic */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
|
||||
D56915072AD5E91B00A2B747 /* Regex+Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */; };
|
||||
D56915092AD5F3E800A2B747 /* AltTests+Sources.swift in Sources */ = {isa = PBXBuildFile; fileRef = D56915082AD5F3E800A2B747 /* AltTests+Sources.swift */; };
|
||||
D5708417292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; };
|
||||
D570841A2924680D00D42D34 /* OperatingSystemVersion+Comparable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */; };
|
||||
D571ADD02A02FC7200B24B63 /* ALTAppPermission.swift in Sources */ = {isa = PBXBuildFile; fileRef = D571ADCF2A02FC7200B24B63 /* ALTAppPermission.swift */; };
|
||||
@@ -417,14 +418,15 @@
|
||||
D5A299892AAB9E5900A3988D /* AppProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = D59A6B7D2AA9226C00F61259 /* AppProcess.swift */; };
|
||||
D5ACE84528E3B8450021CAB9 /* ClearAppCacheOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */; };
|
||||
D5B6F6A92AD75D01007EED5A /* ProcessInfo+Previews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */; };
|
||||
D5B6F6AB2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */; };
|
||||
D5BA9E9B2A9FE1E8007C0661 /* JITManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5BA9E9A2A9FE1E8007C0661 /* JITManager.swift */; };
|
||||
D5C0E7672AD9C75900530CA4 /* AppCardCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C0E7662AD9C75900530CA4 /* AppCardCollectionViewCell.swift */; };
|
||||
D5C8ACDB2A956B2B00669F92 /* Process+STPrivilegedTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C8ACDA2A956B2B00669F92 /* Process+STPrivilegedTask.swift */; };
|
||||
D5CA0C4B280E141900469595 /* ManagedPatron.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CA0C4A280E141900469595 /* ManagedPatron.swift */; };
|
||||
D5CA0C4E280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = D5CA0C4D280E249E00469595 /* AltStore9ToAltStore10.xcmappingmodel */; };
|
||||
D5CD805D29CA2C1E00E591B0 /* HeaderContentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CD805C29CA2C1E00E591B0 /* HeaderContentViewController.swift */; };
|
||||
D5CD805F29CA755E00E591B0 /* SourceDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CD805E29CA755E00E591B0 /* SourceDetailViewController.swift */; };
|
||||
D5CF568C2A0D8EEC006D93E2 /* VerificationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CF56812A0D83F9006D93E2 /* VerificationError.swift */; };
|
||||
D5DAE0942804B0B80034D8D4 /* ScreenshotProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */; };
|
||||
D5DAE0962804DF430034D8D4 /* UpdatePatronsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */; };
|
||||
D5DB145A28F9DC5A00A8F606 /* ALTLocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */; };
|
||||
D5DB145B28F9DC5C00A8F606 /* ALTLocalizedError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */; };
|
||||
@@ -435,6 +437,8 @@
|
||||
D5F48B4C29CD0C48002B52A4 /* AsyncManaged.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F48B4929CD0B67002B52A4 /* AsyncManaged.swift */; };
|
||||
D5F5AF2E28FDD2EC00C938F5 /* TestErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F5AF2D28FDD2EC00C938F5 /* TestErrors.swift */; };
|
||||
D5F5AF7D28ECEA990067C736 /* ErrorDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */; };
|
||||
D5F9821D2AB900060045751F /* AppScreenshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F9821C2AB900060045751F /* AppScreenshot.swift */; };
|
||||
D5F982212AB910180045751F /* AppScreenshotsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F982202AB910180045751F /* AppScreenshotsViewController.swift */; };
|
||||
D5F99A1828D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */; };
|
||||
D5F99A1A28D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */; };
|
||||
D5FB28EC2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */; };
|
||||
@@ -834,7 +838,6 @@
|
||||
BF989190250AAE86002ACF50 /* ViewAppIntentHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewAppIntentHandler.swift; sourceTree = "<group>"; };
|
||||
BF989191250AAE86002ACF50 /* ViewApp.intentdefinition */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.intentdefinition; path = ViewApp.intentdefinition; sourceTree = "<group>"; };
|
||||
BF9ABA4422DCFF43008935CF /* BrowseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewController.swift; sourceTree = "<group>"; };
|
||||
BF9ABA4622DD0638008935CF /* BrowseCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
BF9ABA4822DD0742008935CF /* ScreenshotCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
BF9ABA4A22DD137F008935CF /* NavigationBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationBar.swift; sourceTree = "<group>"; };
|
||||
BF9ABA4C22DD16DE008935CF /* PillButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillButton.swift; sourceTree = "<group>"; };
|
||||
@@ -912,7 +915,6 @@
|
||||
BFD52C1F22A1A9EC000B7ED1 /* cnary.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; name = cnary.c; path = Dependencies/libplist/libcnary/cnary.c; sourceTree = SOURCE_ROOT; };
|
||||
BFD6B03222DFF20800B86064 /* MyAppsComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAppsComponents.swift; sourceTree = "<group>"; };
|
||||
BFDB5B1522EE90D300F74113 /* Date+RelativeDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+RelativeDate.swift"; sourceTree = "<group>"; };
|
||||
BFDB5B2522EFBBEA00F74113 /* BrowseCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BrowseCollectionViewCell.xib; sourceTree = "<group>"; };
|
||||
BFDB6A0722AAED73007EA6D6 /* ResignAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResignAppOperation.swift; sourceTree = "<group>"; };
|
||||
BFDB6A0A22AAEDB7007EA6D6 /* Operation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Operation.swift; sourceTree = "<group>"; };
|
||||
BFDB6A0C22AAFC19007EA6D6 /* OperationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationError.swift; sourceTree = "<group>"; };
|
||||
@@ -976,10 +978,13 @@
|
||||
D54058B82A1D6269008CCC58 /* AppPermissionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionProtocol.swift; sourceTree = "<group>"; };
|
||||
D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+AltStore.swift"; sourceTree = "<group>"; };
|
||||
D540E93728EE1BDE000F1B0F /* ErrorDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsViewController.swift; sourceTree = "<group>"; };
|
||||
D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshotCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorLogTableViewCell.swift; sourceTree = "<group>"; };
|
||||
D552B1D72A042A740066216F /* AppPermissionsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionsCard.swift; sourceTree = "<group>"; };
|
||||
D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppShortcuts.swift; sourceTree = "<group>"; };
|
||||
D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveAppsWidget.swift; sourceTree = "<group>"; };
|
||||
D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Regex+Permissions.swift"; sourceTree = "<group>"; };
|
||||
D56915082AD5F3E800A2B747 /* AltTests+Sources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AltTests+Sources.swift"; sourceTree = "<group>"; };
|
||||
D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Comparable.swift"; sourceTree = "<group>"; };
|
||||
D571ADCD2A02FA7400B24B63 /* SourceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceError.swift; sourceTree = "<group>"; };
|
||||
D571ADCF2A02FC7200B24B63 /* ALTAppPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ALTAppPermission.swift; sourceTree = "<group>"; };
|
||||
@@ -1020,7 +1025,9 @@
|
||||
D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeprecatedAPIs.swift; sourceTree = "<group>"; };
|
||||
D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearAppCacheOperation.swift; sourceTree = "<group>"; };
|
||||
D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+Previews.swift"; sourceTree = "<group>"; };
|
||||
D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewAppScreenshotsViewController.swift; sourceTree = "<group>"; };
|
||||
D5BA9E9A2A9FE1E8007C0661 /* JITManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JITManager.swift; sourceTree = "<group>"; };
|
||||
D5C0E7662AD9C75900530CA4 /* AppCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCardCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||
D5C8ACDA2A956B2B00669F92 /* Process+STPrivilegedTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Process+STPrivilegedTask.swift"; sourceTree = "<group>"; };
|
||||
D5CA0C4A280E141900469595 /* ManagedPatron.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedPatron.swift; sourceTree = "<group>"; };
|
||||
D5CA0C4C280E242500469595 /* AltStore 10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 10.xcdatamodel"; sourceTree = "<group>"; };
|
||||
@@ -1028,7 +1035,6 @@
|
||||
D5CD805C29CA2C1E00E591B0 /* HeaderContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderContentViewController.swift; sourceTree = "<group>"; };
|
||||
D5CD805E29CA755E00E591B0 /* SourceDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceDetailViewController.swift; sourceTree = "<group>"; };
|
||||
D5CF56812A0D83F9006D93E2 /* VerificationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationError.swift; sourceTree = "<group>"; };
|
||||
D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotProcessor.swift; sourceTree = "<group>"; };
|
||||
D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePatronsOperation.swift; sourceTree = "<group>"; };
|
||||
D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ALTLocalizedError.swift; sourceTree = "<group>"; };
|
||||
D5E1E7C028077DE90016FC96 /* UpdateKnownSourcesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateKnownSourcesOperation.swift; sourceTree = "<group>"; };
|
||||
@@ -1037,6 +1043,8 @@
|
||||
D5F48B4929CD0B67002B52A4 /* AsyncManaged.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncManaged.swift; sourceTree = "<group>"; };
|
||||
D5F5AF2D28FDD2EC00C938F5 /* TestErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestErrors.swift; sourceTree = "<group>"; };
|
||||
D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsViewController.swift; sourceTree = "<group>"; };
|
||||
D5F9821C2AB900060045751F /* AppScreenshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshot.swift; sourceTree = "<group>"; };
|
||||
D5F982202AB910180045751F /* AppScreenshotsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshotsViewController.swift; sourceTree = "<group>"; };
|
||||
D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = AltStore10ToAltStore11.xcmappingmodel; sourceTree = "<group>"; };
|
||||
D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreApp10ToStoreApp11Policy.swift; sourceTree = "<group>"; };
|
||||
D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFontDescriptor+Bold.swift"; sourceTree = "<group>"; };
|
||||
@@ -1236,7 +1244,6 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BF41B807233433C100C593A3 /* LoadingState.swift */,
|
||||
D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */,
|
||||
D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */,
|
||||
);
|
||||
path = Types;
|
||||
@@ -1250,6 +1257,7 @@
|
||||
BF3D64AF22E8D4B800E9056B /* AppContentViewControllerCells.swift */,
|
||||
D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */,
|
||||
D552B1D72A042A740066216F /* AppPermissionsCard.swift */,
|
||||
D5418F152AD740750014ABD6 /* Screenshots */,
|
||||
);
|
||||
path = "App Detail";
|
||||
sourceTree = "<group>";
|
||||
@@ -1521,6 +1529,7 @@
|
||||
BF66EEC92501AECA007EE018 /* Account.swift */,
|
||||
BF66EEC72501AECA007EE018 /* AppID.swift */,
|
||||
BF66EEC62501AECA007EE018 /* AppPermission.swift */,
|
||||
D5F9821C2AB900060045751F /* AppScreenshot.swift */,
|
||||
D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */,
|
||||
BF66EECA2501AECA007EE018 /* DatabaseManager.swift */,
|
||||
D5FD4ECA2A9532960097BEE8 /* DatabaseManager+Async.swift */,
|
||||
@@ -1591,6 +1600,7 @@
|
||||
D5F48B4729CCF21B002B52A4 /* AltStore+Async.swift */,
|
||||
D5893F7E2A14183200E767CD /* NSManagedObjectContext+Conveniences.swift */,
|
||||
D52A2F962ACB40F700BDF8E3 /* Logger+AltStore.swift */,
|
||||
D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */,
|
||||
D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
@@ -1687,8 +1697,6 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BF9ABA4422DCFF43008935CF /* BrowseViewController.swift */,
|
||||
BF9ABA4622DD0638008935CF /* BrowseCollectionViewCell.swift */,
|
||||
BFDB5B2522EFBBEA00F74113 /* BrowseCollectionViewCell.xib */,
|
||||
BF9ABA4822DD0742008935CF /* ScreenshotCollectionViewCell.swift */,
|
||||
);
|
||||
path = Browse;
|
||||
@@ -1872,6 +1880,7 @@
|
||||
BF6C8FAD2429597900125131 /* AppBannerCollectionViewCell.swift */,
|
||||
BF6C8FAF2429599900125131 /* TextCollectionReusableView.swift */,
|
||||
D5CD805C29CA2C1E00E591B0 /* HeaderContentViewController.swift */,
|
||||
D5C0E7662AD9C75900530CA4 /* AppCardCollectionViewCell.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
@@ -2132,6 +2141,16 @@
|
||||
path = Previews;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D5418F152AD740750014ABD6 /* Screenshots */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D5F982202AB910180045751F /* AppScreenshotsViewController.swift */,
|
||||
D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */,
|
||||
D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */,
|
||||
);
|
||||
path = Screenshots;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D55467B02A8D5E2600F4CE90 /* App Intents */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -2172,6 +2191,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D586D39A28EF58B0000E101F /* AltTests.swift */,
|
||||
D56915082AD5F3E800A2B747 /* AltTests+Sources.swift */,
|
||||
D5F5AF2D28FDD2EC00C938F5 /* TestErrors.swift */,
|
||||
);
|
||||
path = AltTests;
|
||||
@@ -2700,7 +2720,6 @@
|
||||
BFF0B6922321A305007A79E1 /* AboutPatreonHeaderView.xib in Resources */,
|
||||
BFB6B22423187A3D0022A802 /* NewsCollectionViewCell.xib in Resources */,
|
||||
BFD247752284B9A500981D42 /* Main.storyboard in Resources */,
|
||||
BFDB5B2622EFBBEA00F74113 /* BrowseCollectionViewCell.xib in Resources */,
|
||||
BFE6073C231AE1E7002B0E8E /* SettingsHeaderFooterView.xib in Resources */,
|
||||
BF29012F2318F6B100D88A45 /* AppBannerView.xib in Resources */,
|
||||
BFE6325A22A83BEB00F30809 /* Authentication.storyboard in Resources */,
|
||||
@@ -3017,6 +3036,7 @@
|
||||
BF66EEE82501AED0007EE018 /* UserDefaults+AltStore.swift in Sources */,
|
||||
BF340E9A250AD39500A192CB /* ViewApp.intentdefinition in Sources */,
|
||||
BFAECC522501B0A400528F27 /* CodableError.swift in Sources */,
|
||||
D5F9821D2AB900060045751F /* AppScreenshot.swift in Sources */,
|
||||
BF66EE9E2501AEC1007EE018 /* Fetchable.swift in Sources */,
|
||||
BF66EEDF2501AECA007EE018 /* PatreonAccount.swift in Sources */,
|
||||
BFAECC532501B0A400528F27 /* ServerProtocol.swift in Sources */,
|
||||
@@ -3065,6 +3085,7 @@
|
||||
D5F99A1A28D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift in Sources */,
|
||||
D5177B0D2A26944600270065 /* AltStore12ToAltStore13.xcmappingmodel in Sources */,
|
||||
BFAECC562501B0A400528F27 /* ALTServerError+Conveniences.swift in Sources */,
|
||||
D56915072AD5E91B00A2B747 /* Regex+Permissions.swift in Sources */,
|
||||
BFAECC592501B0A400528F27 /* Result+Conveniences.swift in Sources */,
|
||||
D571ADD02A02FC7200B24B63 /* ALTAppPermission.swift in Sources */,
|
||||
D5E3FB9828FDFAD90034B72C /* NSError+AltStore.swift in Sources */,
|
||||
@@ -3135,6 +3156,7 @@
|
||||
BF3BEFC124086A1E00DE7D55 /* RefreshAppOperation.swift in Sources */,
|
||||
BFE60740231AFD2A002B0E8E /* InsetGroupTableViewCell.swift in Sources */,
|
||||
BF0DCA662433BDF500E3A595 /* AnalyticsManager.swift in Sources */,
|
||||
D5418F172AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift in Sources */,
|
||||
BFCCB51A245E3401001853EA /* VerifyAppOperation.swift in Sources */,
|
||||
BFF0B6982322CAB8007A79E1 /* InstructionsViewController.swift in Sources */,
|
||||
BF9ABA4522DCFF43008935CF /* BrowseViewController.swift in Sources */,
|
||||
@@ -3146,7 +3168,7 @@
|
||||
BFF0B68E23219520007A79E1 /* PatreonViewController.swift in Sources */,
|
||||
BFF00D302501BD7D00746320 /* Intents.intentdefinition in Sources */,
|
||||
D5CF568C2A0D8EEC006D93E2 /* VerificationError.swift in Sources */,
|
||||
D5DAE0942804B0B80034D8D4 /* ScreenshotProcessor.swift in Sources */,
|
||||
D5B6F6AB2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift in Sources */,
|
||||
BFD2476E2284B9A500981D42 /* AppDelegate.swift in Sources */,
|
||||
BF41B806233423AE00C593A3 /* TabBarController.swift in Sources */,
|
||||
BFE00A202503097F00EB4D0C /* INInteraction+AltStore.swift in Sources */,
|
||||
@@ -3162,7 +3184,6 @@
|
||||
BFE60742231B07E6002B0E8E /* SettingsHeaderFooterView.swift in Sources */,
|
||||
BFE338E822F10E56002E24B9 /* LaunchViewController.swift in Sources */,
|
||||
BFA8172B23C5633D001B5953 /* FetchAnisetteDataOperation.swift in Sources */,
|
||||
BF9ABA4722DD0638008935CF /* BrowseCollectionViewCell.swift in Sources */,
|
||||
D552B1D82A042A740066216F /* AppPermissionsCard.swift in Sources */,
|
||||
BFD6B03322DFF20800B86064 /* MyAppsComponents.swift in Sources */,
|
||||
BF41B808233433C100C593A3 /* LoadingState.swift in Sources */,
|
||||
@@ -3200,6 +3221,7 @@
|
||||
BF3432FB246B894F0052F4A1 /* BackupAppOperation.swift in Sources */,
|
||||
BF9ABA4922DD0742008935CF /* ScreenshotCollectionViewCell.swift in Sources */,
|
||||
BF9ABA4D22DD16DE008935CF /* PillButton.swift in Sources */,
|
||||
D5C0E7672AD9C75900530CA4 /* AppCardCollectionViewCell.swift in Sources */,
|
||||
D5DAE0962804DF430034D8D4 /* UpdatePatronsOperation.swift in Sources */,
|
||||
D54058BB2A1D8FE3008CCC58 /* UIColor+AltStore.swift in Sources */,
|
||||
BFE6326C22A86FF300F30809 /* AuthenticationOperation.swift in Sources */,
|
||||
@@ -3213,6 +3235,7 @@
|
||||
BFB3645A2325985F00CD0EB1 /* FindServerOperation.swift in Sources */,
|
||||
D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */,
|
||||
BF3BEFBF2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift in Sources */,
|
||||
D5F982212AB910180045751F /* AppScreenshotsViewController.swift in Sources */,
|
||||
BFF0B69023219C6D007A79E1 /* PatreonComponents.swift in Sources */,
|
||||
BFBE0004250ACFFB0080826E /* ViewApp.intentdefinition in Sources */,
|
||||
BF770E5622BC3C03002A40FE /* Server.swift in Sources */,
|
||||
@@ -3237,6 +3260,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D586D39B28EF58B0000E101F /* AltTests.swift in Sources */,
|
||||
D56915092AD5F3E800A2B747 /* AltTests+Sources.swift in Sources */,
|
||||
D5F5AF2E28FDD2EC00C938F5 /* TestErrors.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
@@ -29,7 +29,12 @@ class AppContentViewController: UITableViewController
|
||||
{
|
||||
var app: StoreApp!
|
||||
|
||||
private lazy var screenshotsDataSource = self.makeScreenshotsDataSource()
|
||||
private lazy var dateFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateStyle = .medium
|
||||
dateFormatter.timeStyle = .none
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
private lazy var byteCountFormatter: ByteCountFormatter = {
|
||||
let formatter = ByteCountFormatter()
|
||||
@@ -43,32 +48,17 @@ class AppContentViewController: UITableViewController
|
||||
@IBOutlet private var versionDateLabel: UILabel!
|
||||
@IBOutlet private var sizeLabel: UILabel!
|
||||
|
||||
@IBOutlet private var screenshotsCollectionView: UICollectionView!
|
||||
@IBOutlet private(set) var appScreenshotsViewController: AppScreenshotsViewController!
|
||||
@IBOutlet private var appScreenshotsHeightConstraint: NSLayoutConstraint!
|
||||
|
||||
@IBOutlet private(set) var appDetailCollectionViewController: AppDetailCollectionViewController!
|
||||
@IBOutlet private var appDetailCollectionViewHeightConstraint: NSLayoutConstraint!
|
||||
|
||||
var preferredScreenshotSize: CGSize? {
|
||||
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||
|
||||
let aspectRatio: CGFloat = 16.0 / 9.0 // Hardcoded for now.
|
||||
|
||||
let width = self.screenshotsCollectionView.bounds.width - (layout.minimumInteritemSpacing * 2)
|
||||
|
||||
let itemWidth = width / 1.5
|
||||
let itemHeight = itemWidth * aspectRatio
|
||||
|
||||
return CGSize(width: itemWidth, height: itemHeight)
|
||||
}
|
||||
|
||||
override func viewDidLoad()
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
self.tableView.contentInset.bottom = 20
|
||||
|
||||
self.screenshotsCollectionView.dataSource = self.screenshotsDataSource
|
||||
self.screenshotsCollectionView.prefetchDataSource = self.screenshotsDataSource
|
||||
|
||||
self.subtitleLabel.text = self.app.subtitle
|
||||
self.descriptionTextView.text = self.app.localizedDescription
|
||||
@@ -99,17 +89,24 @@ class AppContentViewController: UITableViewController
|
||||
{
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
guard var size = self.preferredScreenshotSize else { return }
|
||||
size.height = min(size.height, self.screenshotsCollectionView.bounds.height) // Silence temporary "item too tall" warning.
|
||||
var needsTableViewUpdate = false
|
||||
|
||||
let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||
layout.itemSize = size
|
||||
let screenshotsHeight = self.appScreenshotsViewController.collectionView.contentSize.height
|
||||
if self.appScreenshotsHeightConstraint.constant != screenshotsHeight && screenshotsHeight > 0
|
||||
{
|
||||
self.appScreenshotsHeightConstraint.constant = screenshotsHeight
|
||||
needsTableViewUpdate = true
|
||||
}
|
||||
|
||||
let permissionsHeight = self.appDetailCollectionViewController.collectionView.contentSize.height
|
||||
if self.appDetailCollectionViewHeightConstraint.constant != permissionsHeight && permissionsHeight > 0
|
||||
{
|
||||
self.appDetailCollectionViewHeightConstraint.constant = permissionsHeight
|
||||
|
||||
needsTableViewUpdate = true
|
||||
}
|
||||
|
||||
if needsTableViewUpdate
|
||||
{
|
||||
UIView.performWithoutAnimation {
|
||||
// Update row height without animation.
|
||||
self.tableView.beginUpdates()
|
||||
@@ -121,40 +118,12 @@ class AppContentViewController: UITableViewController
|
||||
|
||||
private extension AppContentViewController
|
||||
{
|
||||
func makeScreenshotsDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>
|
||||
@IBSegueAction
|
||||
func makeAppScreenshotsViewController(_ coder: NSCoder, sender: Any?) -> UIViewController?
|
||||
{
|
||||
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<NSURL, UIImage>(items: self.app.screenshotURLs as [NSURL])
|
||||
dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.image = nil
|
||||
cell.imageView.isIndicatingActivity = true
|
||||
}
|
||||
dataSource.prefetchHandler = { (imageURL, indexPath, completionHandler) in
|
||||
return RSTAsyncBlockOperation() { (operation) in
|
||||
let request = ImageRequest(url: imageURL as URL, processors: [.screenshot])
|
||||
ImagePipeline.shared.loadImage(with: request, progress: nil) { result in
|
||||
guard !operation.isCancelled else { return operation.finish() }
|
||||
|
||||
switch result
|
||||
{
|
||||
case .success(let response): completionHandler(response.image, nil)
|
||||
case .failure(let error): completionHandler(nil, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
||||
let cell = cell as! ScreenshotCollectionViewCell
|
||||
cell.imageView.isIndicatingActivity = false
|
||||
cell.imageView.image = image
|
||||
|
||||
if let error = error
|
||||
{
|
||||
print("Error loading image:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return dataSource
|
||||
let appScreenshotsViewController = AppScreenshotsViewController(app: self.app, coder: coder)
|
||||
self.appScreenshotsViewController = appScreenshotsViewController
|
||||
return appScreenshotsViewController
|
||||
}
|
||||
|
||||
@IBSegueAction
|
||||
@@ -198,8 +167,8 @@ extension AppContentViewController
|
||||
switch Row.allCases[indexPath.row]
|
||||
{
|
||||
case .screenshots:
|
||||
guard let size = self.preferredScreenshotSize else { return 0.0 }
|
||||
return size.height
|
||||
guard !self.app.allScreenshots.isEmpty else { return 0.0 }
|
||||
return UITableView.automaticDimension
|
||||
|
||||
case .permissions:
|
||||
guard !self.app.permissions.isEmpty else { return 0.0 }
|
||||
|
||||
@@ -142,6 +142,14 @@ class AppViewController: UIViewController
|
||||
self.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
override func viewIsAppearing(_ animated: Bool)
|
||||
{
|
||||
super.viewIsAppearing(animated)
|
||||
|
||||
// Prevent banner temporarily flashing a color due to being added back to self.view.
|
||||
self.bannerView.backgroundEffectView.backgroundColor = .clear
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool)
|
||||
{
|
||||
super.viewDidAppear(animated)
|
||||
@@ -197,7 +205,7 @@ class AppViewController: UIViewController
|
||||
{
|
||||
statusBarHeight = 20
|
||||
}
|
||||
else if let statusBarManager = self.view.window?.windowScene?.statusBarManager
|
||||
else if let statusBarManager = (self.view.window ?? self.presentedViewController?.view.window)?.windowScene?.statusBarManager
|
||||
{
|
||||
statusBarHeight = statusBarManager.statusBarFrame.height
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -49,7 +49,7 @@
|
||||
<!--Browse-->
|
||||
<scene sceneID="rXq-UR-qQp">
|
||||
<objects>
|
||||
<collectionViewController id="e3L-BF-iXp" customClass="BrowseViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<collectionViewController storyboardIdentifier="browseViewController" id="e3L-BF-iXp" customClass="BrowseViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" dataMode="prototypes" id="CaT-1q-qnx">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
@@ -252,45 +252,34 @@
|
||||
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="nI6-wC-H2d">
|
||||
<rect key="frame" x="0.0" y="107" width="375" height="44"/>
|
||||
<rect key="frame" x="0.0" y="107" width="375" height="300"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="nI6-wC-H2d" id="Z4y-vb-Z4Q">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<subviews>
|
||||
<collectionView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" dataMode="prototypes" translatesAutoresizingMaskIntoConstraints="NO" id="ppk-lL-at8">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="44"/>
|
||||
<color key="backgroundColor" name="Background"/>
|
||||
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" minimumLineSpacing="10" minimumInteritemSpacing="15" id="ace-Ns-Jd2">
|
||||
<size key="itemSize" width="189" height="406"/>
|
||||
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
||||
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
||||
<inset key="sectionInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
|
||||
</collectionViewFlowLayout>
|
||||
<cells>
|
||||
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="Cell" id="2U6-d3-e4r" customClass="ScreenshotCollectionViewCell">
|
||||
<rect key="frame" x="15" y="-181" width="189" height="406"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO">
|
||||
<rect key="frame" x="0.0" y="0.0" width="189" height="406"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</view>
|
||||
</collectionViewCell>
|
||||
</cells>
|
||||
</collectionView>
|
||||
<containerView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="5yj-Nb-f5H">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" priority="999" constant="300" id="dpf-ba-NNr"/>
|
||||
</constraints>
|
||||
<connections>
|
||||
<segue destination="nX2-hQ-qjX" kind="embed" destinationCreationSelector="makeAppScreenshotsViewController:sender:" id="VxG-Pu-Kf1"/>
|
||||
</connections>
|
||||
</containerView>
|
||||
</subviews>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="ppk-lL-at8" secondAttribute="trailing" id="3QR-Y2-v26"/>
|
||||
<constraint firstAttribute="bottom" secondItem="ppk-lL-at8" secondAttribute="bottom" id="EgJ-Uw-5ta"/>
|
||||
<constraint firstItem="ppk-lL-at8" firstAttribute="leading" secondItem="Z4y-vb-Z4Q" secondAttribute="leading" id="wHf-S9-gMV"/>
|
||||
<constraint firstItem="ppk-lL-at8" firstAttribute="top" secondItem="Z4y-vb-Z4Q" secondAttribute="top" id="xY5-w8-roA"/>
|
||||
<constraint firstAttribute="trailing" secondItem="5yj-Nb-f5H" secondAttribute="trailing" id="2DI-44-pC1"/>
|
||||
<constraint firstItem="5yj-Nb-f5H" firstAttribute="top" secondItem="Z4y-vb-Z4Q" secondAttribute="top" id="URh-5T-73x"/>
|
||||
<constraint firstAttribute="bottom" secondItem="5yj-Nb-f5H" secondAttribute="bottom" id="Yb6-aZ-qNF"/>
|
||||
<constraint firstItem="5yj-Nb-f5H" firstAttribute="leading" secondItem="Z4y-vb-Z4Q" secondAttribute="leading" id="rpG-Ip-qZU"/>
|
||||
</constraints>
|
||||
</tableViewCellContentView>
|
||||
<color key="backgroundColor" name="Background"/>
|
||||
<inset key="separatorInset" minX="15" minY="0.0" maxX="15" maxY="0.0"/>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="EL5-UC-RIw" customClass="AppContentTableViewCell" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="151" width="375" height="98"/>
|
||||
<rect key="frame" x="0.0" y="407" width="375" height="98"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="EL5-UC-RIw" id="D1G-nK-G0Z">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="98"/>
|
||||
@@ -314,7 +303,7 @@
|
||||
<inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="47M-El-a4G" customClass="AppContentTableViewCell" customModule="AltStore" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="249" width="375" height="137.5"/>
|
||||
<rect key="frame" x="0.0" y="505" width="375" height="137.5"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="47M-El-a4G" id="f9D-OR-oGE">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="137.5"/>
|
||||
@@ -383,7 +372,7 @@
|
||||
<inset key="separatorInset" minX="1000" minY="0.0" maxX="0.0" maxY="0.0"/>
|
||||
</tableViewCell>
|
||||
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" rowHeight="300" id="nM7-vJ-W8b">
|
||||
<rect key="frame" x="0.0" y="386.5" width="375" height="300"/>
|
||||
<rect key="frame" x="0.0" y="642.5" width="375" height="300"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="nM7-vJ-W8b" id="cQ2-Jd-pRK">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
|
||||
@@ -437,8 +426,8 @@
|
||||
<size key="freeformSize" width="375" height="667"/>
|
||||
<connections>
|
||||
<outlet property="appDetailCollectionViewHeightConstraint" destination="HFx-PP-dAt" id="ti3-q6-ku1"/>
|
||||
<outlet property="appScreenshotsHeightConstraint" destination="dpf-ba-NNr" id="shO-Kq-Y90"/>
|
||||
<outlet property="descriptionTextView" destination="Pyt-8D-BZA" id="cgV-Hg-LrH"/>
|
||||
<outlet property="screenshotsCollectionView" destination="ppk-lL-at8" id="YoQ-Z6-WTP"/>
|
||||
<outlet property="sizeLabel" destination="DgM-bD-bBY" id="Oky-ax-u20"/>
|
||||
<outlet property="subtitleLabel" destination="BsL-O2-UjD" id="cfe-cf-4a9"/>
|
||||
<outlet property="versionDateLabel" destination="wGD-mS-8fO" id="icB-lC-g9x"/>
|
||||
@@ -450,6 +439,31 @@
|
||||
</objects>
|
||||
<point key="canvasLocation" x="3302" y="-18"/>
|
||||
</scene>
|
||||
<!--App Screenshots View Controller-->
|
||||
<scene sceneID="E6k-TI-c4N">
|
||||
<objects>
|
||||
<collectionViewController storyboardIdentifier="appScreenshotsViewController" id="nX2-hQ-qjX" customClass="AppScreenshotsViewController" customModule="AltStore" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" contentInsetAdjustmentBehavior="never" dataMode="prototypes" id="zXl-if-KtH">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="300"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<collectionViewFlowLayout key="collectionViewLayout" scrollDirection="horizontal" automaticEstimatedItemSize="YES" minimumLineSpacing="10" minimumInteritemSpacing="10" id="MGS-YY-5g9">
|
||||
<size key="itemSize" width="150" height="300"/>
|
||||
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
||||
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
||||
<inset key="sectionInset" minX="0.0" minY="0.0" maxX="0.0" maxY="0.0"/>
|
||||
</collectionViewFlowLayout>
|
||||
<cells/>
|
||||
<connections>
|
||||
<outlet property="dataSource" destination="nX2-hQ-qjX" id="QRj-01-ddR"/>
|
||||
<outlet property="delegate" destination="nX2-hQ-qjX" id="Ha5-Xa-Q6e"/>
|
||||
</connections>
|
||||
</collectionView>
|
||||
</collectionViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="np0-Hj-vy7" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="4096.8000000000002" y="-437.18140929535235"/>
|
||||
</scene>
|
||||
<!--App Detail Collection View Controller-->
|
||||
<scene sceneID="Pcn-h5-5fk">
|
||||
<objects>
|
||||
@@ -468,7 +482,7 @@
|
||||
</collectionViewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="fxm-bB-W29" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="4070" y="-18"/>
|
||||
<point key="canvasLocation" x="4097" y="-19"/>
|
||||
</scene>
|
||||
<!--Settings-->
|
||||
<scene sceneID="KlD-j0-ROn">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -21,7 +21,7 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing
|
||||
private lazy var dataSource = self.makeDataSource()
|
||||
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
|
||||
|
||||
private let prototypeCell = BrowseCollectionViewCell.instantiate(with: BrowseCollectionViewCell.nib!)!
|
||||
private let prototypeCell = AppCardCollectionViewCell(frame: .zero)
|
||||
|
||||
private var loadingState: LoadingState = .loading {
|
||||
didSet {
|
||||
@@ -58,11 +58,14 @@ class BrowseViewController: UICollectionViewController, PeekPopPreviewing
|
||||
|
||||
self.prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
self.collectionView.register(BrowseCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
self.collectionView.register(AppCardCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
|
||||
|
||||
self.collectionView.dataSource = self.dataSource
|
||||
self.collectionView.prefetchDataSource = self.dataSource
|
||||
|
||||
let collectionViewLayout = self.collectionViewLayout as! UICollectionViewFlowLayout
|
||||
collectionViewLayout.minimumLineSpacing = 30
|
||||
|
||||
(self as PeekPopPreviewing).registerForPreviewing(with: self, sourceView: self.collectionView)
|
||||
|
||||
if let source = self.source
|
||||
@@ -120,14 +123,11 @@ private extension BrowseViewController
|
||||
let context = self.source?.managedObjectContext ?? DatabaseManager.shared.viewContext
|
||||
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<StoreApp, UIImage>(fetchRequest: fetchRequest, managedObjectContext: context)
|
||||
dataSource.cellConfigurationHandler = { (cell, app, indexPath) in
|
||||
let cell = cell as! BrowseCollectionViewCell
|
||||
let cell = cell as! AppCardCollectionViewCell
|
||||
cell.layoutMargins.left = self.view.layoutMargins.left
|
||||
cell.layoutMargins.right = self.view.layoutMargins.right
|
||||
|
||||
cell.subtitleLabel.text = app.subtitle
|
||||
cell.imageURLs = Array(app.screenshotURLs.prefix(2))
|
||||
|
||||
cell.bannerView.configure(for: app)
|
||||
cell.configure(for: app)
|
||||
|
||||
cell.bannerView.iconImageView.image = nil
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = true
|
||||
@@ -187,7 +187,7 @@ private extension BrowseViewController
|
||||
}
|
||||
}
|
||||
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
|
||||
let cell = cell as! BrowseCollectionViewCell
|
||||
let cell = cell as! AppCardCollectionViewCell
|
||||
cell.bannerView.iconImageView.isIndicatingActivity = false
|
||||
cell.bannerView.iconImageView.image = image
|
||||
|
||||
@@ -366,21 +366,18 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
||||
{
|
||||
let item = self.dataSource.item(at: indexPath)
|
||||
let itemID = item.globallyUniqueID ?? item.bundleIdentifier
|
||||
|
||||
if let previousSize = self.cachedItemSizes[item.bundleIdentifier]
|
||||
if let previousSize = self.cachedItemSizes[itemID]
|
||||
{
|
||||
return previousSize
|
||||
}
|
||||
|
||||
let maxVisibleScreenshots = 2 as CGFloat
|
||||
let aspectRatio: CGFloat = 16.0 / 9.0
|
||||
|
||||
let layout = self.prototypeCell.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout
|
||||
let padding = (layout.minimumInteritemSpacing * (maxVisibleScreenshots - 1)) + layout.sectionInset.left + layout.sectionInset.right
|
||||
|
||||
self.dataSource.cellConfigurationHandler(self.prototypeCell, item, indexPath)
|
||||
|
||||
let insets = (self.view.layoutMargins.left + self.view.layoutMargins.right)
|
||||
|
||||
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
|
||||
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width - insets)
|
||||
widthConstraint.isActive = true
|
||||
defer { widthConstraint.isActive = false }
|
||||
|
||||
@@ -388,17 +385,8 @@ extension BrowseViewController: UICollectionViewDelegateFlowLayout
|
||||
self.prototypeCell.frame.size.width = widthConstraint.constant
|
||||
self.prototypeCell.layoutIfNeeded()
|
||||
|
||||
let collectionViewWidth = self.prototypeCell.screenshotsCollectionView.bounds.width
|
||||
let screenshotWidth = ((collectionViewWidth - padding) / maxVisibleScreenshots).rounded(.down)
|
||||
let screenshotHeight = screenshotWidth * aspectRatio
|
||||
|
||||
let heightConstraint = self.prototypeCell.screenshotsCollectionView.heightAnchor.constraint(equalToConstant: screenshotHeight)
|
||||
heightConstraint.priority = .defaultHigh // Prevent temporary unsatisfiable constraints error.
|
||||
heightConstraint.isActive = true
|
||||
defer { heightConstraint.isActive = false }
|
||||
|
||||
let itemSize = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
self.cachedItemSizes[item.bundleIdentifier] = itemSize
|
||||
self.cachedItemSizes[itemID] = itemSize
|
||||
return itemSize
|
||||
}
|
||||
|
||||
@@ -435,3 +423,16 @@ extension BrowseViewController: UIViewControllerPreviewingDelegate
|
||||
self.navigationController?.pushViewController(viewControllerToCommit, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 17, *)
|
||||
#Preview(traits: .portrait) {
|
||||
DatabaseManager.shared.startForPreview()
|
||||
|
||||
let storyboard = UIStoryboard(name: "Main", bundle: .main)
|
||||
let browseViewController = storyboard.instantiateViewController(identifier: "browseViewController") { coder in
|
||||
BrowseViewController(source: nil, coder: coder)
|
||||
}
|
||||
|
||||
let navigationController = UINavigationController(rootViewController: browseViewController)
|
||||
return navigationController
|
||||
}
|
||||
|
||||
276
AltStore/Components/AppCardCollectionViewCell.swift
Normal file
276
AltStore/Components/AppCardCollectionViewCell.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ extension SourceError
|
||||
case duplicate
|
||||
|
||||
case missingPermissionUsageDescription
|
||||
case missingScreenshotSize
|
||||
}
|
||||
|
||||
static func unsupported(_ source: Source) -> SourceError { SourceError(code: .unsupported, source: source) }
|
||||
@@ -36,6 +37,10 @@ extension SourceError
|
||||
static func missingPermissionUsageDescription(for permission: any ALTAppPermission, app: StoreApp, source: Source) -> SourceError {
|
||||
SourceError(code: .missingPermissionUsageDescription, source: source, app: app, permission: permission)
|
||||
}
|
||||
|
||||
static func missingScreenshotSize(for screenshot: AppScreenshot, source: Source) -> SourceError {
|
||||
SourceError(code: .missingScreenshotSize, source: source, app: screenshot.app, screenshotURL: screenshot.imageURL)
|
||||
}
|
||||
}
|
||||
|
||||
struct SourceError: ALTLocalizedError
|
||||
@@ -59,6 +64,9 @@ struct SourceError: ALTLocalizedError
|
||||
@UserInfoValue
|
||||
var permission: (any ALTAppPermission)?
|
||||
|
||||
@UserInfoValue
|
||||
var screenshotURL: URL?
|
||||
|
||||
var errorFailureReason: String {
|
||||
switch self.code
|
||||
{
|
||||
@@ -112,6 +120,14 @@ struct SourceError: ALTLocalizedError
|
||||
let permissionType = permission.type.localizedName ?? NSLocalizedString("Permission", comment: "")
|
||||
let failureReason = String(format: NSLocalizedString("The %@ '%@' for %@ is missing a usage description.", comment: ""), permissionType.lowercased(), permission.rawValue, appName)
|
||||
return failureReason
|
||||
|
||||
case .missingScreenshotSize:
|
||||
let appName = self.$app.name ?? String(format: NSLocalizedString("an app in source “%@”", comment: ""), self.$source.name)
|
||||
let baseMessage = String(format: NSLocalizedString("An iPad screenshot for %@ does not specify its size", comment: ""), appName)
|
||||
guard let screenshotURL else { return baseMessage + "." }
|
||||
|
||||
let failureReason = baseMessage + ": \(screenshotURL.absoluteString)"
|
||||
return failureReason
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -122,7 +122,35 @@ class FetchSourceOperation: ResultOperation<Source>
|
||||
decoder.managedObjectContext = childContext
|
||||
decoder.sourceURL = self.sourceURL
|
||||
|
||||
let source = try decoder.decode(Source.self, from: data)
|
||||
let source: Source
|
||||
|
||||
do
|
||||
{
|
||||
source = try decoder.decode(Source.self, from: data)
|
||||
}
|
||||
catch let error as DecodingError
|
||||
{
|
||||
let nsError = error as NSError
|
||||
guard let codingPath = nsError.userInfo[ALTNSCodingPathKey] as? [CodingKey] else { throw error }
|
||||
|
||||
let rawComponents = codingPath.map { $0.intValue?.description ?? $0.stringValue }
|
||||
let pathDescription = rawComponents.joined(separator: " > ")
|
||||
|
||||
var userInfo = nsError.userInfo
|
||||
|
||||
if let debugDescription = nsError.localizedDebugDescription
|
||||
{
|
||||
let detailedDescription = debugDescription + "\n\n" + pathDescription
|
||||
userInfo[NSDebugDescriptionErrorKey] = detailedDescription
|
||||
}
|
||||
else
|
||||
{
|
||||
userInfo[NSDebugDescriptionErrorKey] = pathDescription
|
||||
}
|
||||
|
||||
throw NSError(domain: nsError.domain, code: nsError.code, userInfo: userInfo)
|
||||
}
|
||||
|
||||
let identifier = source.identifier
|
||||
|
||||
try self.verify(source, response: response)
|
||||
@@ -181,6 +209,12 @@ private extension FetchSourceOperation
|
||||
// Privacy permissions MUST have a usage description.
|
||||
guard permission.usageDescription != nil else { throw SourceError.missingPermissionUsageDescription(for: permission.permission, app: app, source: source) }
|
||||
}
|
||||
|
||||
for screenshot in app.screenshots(for: .ipad)
|
||||
{
|
||||
// All iPad screenshots MUST have an explicit size.
|
||||
guard screenshot.size != nil else { throw SourceError.missingScreenshotSize(for: screenshot, source: source) }
|
||||
}
|
||||
}
|
||||
|
||||
if let previousSourceID = self.$source.identifier
|
||||
|
||||
@@ -212,41 +212,23 @@ private extension VerifyAppOperation
|
||||
|
||||
|
||||
// Privacy
|
||||
let allPrivacyPermissions: Set<ALTAppPrivacyPermission>
|
||||
if #available(iOS 16, *)
|
||||
{
|
||||
let regex = Regex {
|
||||
"NS"
|
||||
|
||||
// Capture permission "name"
|
||||
Capture {
|
||||
OneOrMore(.anyGraphemeCluster)
|
||||
let allPrivacyPermissions = ([app] + app.appExtensions).flatMap { (app) in
|
||||
let permissions = app.bundle.infoDictionary?.keys.compactMap { key -> ALTAppPrivacyPermission? in
|
||||
if #available(iOS 16, *)
|
||||
{
|
||||
guard key.wholeMatch(of: Regex.privacyPermission) != nil else { return nil }
|
||||
}
|
||||
else
|
||||
{
|
||||
guard key.contains("UsageDescription") else { return nil }
|
||||
}
|
||||
|
||||
"UsageDescription"
|
||||
|
||||
// Optional suffix
|
||||
Optionally(OneOrMore(.anyGraphemeCluster))
|
||||
}
|
||||
let permission = ALTAppPrivacyPermission(rawValue: key)
|
||||
return permission
|
||||
} ?? []
|
||||
|
||||
let privacyPermissions = ([app] + app.appExtensions).flatMap { (app) in
|
||||
let permissions = app.bundle.infoDictionary?.keys.compactMap { key -> ALTAppPrivacyPermission? in
|
||||
guard let match = key.wholeMatch(of: regex) else { return nil }
|
||||
|
||||
let permission = ALTAppPrivacyPermission(rawValue: String(match.1))
|
||||
return permission
|
||||
} ?? []
|
||||
|
||||
return permissions
|
||||
}
|
||||
|
||||
allPrivacyPermissions = Set(privacyPermissions)
|
||||
return permissions
|
||||
}
|
||||
else
|
||||
{
|
||||
allPrivacyPermissions = []
|
||||
}
|
||||
|
||||
|
||||
// Verify permissions.
|
||||
let sourcePermissions: Set<AnyHashable> = Set(await $storeApp.perform { $0.permissions.map { AnyHashable($0.permission) } })
|
||||
@@ -254,8 +236,37 @@ private extension VerifyAppOperation
|
||||
|
||||
// To pass: EVERY permission in localPermissions must also appear in sourcePermissions.
|
||||
// If there is a single missing permission, throw error.
|
||||
let missingPermissions: [any ALTAppPermission] = localPermissions.filter { !sourcePermissions.contains(AnyHashable($0)) }
|
||||
guard missingPermissions.isEmpty else { throw VerificationError.undeclaredPermissions(missingPermissions, app: app) }
|
||||
let missingPermissions: [any ALTAppPermission] = localPermissions.filter { permission in
|
||||
if sourcePermissions.contains(AnyHashable(permission))
|
||||
{
|
||||
// `permission` exists in source, so return false.
|
||||
return false
|
||||
}
|
||||
else if permission.type == .privacy
|
||||
{
|
||||
guard #available(iOS 16, *) else {
|
||||
// Assume all privacy permissions _are_ included in source on pre-iOS 16 devices.
|
||||
return false
|
||||
}
|
||||
|
||||
// Special-handling for legacy privacy permissions.
|
||||
if let match = permission.rawValue.firstMatch(of: Regex.privacyPermission),
|
||||
case let legacyPermission = ALTAppPrivacyPermission(rawValue: String(match.1)),
|
||||
sourcePermissions.contains(AnyHashable(legacyPermission))
|
||||
{
|
||||
// The legacy name of this permission exists in the source, so return false.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Source doesn't contain permission or its legacy name, so assume it is missing.
|
||||
return true
|
||||
}
|
||||
|
||||
guard missingPermissions.isEmpty else {
|
||||
// There is at least one undeclared permission, so throw error.
|
||||
throw VerificationError.undeclaredPermissions(missingPermissions, app: app)
|
||||
}
|
||||
|
||||
return localPermissions
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
31
AltStoreCore/Extensions/Regex+Permissions.swift
Normal file
31
AltStoreCore/Extensions/Regex+Permissions.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22D68" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22222" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Account" representedClassName="Account" syncable="YES">
|
||||
<attribute name="appleID" attributeType="String"/>
|
||||
<attribute name="firstName" attributeType="String"/>
|
||||
@@ -42,6 +42,23 @@
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="AppScreenshot" representedClassName="AppScreenshot" syncable="YES">
|
||||
<attribute name="appBundleID" attributeType="String"/>
|
||||
<attribute name="deviceType" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
|
||||
<attribute name="height" optional="YES" attributeType="Integer 16" usesScalarValueType="NO"/>
|
||||
<attribute name="imageURL" attributeType="URI"/>
|
||||
<attribute name="sourceID" attributeType="String"/>
|
||||
<attribute name="width" optional="YES" attributeType="Integer 16" usesScalarValueType="NO"/>
|
||||
<relationship name="app" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="StoreApp" inverseName="screenshots" inverseEntity="StoreApp"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="imageURL"/>
|
||||
<constraint value="deviceType"/>
|
||||
<constraint value="appBundleID"/>
|
||||
<constraint value="sourceID"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="AppVersion" representedClassName="AppVersion" syncable="YES">
|
||||
<attribute name="appBundleID" attributeType="String"/>
|
||||
<attribute name="buildVersion" attributeType="String"/>
|
||||
@@ -207,6 +224,7 @@
|
||||
<relationship name="loggedErrors" toMany="YES" deletionRule="Nullify" destinationEntity="LoggedError" inverseName="storeApp" inverseEntity="LoggedError"/>
|
||||
<relationship name="newsItems" toMany="YES" deletionRule="Nullify" destinationEntity="NewsItem" inverseName="storeApp" inverseEntity="NewsItem"/>
|
||||
<relationship name="permissions" toMany="YES" deletionRule="Cascade" destinationEntity="AppPermission" inverseName="app" inverseEntity="AppPermission"/>
|
||||
<relationship name="screenshots" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="AppScreenshot" inverseName="app" inverseEntity="AppScreenshot"/>
|
||||
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="apps" inverseEntity="Source"/>
|
||||
<relationship name="versions" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="AppVersion" inverseName="app" inverseEntity="AppVersion"/>
|
||||
<uniquenessConstraints>
|
||||
|
||||
@@ -12,7 +12,7 @@ import UIKit
|
||||
import AltSign
|
||||
|
||||
@objc(AppPermission) @dynamicMemberLookup
|
||||
public class AppPermission: NSManagedObject, Decodable, Fetchable
|
||||
public class AppPermission: NSManagedObject, Fetchable
|
||||
{
|
||||
/* Properties */
|
||||
@NSManaged public var type: ALTAppPermissionType
|
||||
@@ -47,37 +47,13 @@ public class AppPermission: NSManagedObject, Decodable, Fetchable
|
||||
super.init(entity: entity, insertInto: context)
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey
|
||||
convenience init(permission: String, usageDescription: String?, type: ALTAppPermissionType, context: NSManagedObjectContext)
|
||||
{
|
||||
case name
|
||||
case usageDescription
|
||||
}
|
||||
|
||||
public required init(from decoder: Decoder) throws
|
||||
{
|
||||
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
|
||||
self.init(entity: AppPermission.entity(), insertInto: context)
|
||||
|
||||
super.init(entity: AppPermission.entity(), insertInto: context)
|
||||
|
||||
do
|
||||
{
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
self._permission = try container.decode(String.self, forKey: .name)
|
||||
self.usageDescription = try container.decodeIfPresent(String.self, forKey: .usageDescription)
|
||||
|
||||
// Will be updated from StoreApp.
|
||||
self.type = .unknown
|
||||
}
|
||||
catch
|
||||
{
|
||||
if let context = self.managedObjectContext
|
||||
{
|
||||
context.delete(self)
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
self._permission = permission
|
||||
self.usageDescription = usageDescription
|
||||
self.type = type
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,3 +75,114 @@ public extension AppPermission
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AnyDecodable: Decodable
|
||||
{
|
||||
init(from decoder: Decoder) throws
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal struct AppPermissions: Decodable
|
||||
{
|
||||
var entitlements: [AppPermission] = []
|
||||
var privacy: [AppPermission] = []
|
||||
|
||||
private enum CodingKeys: String, CodingKey, Decodable
|
||||
{
|
||||
case entitlements
|
||||
case privacy
|
||||
|
||||
// Legacy
|
||||
case name
|
||||
case usageDescription
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws
|
||||
{
|
||||
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.entitlements = try self.parseEntitlements(from: container, into: context)
|
||||
self.privacy = try self.parsePrivacyPermissions(from: container, into: context)
|
||||
}
|
||||
|
||||
private func parseEntitlements(from container: KeyedDecodingContainer<CodingKeys>, into context: NSManagedObjectContext) throws -> [AppPermission]
|
||||
{
|
||||
guard container.contains(.entitlements) else { return [] }
|
||||
|
||||
do
|
||||
{
|
||||
do
|
||||
{
|
||||
// Legacy
|
||||
// Must parse as [String: String], NOT [CodingKeys: String], to avoid incorrect DecodingError.typeMismatch error.
|
||||
let rawEntitlements = try container.decode([[String: String]].self, forKey: .entitlements)
|
||||
|
||||
let entitlements = try rawEntitlements.compactMap { (dictionary) -> AppPermission? in
|
||||
guard let name = dictionary[CodingKeys.name.rawValue] else {
|
||||
let context = DecodingError.Context(codingPath: container.codingPath, debugDescription: "Legacy entitlements must have `name` key.")
|
||||
throw DecodingError.keyNotFound(CodingKeys.name, context)
|
||||
}
|
||||
|
||||
let entitlement = AppPermission(permission: name, usageDescription: nil, type: .entitlement, context: context)
|
||||
return entitlement
|
||||
}
|
||||
|
||||
return entitlements
|
||||
}
|
||||
catch DecodingError.typeMismatch
|
||||
{
|
||||
// Detailed
|
||||
// AnyDecodable ensures we're forward-compatible with any values we may later require for entitlement permissions.
|
||||
let rawEntitlements = try container.decode([String: AnyDecodable?].self, forKey: .entitlements)
|
||||
|
||||
let entitlements = rawEntitlements.map { AppPermission(permission: $0.key, usageDescription: nil, type: .entitlement, context: context) }
|
||||
return entitlements
|
||||
}
|
||||
}
|
||||
catch DecodingError.typeMismatch
|
||||
{
|
||||
// Default
|
||||
let rawEntitlements = try container.decode([String].self, forKey: .entitlements)
|
||||
|
||||
let entitlements = rawEntitlements.map { AppPermission(permission: $0, usageDescription: nil, type: .entitlement, context: context) }
|
||||
return entitlements
|
||||
}
|
||||
}
|
||||
|
||||
private func parsePrivacyPermissions(from container: KeyedDecodingContainer<CodingKeys>, into context: NSManagedObjectContext) throws -> [AppPermission]
|
||||
{
|
||||
guard container.contains(.privacy) else { return [] }
|
||||
|
||||
do
|
||||
{
|
||||
// Legacy
|
||||
// Must parse as [String: String], NOT [CodingKeys: String], to avoid incorrect DecodingError.typeMismatch error.
|
||||
let rawPermissions = try container.decode([[String: String]].self, forKey: .privacy)
|
||||
|
||||
let permissions = try rawPermissions.compactMap { (dictionary) -> AppPermission? in
|
||||
guard let name = dictionary[CodingKeys.name.rawValue] else {
|
||||
let context = DecodingError.Context(codingPath: container.codingPath, debugDescription: "Legacy privacy permissions must have `name` key.")
|
||||
throw DecodingError.keyNotFound(CodingKeys.name, context)
|
||||
}
|
||||
|
||||
let usageDescription = dictionary[CodingKeys.usageDescription.rawValue]
|
||||
|
||||
let convertedName = "NS" + name + "UsageDescription" // Convert legacy privacy permissions to their NS[Privacy]UsageDescription equivalent.
|
||||
let permission = AppPermission(permission: convertedName, usageDescription: usageDescription, type: .privacy, context: context)
|
||||
return permission
|
||||
}
|
||||
|
||||
return permissions
|
||||
}
|
||||
catch DecodingError.typeMismatch
|
||||
{
|
||||
// Default
|
||||
let rawPermissions = try container.decode([String: String?].self, forKey: .privacy)
|
||||
|
||||
let permissions = rawPermissions.map { AppPermission(permission: $0, usageDescription: $1, type: .privacy, context: context) }
|
||||
return permissions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
208
AltStoreCore/Model/AppScreenshot.swift
Normal file
208
AltStoreCore/Model/AppScreenshot.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,7 +253,7 @@ private extension DatabaseManager
|
||||
}
|
||||
|
||||
// Make sure to always update source URL to be current.
|
||||
altStoreSource.sourceURL = Source.altStoreSourceURL
|
||||
try! altStoreSource.setSourceURL(Source.altStoreSourceURL)
|
||||
|
||||
let storeApp: StoreApp
|
||||
|
||||
|
||||
@@ -156,6 +156,12 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
|
||||
{
|
||||
appVersion.managedObjectContext?.delete(appVersion)
|
||||
}
|
||||
|
||||
// Delete previous screenshots (different than below).
|
||||
for case let appScreenshot as AppScreenshot in previousApp._screenshots where appScreenshot.app == nil
|
||||
{
|
||||
appScreenshot.managedObjectContext?.delete(appScreenshot)
|
||||
}
|
||||
}
|
||||
|
||||
case is AppVersion where conflict.conflictingObjects.count == 2:
|
||||
@@ -181,8 +187,9 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
|
||||
return
|
||||
}
|
||||
|
||||
var sortedVersionIDsByGlobalAppID = [String: NSOrderedSet]()
|
||||
var permissionsByGlobalAppID = [String: Set<AnyHashable>]()
|
||||
var sortedVersionIDsByGlobalAppID = [String: NSOrderedSet]()
|
||||
var sortedScreenshotIDsByGlobalAppID = [String: NSOrderedSet]()
|
||||
|
||||
var featuredAppIDsBySourceID = [String: [String]]()
|
||||
|
||||
@@ -212,10 +219,19 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
|
||||
databaseVersion.managedObjectContext?.delete(databaseVersion)
|
||||
}
|
||||
|
||||
// Screenshots
|
||||
let contextScreenshotIDs = NSOrderedSet(array: contextApp._screenshots.lazy.compactMap { $0 as? AppScreenshot }.map { $0.screenshotID })
|
||||
for case let databaseScreenshot as AppScreenshot in databaseObject._screenshots where !contextScreenshotIDs.contains(databaseScreenshot.screenshotID)
|
||||
{
|
||||
// Screenshot ID does NOT exist in context, so delete existing databaseScreenshot.
|
||||
databaseScreenshot.managedObjectContext?.delete(databaseScreenshot)
|
||||
}
|
||||
|
||||
if let globallyUniqueID = contextApp.globallyUniqueID
|
||||
{
|
||||
permissionsByGlobalAppID[globallyUniqueID] = contextPermissions
|
||||
sortedVersionIDsByGlobalAppID[globallyUniqueID] = contextVersionIDs
|
||||
sortedScreenshotIDsByGlobalAppID[globallyUniqueID] = contextScreenshotIDs
|
||||
}
|
||||
|
||||
case let databaseObject as Source:
|
||||
@@ -295,6 +311,31 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
|
||||
|
||||
appVersions = fixedAppVersions
|
||||
}
|
||||
|
||||
// Screenshots
|
||||
if let sortedScreenshotIDs = sortedScreenshotIDsByGlobalAppID[globallyUniqueID],
|
||||
let sortedScreenshotIDsArray = sortedScreenshotIDs.array as? [String],
|
||||
case let databaseScreenshotIDs = databaseObject.allScreenshots.map({ $0.screenshotID }),
|
||||
databaseScreenshotIDs != sortedScreenshotIDsArray
|
||||
{
|
||||
// Screenshot order is incorrect, so attempt to fix by re-sorting.
|
||||
let fixedScreenshots = databaseObject.allScreenshots.sorted { (screenshotA, screenshotB) in
|
||||
let indexA = sortedScreenshotIDs.index(of: screenshotA.screenshotID)
|
||||
let indexB = sortedScreenshotIDs.index(of: screenshotB.screenshotID)
|
||||
return indexA < indexB
|
||||
}
|
||||
|
||||
let appScreenshotIDs = fixedScreenshots.map { $0.screenshotID }
|
||||
if appScreenshotIDs == sortedScreenshotIDsArray
|
||||
{
|
||||
databaseObject.setScreenshots(fixedScreenshots)
|
||||
}
|
||||
else
|
||||
{
|
||||
// Screenshots are still not in correct order, but not worth throwing error so ignore.
|
||||
print("Failed to re-sort screenshots into correct order. Expected:", sortedScreenshotIDsArray)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always update versions post-merging to make sure latestSupportedVersion is correct.
|
||||
|
||||
@@ -11,11 +11,7 @@ import UIKit
|
||||
|
||||
public extension Source
|
||||
{
|
||||
#if ALPHA
|
||||
static let altStoreIdentifier = "com.rileytestut.AltStore.Alpha"
|
||||
#else
|
||||
static let altStoreIdentifier = "com.rileytestut.AltStore"
|
||||
#endif
|
||||
static let altStoreIdentifier = try! Source.sourceID(from: Source.altStoreSourceURL)
|
||||
|
||||
#if STAGING
|
||||
|
||||
@@ -62,7 +58,7 @@ public class Source: NSManagedObject, Fetchable, Decodable
|
||||
{
|
||||
/* Properties */
|
||||
@NSManaged public var name: String
|
||||
@NSManaged public var identifier: String
|
||||
@NSManaged public private(set) var identifier: String
|
||||
@NSManaged public var sourceURL: URL
|
||||
|
||||
/* Source Detail */
|
||||
@@ -114,7 +110,6 @@ public class Source: NSManagedObject, Fetchable, Decodable
|
||||
private enum CodingKeys: String, CodingKey
|
||||
{
|
||||
case name
|
||||
case identifier
|
||||
case sourceURL
|
||||
case subtitle
|
||||
case localizedDescription = "description"
|
||||
@@ -143,11 +138,8 @@ public class Source: NSManagedObject, Fetchable, Decodable
|
||||
|
||||
do
|
||||
{
|
||||
self.sourceURL = sourceURL
|
||||
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.name = try container.decode(String.self, forKey: .name)
|
||||
self.identifier = try container.decode(String.self, forKey: .identifier)
|
||||
|
||||
// Optional Values
|
||||
self.subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle)
|
||||
@@ -173,7 +165,6 @@ public class Source: NSManagedObject, Fetchable, Decodable
|
||||
|
||||
for (index, app) in apps.enumerated()
|
||||
{
|
||||
app.sourceIdentifier = self.identifier
|
||||
app.sortIndex = Int32(index)
|
||||
}
|
||||
self._apps = NSMutableOrderedSet(array: apps)
|
||||
@@ -181,7 +172,6 @@ public class Source: NSManagedObject, Fetchable, Decodable
|
||||
let newsItems = try container.decodeIfPresent([NewsItem].self, forKey: .news) ?? []
|
||||
for (index, item) in newsItems.enumerated()
|
||||
{
|
||||
item.sourceIdentifier = self.identifier
|
||||
item.sortIndex = Int32(index)
|
||||
}
|
||||
|
||||
@@ -203,6 +193,9 @@ public class Source: NSManagedObject, Fetchable, Decodable
|
||||
let featuredAppBundleIDs = try container.decodeIfPresent([String].self, forKey: .featuredApps)
|
||||
let featuredApps = featuredAppBundleIDs?.compactMap { appsByID[$0] }
|
||||
self.setFeaturedApps(featuredApps)
|
||||
|
||||
// Updates identifier + apps & newsItems
|
||||
try self.setSourceURL(sourceURL)
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -257,6 +250,54 @@ public extension Source
|
||||
|
||||
internal extension Source
|
||||
{
|
||||
class func sourceID(from sourceURL: URL) throws -> String
|
||||
{
|
||||
// Based on https://encyclopedia.pub/entry/29841
|
||||
|
||||
guard var components = URLComponents(url: sourceURL, resolvingAgainstBaseURL: true) else { throw URLError(.badURL, userInfo: [NSURLErrorKey: sourceURL]) }
|
||||
|
||||
if components.scheme == nil && components.host == nil
|
||||
{
|
||||
// Special handling for URLs without explicit scheme & incorrectly assumed to have nil host (e.g. "altstore.io/my/path")
|
||||
guard let updatedComponents = URLComponents(string: "https://" + sourceURL.absoluteString) else { throw URLError(.cannotFindHost, userInfo: [NSURLErrorKey: sourceURL]) }
|
||||
components = updatedComponents
|
||||
}
|
||||
|
||||
// 1. Don't use percent encoding
|
||||
guard let host = components.host else { throw URLError(.cannotFindHost, userInfo: [NSURLErrorKey: sourceURL]) }
|
||||
|
||||
// 2. Ignore scheme
|
||||
var standardizedID = host
|
||||
|
||||
// 3. Add port (if not default)
|
||||
if let port = components.port, port != 80 && port != 443
|
||||
{
|
||||
standardizedID += ":" + String(port)
|
||||
}
|
||||
|
||||
// 4. Add path without fragment or query parameters
|
||||
// 5. Remove duplicate slashes
|
||||
let path = components.path.replacingOccurrences(of: "//", with: "/") // Only remove duplicate slashes from path, not entire URL.
|
||||
standardizedID += path // path has leading `/`
|
||||
|
||||
// 6. Convert to lowercase
|
||||
standardizedID = standardizedID.lowercased()
|
||||
|
||||
// 7. Remove trailing `/`
|
||||
if standardizedID.hasSuffix("/")
|
||||
{
|
||||
standardizedID.removeLast()
|
||||
}
|
||||
|
||||
// 8. Remove leading "www"
|
||||
if standardizedID.hasPrefix("www.")
|
||||
{
|
||||
standardizedID.removeFirst(4)
|
||||
}
|
||||
|
||||
return standardizedID
|
||||
}
|
||||
|
||||
func setFeaturedApps(_ featuredApps: [StoreApp]?)
|
||||
{
|
||||
// Explicitly update relationships for all apps to ensure featuredApps merges correctly.
|
||||
@@ -278,6 +319,27 @@ internal extension Source
|
||||
}
|
||||
}
|
||||
|
||||
public extension Source
|
||||
{
|
||||
func setSourceURL(_ sourceURL: URL) throws
|
||||
{
|
||||
let identifier = try Source.sourceID(from: sourceURL)
|
||||
|
||||
self.identifier = identifier
|
||||
self.sourceURL = sourceURL
|
||||
|
||||
for app in self.apps
|
||||
{
|
||||
app.sourceIdentifier = identifier
|
||||
}
|
||||
|
||||
for newsItem in self.newsItems
|
||||
{
|
||||
newsItem.sourceIdentifier = identifier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Source
|
||||
{
|
||||
@nonobjc class func fetchRequest() -> NSFetchRequest<Source>
|
||||
@@ -289,8 +351,7 @@ public extension Source
|
||||
{
|
||||
let source = Source(context: context)
|
||||
source.name = "AltStore"
|
||||
source.identifier = Source.altStoreIdentifier
|
||||
source.sourceURL = Source.altStoreSourceURL
|
||||
try! source.setSourceURL(Source.altStoreSourceURL)
|
||||
|
||||
return source
|
||||
}
|
||||
|
||||
@@ -23,12 +23,6 @@ public extension StoreApp
|
||||
#endif
|
||||
|
||||
static let dolphinAppID = "me.oatmealdome.dolphinios-njb"
|
||||
|
||||
private struct AppPermissions: Decodable
|
||||
{
|
||||
var entitlements: [AppPermission]?
|
||||
var privacy: [AppPermission]?
|
||||
}
|
||||
}
|
||||
|
||||
@objc(StoreApp)
|
||||
@@ -74,6 +68,11 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
|
||||
{
|
||||
permission.sourceID = self.sourceIdentifier ?? ""
|
||||
}
|
||||
|
||||
for screenshot in self.allScreenshots
|
||||
{
|
||||
screenshot.sourceID = self.sourceIdentifier ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@NSManaged private var primitiveSourceIdentifier: String?
|
||||
@@ -114,6 +113,11 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
|
||||
return self._versions.array as! [AppVersion]
|
||||
}
|
||||
|
||||
@nonobjc public var allScreenshots: [AppScreenshot] {
|
||||
return self._screenshots.array as! [AppScreenshot]
|
||||
}
|
||||
@NSManaged @objc(screenshots) private(set) var _screenshots: NSOrderedSet
|
||||
|
||||
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
|
||||
{
|
||||
super.init(entity: entity, insertInto: context)
|
||||
@@ -125,18 +129,21 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
|
||||
case bundleIdentifier
|
||||
case developerName
|
||||
case localizedDescription
|
||||
case version
|
||||
case versionDescription
|
||||
case versionDate
|
||||
case iconURL
|
||||
case screenshotURLs
|
||||
case downloadURL
|
||||
case screenshots
|
||||
case tintColor
|
||||
case subtitle
|
||||
case permissions = "appPermissions"
|
||||
case size
|
||||
case isBeta = "beta"
|
||||
case versions
|
||||
|
||||
// Legacy
|
||||
case version
|
||||
case versionDescription
|
||||
case versionDate
|
||||
case downloadURL
|
||||
case screenshotURLs
|
||||
}
|
||||
|
||||
public required init(from decoder: Decoder) throws
|
||||
@@ -157,7 +164,6 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
|
||||
self.subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle)
|
||||
|
||||
self.iconURL = try container.decode(URL.self, forKey: .iconURL)
|
||||
self.screenshotURLs = try container.decodeIfPresent([URL].self, forKey: .screenshotURLs) ?? []
|
||||
|
||||
if let tintColorHex = try container.decodeIfPresent(String.self, forKey: .tintColor)
|
||||
{
|
||||
@@ -170,12 +176,37 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
|
||||
|
||||
self.isBeta = try container.decodeIfPresent(Bool.self, forKey: .isBeta) ?? false
|
||||
|
||||
let appScreenshots: [AppScreenshot]
|
||||
|
||||
if let screenshots = try container.decodeIfPresent(AppScreenshots.self, forKey: .screenshots)
|
||||
{
|
||||
appScreenshots = screenshots.screenshots
|
||||
}
|
||||
else if let screenshotURLs = try container.decodeIfPresent([URL].self, forKey: .screenshotURLs)
|
||||
{
|
||||
// Assume 9:16 iPhone 8 screen dimensions for legacy screenshotURLs.
|
||||
let legacyAspectRatio = CGSize(width: 750, height: 1334)
|
||||
|
||||
appScreenshots = screenshotURLs.map { imageURL in
|
||||
let screenshot = AppScreenshot(imageURL: imageURL, size: legacyAspectRatio, deviceType: .iphone, context: context)
|
||||
return screenshot
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
appScreenshots = []
|
||||
}
|
||||
|
||||
for screenshot in appScreenshots
|
||||
{
|
||||
screenshot.appBundleID = self.bundleIdentifier
|
||||
}
|
||||
|
||||
self.setScreenshots(appScreenshots)
|
||||
|
||||
if let appPermissions = try container.decodeIfPresent(AppPermissions.self, forKey: .permissions)
|
||||
{
|
||||
appPermissions.entitlements?.forEach { $0.type = .entitlement }
|
||||
appPermissions.privacy?.forEach { $0.type = .privacy }
|
||||
|
||||
let allPermissions = (appPermissions.entitlements ?? []) + (appPermissions.privacy ?? [])
|
||||
let allPermissions = appPermissions.entitlements + appPermissions.privacy
|
||||
for permission in allPermissions
|
||||
{
|
||||
permission.appBundleID = self.bundleIdentifier
|
||||
@@ -279,6 +310,58 @@ internal extension StoreApp
|
||||
|
||||
self._permissions = permissions as NSSet
|
||||
}
|
||||
|
||||
func setScreenshots(_ screenshots: [AppScreenshot])
|
||||
{
|
||||
for case let screenshot as AppScreenshot in self._screenshots
|
||||
{
|
||||
if screenshots.contains(screenshot)
|
||||
{
|
||||
screenshot.app = self
|
||||
}
|
||||
else
|
||||
{
|
||||
screenshot.app = nil
|
||||
}
|
||||
}
|
||||
|
||||
self._screenshots = NSOrderedSet(array: screenshots)
|
||||
|
||||
// Backwards compatibility
|
||||
self.screenshotURLs = screenshots.map { $0.imageURL }
|
||||
}
|
||||
}
|
||||
|
||||
public extension StoreApp
|
||||
{
|
||||
func screenshots(for deviceType: ALTDeviceType) -> [AppScreenshot]
|
||||
{
|
||||
//TODO: Support multiple device types
|
||||
let filteredScreenshots = self.allScreenshots.filter { $0.deviceType == deviceType }
|
||||
return filteredScreenshots
|
||||
}
|
||||
|
||||
func preferredScreenshots() -> [AppScreenshot]
|
||||
{
|
||||
let deviceType: ALTDeviceType
|
||||
|
||||
if UIDevice.current.model.contains("iPad")
|
||||
{
|
||||
deviceType = .ipad
|
||||
}
|
||||
else
|
||||
{
|
||||
deviceType = .iphone
|
||||
}
|
||||
|
||||
let preferredScreenshots = self.screenshots(for: deviceType)
|
||||
guard !preferredScreenshots.isEmpty else {
|
||||
// There are no screenshots for deviceType, so return _all_ screenshots instead.
|
||||
return self.allScreenshots
|
||||
}
|
||||
|
||||
return preferredScreenshots
|
||||
}
|
||||
}
|
||||
|
||||
public extension StoreApp
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import RegexBuilder
|
||||
|
||||
import AltSign
|
||||
|
||||
extension ALTAppPermissionType
|
||||
@@ -156,5 +158,10 @@ extension ALTAppPrivacyPermission: ALTAppPermission
|
||||
{
|
||||
public var type: ALTAppPermissionType { .privacy }
|
||||
|
||||
public var synthesizedName: String? { nil }
|
||||
public var synthesizedName: String? {
|
||||
guard #available(iOS 16, *), let match = self.rawValue.wholeMatch(of: Regex.privacyPermission) else { return nil }
|
||||
|
||||
let synthesizedNamed = String(match.1)
|
||||
return synthesizedNamed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -865,291 +865,291 @@
|
||||
</dict>
|
||||
<key>privacy</key>
|
||||
<dict>
|
||||
<key>BluetoothAlways</key>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Bluetooth</string>
|
||||
<key>key</key>
|
||||
<string>BluetoothAlways</string>
|
||||
<string>NSBluetoothAlwaysUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>antenna.radiowaves.left.and.right</string>
|
||||
</dict>
|
||||
<key>BluetoothPeripheral</key>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Bluetooth (Peripherals)</string>
|
||||
<key>key</key>
|
||||
<string>BluetoothPeripheral</string>
|
||||
<string>NSBluetoothPeripheralUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>antenna.radiowaves.left.and.right</string>
|
||||
</dict>
|
||||
<key>Calendars</key>
|
||||
<key>NSCalendarsUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Calendars</string>
|
||||
<key>key</key>
|
||||
<string>Calendars</string>
|
||||
<string>NSCalendarsUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>calendar</string>
|
||||
</dict>
|
||||
<key>Reminders</key>
|
||||
<key>NSRemindersUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Reminders</string>
|
||||
<key>key</key>
|
||||
<string>Reminders</string>
|
||||
<string>NSRemindersUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>note.text</string>
|
||||
</dict>
|
||||
<key>Camera</key>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Camera</string>
|
||||
<key>key</key>
|
||||
<string>Camera</string>
|
||||
<string>NSCameraUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>camera</string>
|
||||
</dict>
|
||||
<key>Microphone</key>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Microphone</string>
|
||||
<key>key</key>
|
||||
<string>Microphone</string>
|
||||
<string>NSMicrophoneUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>mic</string>
|
||||
</dict>
|
||||
<key>Contacts</key>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Contacts</string>
|
||||
<key>key</key>
|
||||
<string>Contacts</string>
|
||||
<string>NSContactsUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>person.crop.circle</string>
|
||||
</dict>
|
||||
<key>FaceID</key>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Face ID</string>
|
||||
<key>key</key>
|
||||
<string>FaceID</string>
|
||||
<string>NSFaceIDUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>faceid</string>
|
||||
</dict>
|
||||
<key>GKFriendList</key>
|
||||
<key>NSGKFriendListUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Game Center Friends List</string>
|
||||
<key>key</key>
|
||||
<string>GKFriendList</string>
|
||||
<string>NSGKFriendListUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>gamecontroller</string>
|
||||
</dict>
|
||||
<key>HealthClinicalHealthRecordsShare</key>
|
||||
<key>NSHealthClinicalHealthRecordsShareUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Health (Clinical Records)</string>
|
||||
<key>key</key>
|
||||
<string>HealthClinicalHealthRecordsShare</string>
|
||||
<string>NSHealthClinicalHealthRecordsShareUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>heart.text.square</string>
|
||||
</dict>
|
||||
<key>HealthShare</key>
|
||||
<key>NSHealthShareUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Health</string>
|
||||
<key>key</key>
|
||||
<string>HealthShare</string>
|
||||
<string>NSHealthShareUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>heart.text.square</string>
|
||||
</dict>
|
||||
<key>HealthUpdate</key>
|
||||
<key>NSHealthUpdateUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Health (Add)</string>
|
||||
<key>key</key>
|
||||
<string>HealthUpdate</string>
|
||||
<string>NSHealthUpdateUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>heart.text.square</string>
|
||||
</dict>
|
||||
<key>HomeKit</key>
|
||||
<key>NSHomeKitUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>HomeKit</string>
|
||||
<key>key</key>
|
||||
<string>HomeKit</string>
|
||||
<string>NSHomeKitUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>homekit</string>
|
||||
</dict>
|
||||
<key>LocationAlways</key>
|
||||
<key>NSLocationAlwaysUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Location (Background)</string>
|
||||
<key>key</key>
|
||||
<string>LocationAlways</string>
|
||||
<string>NSLocationAlwaysUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>location.fill</string>
|
||||
</dict>
|
||||
<key>LocationAlwaysAndWhenInUse</key>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Location (Always)</string>
|
||||
<key>key</key>
|
||||
<string>LocationAlwaysAndWhenInUse</string>
|
||||
<string>NSLocationAlwaysAndWhenInUseUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>location.fill</string>
|
||||
</dict>
|
||||
<key>Location</key>
|
||||
<key>NSLocationUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Location</string>
|
||||
<key>key</key>
|
||||
<string>Location</string>
|
||||
<string>NSLocationUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>location.fill</string>
|
||||
</dict>
|
||||
<key>LocationWhenInUse</key>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Location (When Using)</string>
|
||||
<key>key</key>
|
||||
<string>LocationWhenInUse</string>
|
||||
<string>NSLocationWhenInUseUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>location</string>
|
||||
</dict>
|
||||
<key>LocationTemporary</key>
|
||||
<key>NSLocationTemporaryUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Location (Temporary)</string>
|
||||
<key>key</key>
|
||||
<string>LocationTemporary</string>
|
||||
<string>NSLocationTemporaryUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>location</string>
|
||||
</dict>
|
||||
<key>AppleMusic</key>
|
||||
<key>NSAppleMusicUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Apple Music</string>
|
||||
<key>key</key>
|
||||
<string>AppleMusic</string>
|
||||
<string>NSAppleMusicUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>music.note</string>
|
||||
</dict>
|
||||
<key>Motion</key>
|
||||
<key>NSMotionUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Motion</string>
|
||||
<key>key</key>
|
||||
<string>Motion</string>
|
||||
<string>NSMotionUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>figure.run</string>
|
||||
</dict>
|
||||
<key>FallDetection</key>
|
||||
<key>NSFallDetectionUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Fall Detection</string>
|
||||
<key>key</key>
|
||||
<string>FallDetection</string>
|
||||
<string>NSFallDetectionUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>figure.fall</string>
|
||||
</dict>
|
||||
<key>LocalNetwork</key>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Local Network</string>
|
||||
<key>key</key>
|
||||
<string>LocalNetwork</string>
|
||||
<string>NSLocalNetworkUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>network</string>
|
||||
</dict>
|
||||
<key>NearbyInteraction</key>
|
||||
<key>NSNearbyInteractionUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Nearby Interaction</string>
|
||||
<key>key</key>
|
||||
<string>NearbyInteraction</string>
|
||||
<string>NSNearbyInteractionUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>dot.radiowaves.left.and.right</string>
|
||||
</dict>
|
||||
<key>NearbyInteractionAllowOnce</key>
|
||||
<key>NSNearbyInteractionAllowOnceUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Nearby Interaction (Once)</string>
|
||||
<key>key</key>
|
||||
<string>NearbyInteractionAllowOnce</string>
|
||||
<string>NSNearbyInteractionAllowOnceUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>dot.radiowaves.left.and.right</string>
|
||||
</dict>
|
||||
<key>NFCReader</key>
|
||||
<key>NFCReaderUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>NFC</string>
|
||||
<key>key</key>
|
||||
<string>NFCReader</string>
|
||||
<string>NSNFCReaderUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>sensor.tag.radiowaves.forward</string>
|
||||
</dict>
|
||||
<key>PhotoLibraryAdd</key>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Photos (Add)</string>
|
||||
<key>key</key>
|
||||
<string>PhotoLibraryAdd</string>
|
||||
<string>NSPhotoLibraryAddUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>photo</string>
|
||||
</dict>
|
||||
<key>PhotoLibrary</key>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Photos</string>
|
||||
<key>key</key>
|
||||
<string>PhotoLibrary</string>
|
||||
<string>NSPhotoLibraryUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>photo.stack</string>
|
||||
</dict>
|
||||
<key>UserTracking</key>
|
||||
<key>NSUserTrackingUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>User Tracking</string>
|
||||
<key>key</key>
|
||||
<string>UserTracking</string>
|
||||
<string>NSUserTrackingUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>location.magnifyingglass</string>
|
||||
</dict>
|
||||
<key>SensorKit</key>
|
||||
<key>NSSensorKitUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>SensorKit</string>
|
||||
<key>key</key>
|
||||
<string>SensorKit</string>
|
||||
<string>NSSensorKitUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>sensor</string>
|
||||
</dict>
|
||||
<key>Siri</key>
|
||||
<key>NSSiriUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Siri</string>
|
||||
<key>key</key>
|
||||
<string>Siri</string>
|
||||
<string>NSSiriUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>mic.circle</string>
|
||||
</dict>
|
||||
<key>SpeechRecognition</key>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>Speech Recognition</string>
|
||||
<key>key</key>
|
||||
<string>SpeechRecognition</string>
|
||||
<string>NSSpeechRecognitionUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>waveform.and.mic</string>
|
||||
</dict>
|
||||
<key>Identity</key>
|
||||
<key>NSIdentityUsageDescription</key>
|
||||
<dict>
|
||||
<key>name</key>
|
||||
<string>ID Cards</string>
|
||||
<key>key</key>
|
||||
<string>Identity</string>
|
||||
<string>NSIdentityUsageDescription</string>
|
||||
<key>symbol</key>
|
||||
<string>wallet.pass</string>
|
||||
</dict>
|
||||
|
||||
159
AltTests/AltTests+Sources.swift
Normal file
159
AltTests/AltTests+Sources.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ enum TestErrorCode: Int, ALTErrorEnum, CaseIterable
|
||||
}
|
||||
}
|
||||
|
||||
extension TestError
|
||||
extension DefaultLocalizedError<TestErrorCode>
|
||||
{
|
||||
static var allErrors: [TestError] {
|
||||
return Code.allCases.map { TestError($0) }
|
||||
@@ -70,12 +70,16 @@ extension VerificationError
|
||||
static var testErrors: [VerificationError] {
|
||||
let app = ALTApplication(fileURL: Bundle.main.bundleURL)!
|
||||
|
||||
return VerificationError.Code.allCases.map { code -> VerificationError in
|
||||
return VerificationError.Code.allCases.compactMap { code -> VerificationError? in
|
||||
switch code
|
||||
{
|
||||
case .privateEntitlements: return VerificationError.privateEntitlements(["dynamic-codesigning": true], app: app)
|
||||
case .mismatchedBundleIdentifiers: return VerificationError.mismatchedBundleIdentifiers(sourceBundleID: "com.rileytestut.App", app: app)
|
||||
case .iOSVersionNotSupported: return VerificationError.iOSVersionNotSupported(app: app, requiredOSVersion: OperatingSystemVersion(majorVersion: 21, minorVersion: 1, patchVersion: 0))
|
||||
case .mismatchedHash: return VerificationError.mismatchedHash("12345", expectedHash: "67890", app: app)
|
||||
case .mismatchedVersion: return VerificationError.mismatchedVersion("1.0", expectedVersion: "1.1", app: app)
|
||||
case .mismatchedBuildVersion: return VerificationError.mismatchedBuildVersion("1", expectedVersion: "28", app: app)
|
||||
case .undeclaredPermissions: return VerificationError.undeclaredPermissions([ALTEntitlement.appGroups, ALTAppPrivacyPermission.bluetooth], app: app)
|
||||
case .addedPermissions: return nil //VerificationError.addedPermissions([ALTAppPrivacyPermission.appleMusic, ALTEntitlement.interAppAudio], appVersion: app)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -219,7 +223,6 @@ extension OperationError
|
||||
{
|
||||
case .unknown: return .unknown()
|
||||
case .unknownResult: return .unknownResult
|
||||
case .cancelled: return .cancelled
|
||||
case .timedOut: return .timedOut
|
||||
case .notAuthenticated: return .notAuthenticated
|
||||
case .appNotFound: return .appNotFound(name: "Delta")
|
||||
@@ -233,6 +236,7 @@ extension OperationError
|
||||
case .serverNotFound: return .serverNotFound
|
||||
case .connectionFailed: return .connectionFailed
|
||||
case .connectionDropped: return .connectionDropped
|
||||
case .forbidden: return .forbidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ extern NSErrorUserInfoKey const ALTDeviceNameErrorKey;
|
||||
extern NSErrorUserInfoKey const ALTOperatingSystemNameErrorKey;
|
||||
extern NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey;
|
||||
|
||||
extern NSErrorUserInfoKey const ALTNSCodingPathKey;
|
||||
|
||||
typedef NS_ERROR_ENUM(AltServerErrorDomain, ALTServerError)
|
||||
{
|
||||
ALTServerErrorUnderlyingError = -1,
|
||||
|
||||
@@ -30,6 +30,8 @@ NSErrorUserInfoKey const ALTDeviceNameErrorKey = @"deviceName";
|
||||
NSErrorUserInfoKey const ALTOperatingSystemNameErrorKey = @"ALTOperatingSystemName";
|
||||
NSErrorUserInfoKey const ALTOperatingSystemVersionErrorKey = @"ALTOperatingSystemVersion";
|
||||
|
||||
NSErrorUserInfoKey const ALTNSCodingPathKey = @"NSCodingPath";
|
||||
|
||||
@implementation NSError (ALTServerError)
|
||||
|
||||
+ (void)load
|
||||
|
||||
Reference in New Issue
Block a user