diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 0e000a1c..9ce0ff11 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -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 = ""; }; BF989191250AAE86002ACF50 /* ViewApp.intentdefinition */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.intentdefinition; path = ViewApp.intentdefinition; sourceTree = ""; }; BF9ABA4422DCFF43008935CF /* BrowseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewController.swift; sourceTree = ""; }; - BF9ABA4622DD0638008935CF /* BrowseCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseCollectionViewCell.swift; sourceTree = ""; }; BF9ABA4822DD0742008935CF /* ScreenshotCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotCollectionViewCell.swift; sourceTree = ""; }; BF9ABA4A22DD137F008935CF /* NavigationBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NavigationBar.swift; sourceTree = ""; }; BF9ABA4C22DD16DE008935CF /* PillButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillButton.swift; sourceTree = ""; }; @@ -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 = ""; }; BFDB5B1522EE90D300F74113 /* Date+RelativeDate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+RelativeDate.swift"; sourceTree = ""; }; - BFDB5B2522EFBBEA00F74113 /* BrowseCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BrowseCollectionViewCell.xib; sourceTree = ""; }; BFDB6A0722AAED73007EA6D6 /* ResignAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResignAppOperation.swift; sourceTree = ""; }; BFDB6A0A22AAEDB7007EA6D6 /* Operation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Operation.swift; sourceTree = ""; }; BFDB6A0C22AAFC19007EA6D6 /* OperationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationError.swift; sourceTree = ""; }; @@ -976,10 +978,13 @@ D54058B82A1D6269008CCC58 /* AppPermissionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionProtocol.swift; sourceTree = ""; }; D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+AltStore.swift"; sourceTree = ""; }; D540E93728EE1BDE000F1B0F /* ErrorDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsViewController.swift; sourceTree = ""; }; + D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshotCollectionViewCell.swift; sourceTree = ""; }; D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorLogTableViewCell.swift; sourceTree = ""; }; D552B1D72A042A740066216F /* AppPermissionsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionsCard.swift; sourceTree = ""; }; D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppShortcuts.swift; sourceTree = ""; }; D55467C42A8D72C300F4CE90 /* ActiveAppsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveAppsWidget.swift; sourceTree = ""; }; + D56915052AD5D75B00A2B747 /* Regex+Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Regex+Permissions.swift"; sourceTree = ""; }; + D56915082AD5F3E800A2B747 /* AltTests+Sources.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AltTests+Sources.swift"; sourceTree = ""; }; D5708416292448DA00D42D34 /* OperatingSystemVersion+Comparable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OperatingSystemVersion+Comparable.swift"; sourceTree = ""; }; D571ADCD2A02FA7400B24B63 /* SourceError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceError.swift; sourceTree = ""; }; D571ADCF2A02FC7200B24B63 /* ALTAppPermission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ALTAppPermission.swift; sourceTree = ""; }; @@ -1020,7 +1025,9 @@ D5A2193329B14F94002229FC /* DeprecatedAPIs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeprecatedAPIs.swift; sourceTree = ""; }; D5ACE84428E3B8450021CAB9 /* ClearAppCacheOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClearAppCacheOperation.swift; sourceTree = ""; }; D5B6F6A82AD75D01007EED5A /* ProcessInfo+Previews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+Previews.swift"; sourceTree = ""; }; + D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewAppScreenshotsViewController.swift; sourceTree = ""; }; D5BA9E9A2A9FE1E8007C0661 /* JITManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JITManager.swift; sourceTree = ""; }; + D5C0E7662AD9C75900530CA4 /* AppCardCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCardCollectionViewCell.swift; sourceTree = ""; }; D5C8ACDA2A956B2B00669F92 /* Process+STPrivilegedTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Process+STPrivilegedTask.swift"; sourceTree = ""; }; D5CA0C4A280E141900469595 /* ManagedPatron.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedPatron.swift; sourceTree = ""; }; D5CA0C4C280E242500469595 /* AltStore 10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "AltStore 10.xcdatamodel"; sourceTree = ""; }; @@ -1028,7 +1035,6 @@ D5CD805C29CA2C1E00E591B0 /* HeaderContentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderContentViewController.swift; sourceTree = ""; }; D5CD805E29CA755E00E591B0 /* SourceDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceDetailViewController.swift; sourceTree = ""; }; D5CF56812A0D83F9006D93E2 /* VerificationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationError.swift; sourceTree = ""; }; - D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotProcessor.swift; sourceTree = ""; }; D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePatronsOperation.swift; sourceTree = ""; }; D5DB145828F9DC1000A8F606 /* ALTLocalizedError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ALTLocalizedError.swift; sourceTree = ""; }; D5E1E7C028077DE90016FC96 /* UpdateKnownSourcesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateKnownSourcesOperation.swift; sourceTree = ""; }; @@ -1037,6 +1043,8 @@ D5F48B4929CD0B67002B52A4 /* AsyncManaged.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncManaged.swift; sourceTree = ""; }; D5F5AF2D28FDD2EC00C938F5 /* TestErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestErrors.swift; sourceTree = ""; }; D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsViewController.swift; sourceTree = ""; }; + D5F9821C2AB900060045751F /* AppScreenshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshot.swift; sourceTree = ""; }; + D5F982202AB910180045751F /* AppScreenshotsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshotsViewController.swift; sourceTree = ""; }; D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = AltStore10ToAltStore11.xcmappingmodel; sourceTree = ""; }; D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreApp10ToStoreApp11Policy.swift; sourceTree = ""; }; D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFontDescriptor+Bold.swift"; sourceTree = ""; }; @@ -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 = ""; @@ -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 = ""; @@ -2132,6 +2141,16 @@ path = Previews; sourceTree = ""; }; + D5418F152AD740750014ABD6 /* Screenshots */ = { + isa = PBXGroup; + children = ( + D5F982202AB910180045751F /* AppScreenshotsViewController.swift */, + D5B6F6AA2AD76541007EED5A /* PreviewAppScreenshotsViewController.swift */, + D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */, + ); + path = Screenshots; + sourceTree = ""; + }; 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; diff --git a/AltStore/App Detail/AppContentViewController.swift b/AltStore/App Detail/AppContentViewController.swift index b9e882b8..f159738f 100644 --- a/AltStore/App Detail/AppContentViewController.swift +++ b/AltStore/App Detail/AppContentViewController.swift @@ -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 + @IBSegueAction + func makeAppScreenshotsViewController(_ coder: NSCoder, sender: Any?) -> UIViewController? { - let dataSource = RSTArrayCollectionViewPrefetchingDataSource(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 } diff --git a/AltStore/App Detail/AppViewController.swift b/AltStore/App Detail/AppViewController.swift index ce26fbe9..3d17e8f3 100644 --- a/AltStore/App Detail/AppViewController.swift +++ b/AltStore/App Detail/AppViewController.swift @@ -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 } diff --git a/AltStore/App Detail/Screenshots/AppScreenshotCollectionViewCell.swift b/AltStore/App Detail/Screenshots/AppScreenshotCollectionViewCell.swift new file mode 100644 index 00000000..efdabdfc --- /dev/null +++ b/AltStore/App Detail/Screenshots/AppScreenshotCollectionViewCell.swift @@ -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 + } +} diff --git a/AltStore/App Detail/Screenshots/AppScreenshotsViewController.swift b/AltStore/App Detail/Screenshots/AppScreenshotsViewController.swift new file mode 100644 index 00000000..46b89eb1 --- /dev/null +++ b/AltStore/App Detail/Screenshots/AppScreenshotsViewController.swift @@ -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 + { + let screenshots = self.app.preferredScreenshots() + + let dataSource = RSTArrayCollectionViewPrefetchingDataSource(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 +} diff --git a/AltStore/App Detail/Screenshots/PreviewAppScreenshotsViewController.swift b/AltStore/App Detail/Screenshots/PreviewAppScreenshotsViewController.swift new file mode 100644 index 00000000..8ffed408 --- /dev/null +++ b/AltStore/App Detail/Screenshots/PreviewAppScreenshotsViewController.swift @@ -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 + { + let screenshots = self.app.preferredScreenshots() + + let dataSource = RSTArrayCollectionViewPrefetchingDataSource(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 +} diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index bec67692..dd0b6778 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -49,7 +49,7 @@ - + @@ -252,45 +252,34 @@ - + - + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + - - - - + + + + - + @@ -314,7 +303,7 @@ - + @@ -383,7 +372,7 @@ - + @@ -437,8 +426,8 @@ + - @@ -450,6 +439,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + @@ -468,7 +482,7 @@ - + diff --git a/AltStore/Browse/BrowseCollectionViewCell.swift b/AltStore/Browse/BrowseCollectionViewCell.swift deleted file mode 100644 index 317c744d..00000000 --- a/AltStore/Browse/BrowseCollectionViewCell.swift +++ /dev/null @@ -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 - { - let dataSource = RSTArrayCollectionViewPrefetchingDataSource(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 - } -} diff --git a/AltStore/Browse/BrowseCollectionViewCell.xib b/AltStore/Browse/BrowseCollectionViewCell.xib deleted file mode 100644 index ecd02b4e..00000000 --- a/AltStore/Browse/BrowseCollectionViewCell.xib +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/AltStore/Browse/BrowseViewController.swift b/AltStore/Browse/BrowseViewController.swift index 5451cd45..2bd8e454 100644 --- a/AltStore/Browse/BrowseViewController.swift +++ b/AltStore/Browse/BrowseViewController.swift @@ -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(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 +} diff --git a/AltStore/Components/AppCardCollectionViewCell.swift b/AltStore/Components/AppCardCollectionViewCell.swift new file mode 100644 index 00000000..7177233f --- /dev/null +++ b/AltStore/Components/AppCardCollectionViewCell.swift @@ -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 + { + let dataSource = RSTArrayCollectionViewPrefetchingDataSource(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 + } +} diff --git a/AltStore/Operations/Errors/SourceError.swift b/AltStore/Operations/Errors/SourceError.swift index 6a5450bc..bbf0110d 100644 --- a/AltStore/Operations/Errors/SourceError.swift +++ b/AltStore/Operations/Errors/SourceError.swift @@ -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 } } diff --git a/AltStore/Operations/FetchSourceOperation.swift b/AltStore/Operations/FetchSourceOperation.swift index 8e916690..d9827560 100644 --- a/AltStore/Operations/FetchSourceOperation.swift +++ b/AltStore/Operations/FetchSourceOperation.swift @@ -122,7 +122,35 @@ class FetchSourceOperation: ResultOperation 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 diff --git a/AltStore/Operations/VerifyAppOperation.swift b/AltStore/Operations/VerifyAppOperation.swift index a36e1c08..f4823e38 100644 --- a/AltStore/Operations/VerifyAppOperation.swift +++ b/AltStore/Operations/VerifyAppOperation.swift @@ -212,41 +212,23 @@ private extension VerifyAppOperation // Privacy - let allPrivacyPermissions: Set - 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 = 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 } diff --git a/AltStore/Types/ScreenshotProcessor.swift b/AltStore/Types/ScreenshotProcessor.swift deleted file mode 100644 index 2f3f6b45..00000000 --- a/AltStore/Types/ScreenshotProcessor.swift +++ /dev/null @@ -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() } -} diff --git a/AltStoreCore/Extensions/Regex+Permissions.swift b/AltStoreCore/Extensions/Regex+Permissions.swift new file mode 100644 index 00000000..0528050d --- /dev/null +++ b/AltStoreCore/Extensions/Regex+Permissions.swift @@ -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)) + } + } +} diff --git a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 13.xcdatamodel/contents b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 13.xcdatamodel/contents index a1866d15..dc9d4873 100644 --- a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 13.xcdatamodel/contents +++ b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 13.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -42,6 +42,23 @@ + + + + + + + + + + + + + + + + + @@ -207,6 +224,7 @@ + diff --git a/AltStoreCore/Model/AppPermission.swift b/AltStoreCore/Model/AppPermission.swift index 8c72efe0..8f0bbf60 100644 --- a/AltStoreCore/Model/AppPermission.swift +++ b/AltStoreCore/Model/AppPermission.swift @@ -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, 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, 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 + } + } +} diff --git a/AltStoreCore/Model/AppScreenshot.swift b/AltStoreCore/Model/AppScreenshot.swift new file mode 100644 index 00000000..86f63fb2 --- /dev/null +++ b/AltStoreCore/Model/AppScreenshot.swift @@ -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 + { + return NSFetchRequest(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 + + 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) + } + } + } + } +} diff --git a/AltStoreCore/Model/DatabaseManager.swift b/AltStoreCore/Model/DatabaseManager.swift index ec1b78a4..0245420f 100644 --- a/AltStoreCore/Model/DatabaseManager.swift +++ b/AltStoreCore/Model/DatabaseManager.swift @@ -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 diff --git a/AltStoreCore/Model/MergePolicy.swift b/AltStoreCore/Model/MergePolicy.swift index a2a2cdab..4bac336c 100644 --- a/AltStoreCore/Model/MergePolicy.swift +++ b/AltStoreCore/Model/MergePolicy.swift @@ -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]() + 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. diff --git a/AltStoreCore/Model/Source.swift b/AltStoreCore/Model/Source.swift index e83ef9b9..2d958c92 100644 --- a/AltStoreCore/Model/Source.swift +++ b/AltStoreCore/Model/Source.swift @@ -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 @@ -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 } diff --git a/AltStoreCore/Model/StoreApp.swift b/AltStoreCore/Model/StoreApp.swift index 92c1f4d8..2a5035f8 100644 --- a/AltStoreCore/Model/StoreApp.swift +++ b/AltStoreCore/Model/StoreApp.swift @@ -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 diff --git a/AltStoreCore/Protocols/ALTAppPermission.swift b/AltStoreCore/Protocols/ALTAppPermission.swift index 5db62992..be1f0e7c 100644 --- a/AltStoreCore/Protocols/ALTAppPermission.swift +++ b/AltStoreCore/Protocols/ALTAppPermission.swift @@ -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 + } } diff --git a/AltStoreCore/Resources/Permissions.plist b/AltStoreCore/Resources/Permissions.plist index dbf20d38..138a48ec 100644 --- a/AltStoreCore/Resources/Permissions.plist +++ b/AltStoreCore/Resources/Permissions.plist @@ -865,291 +865,291 @@ privacy - BluetoothAlways + NSBluetoothAlwaysUsageDescription name Bluetooth key - BluetoothAlways + NSBluetoothAlwaysUsageDescription symbol antenna.radiowaves.left.and.right - BluetoothPeripheral + NSBluetoothPeripheralUsageDescription name Bluetooth (Peripherals) key - BluetoothPeripheral + NSBluetoothPeripheralUsageDescription symbol antenna.radiowaves.left.and.right - Calendars + NSCalendarsUsageDescription name Calendars key - Calendars + NSCalendarsUsageDescription symbol calendar - Reminders + NSRemindersUsageDescription name Reminders key - Reminders + NSRemindersUsageDescription symbol note.text - Camera + NSCameraUsageDescription name Camera key - Camera + NSCameraUsageDescription symbol camera - Microphone + NSMicrophoneUsageDescription name Microphone key - Microphone + NSMicrophoneUsageDescription symbol mic - Contacts + NSContactsUsageDescription name Contacts key - Contacts + NSContactsUsageDescription symbol person.crop.circle - FaceID + NSFaceIDUsageDescription name Face ID key - FaceID + NSFaceIDUsageDescription symbol faceid - GKFriendList + NSGKFriendListUsageDescription name Game Center Friends List key - GKFriendList + NSGKFriendListUsageDescription symbol gamecontroller - HealthClinicalHealthRecordsShare + NSHealthClinicalHealthRecordsShareUsageDescription name Health (Clinical Records) key - HealthClinicalHealthRecordsShare + NSHealthClinicalHealthRecordsShareUsageDescription symbol heart.text.square - HealthShare + NSHealthShareUsageDescription name Health key - HealthShare + NSHealthShareUsageDescription symbol heart.text.square - HealthUpdate + NSHealthUpdateUsageDescription name Health (Add) key - HealthUpdate + NSHealthUpdateUsageDescription symbol heart.text.square - HomeKit + NSHomeKitUsageDescription name HomeKit key - HomeKit + NSHomeKitUsageDescription symbol homekit - LocationAlways + NSLocationAlwaysUsageDescription name Location (Background) key - LocationAlways + NSLocationAlwaysUsageDescription symbol location.fill - LocationAlwaysAndWhenInUse + NSLocationAlwaysAndWhenInUseUsageDescription name Location (Always) key - LocationAlwaysAndWhenInUse + NSLocationAlwaysAndWhenInUseUsageDescription symbol location.fill - Location + NSLocationUsageDescription name Location key - Location + NSLocationUsageDescription symbol location.fill - LocationWhenInUse + NSLocationWhenInUseUsageDescription name Location (When Using) key - LocationWhenInUse + NSLocationWhenInUseUsageDescription symbol location - LocationTemporary + NSLocationTemporaryUsageDescription name Location (Temporary) key - LocationTemporary + NSLocationTemporaryUsageDescription symbol location - AppleMusic + NSAppleMusicUsageDescription name Apple Music key - AppleMusic + NSAppleMusicUsageDescription symbol music.note - Motion + NSMotionUsageDescription name Motion key - Motion + NSMotionUsageDescription symbol figure.run - FallDetection + NSFallDetectionUsageDescription name Fall Detection key - FallDetection + NSFallDetectionUsageDescription symbol figure.fall - LocalNetwork + NSLocalNetworkUsageDescription name Local Network key - LocalNetwork + NSLocalNetworkUsageDescription symbol network - NearbyInteraction + NSNearbyInteractionUsageDescription name Nearby Interaction key - NearbyInteraction + NSNearbyInteractionUsageDescription symbol dot.radiowaves.left.and.right - NearbyInteractionAllowOnce + NSNearbyInteractionAllowOnceUsageDescription name Nearby Interaction (Once) key - NearbyInteractionAllowOnce + NSNearbyInteractionAllowOnceUsageDescription symbol dot.radiowaves.left.and.right - NFCReader + NFCReaderUsageDescription name NFC key - NFCReader + NSNFCReaderUsageDescription symbol sensor.tag.radiowaves.forward - PhotoLibraryAdd + NSPhotoLibraryAddUsageDescription name Photos (Add) key - PhotoLibraryAdd + NSPhotoLibraryAddUsageDescription symbol photo - PhotoLibrary + NSPhotoLibraryUsageDescription name Photos key - PhotoLibrary + NSPhotoLibraryUsageDescription symbol photo.stack - UserTracking + NSUserTrackingUsageDescription name User Tracking key - UserTracking + NSUserTrackingUsageDescription symbol location.magnifyingglass - SensorKit + NSSensorKitUsageDescription name SensorKit key - SensorKit + NSSensorKitUsageDescription symbol sensor - Siri + NSSiriUsageDescription name Siri key - Siri + NSSiriUsageDescription symbol mic.circle - SpeechRecognition + NSSpeechRecognitionUsageDescription name Speech Recognition key - SpeechRecognition + NSSpeechRecognitionUsageDescription symbol waveform.and.mic - Identity + NSIdentityUsageDescription name ID Cards key - Identity + NSIdentityUsageDescription symbol wallet.pass diff --git a/AltTests/AltTests+Sources.swift b/AltTests/AltTests+Sources.swift new file mode 100644 index 00000000..b20d05f8 --- /dev/null +++ b/AltTests/AltTests+Sources.swift @@ -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) + } +} diff --git a/AltTests/TestErrors.swift b/AltTests/TestErrors.swift index c3171c5a..02728df0 100644 --- a/AltTests/TestErrors.swift +++ b/AltTests/TestErrors.swift @@ -31,7 +31,7 @@ enum TestErrorCode: Int, ALTErrorEnum, CaseIterable } } -extension TestError +extension DefaultLocalizedError { 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() } } } diff --git a/Shared/Categories/NSError+ALTServerError.h b/Shared/Categories/NSError+ALTServerError.h index 173a4d38..7368e4e0 100644 --- a/Shared/Categories/NSError+ALTServerError.h +++ b/Shared/Categories/NSError+ALTServerError.h @@ -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, diff --git a/Shared/Categories/NSError+ALTServerError.m b/Shared/Categories/NSError+ALTServerError.m index 3394b6ad..c77a9253 100644 --- a/Shared/Categories/NSError+ALTServerError.m +++ b/Shared/Categories/NSError+ALTServerError.m @@ -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