diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 407830a3..3aae3ab4 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -435,6 +435,7 @@ 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 */; }; 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 */; }; @@ -1096,6 +1097,7 @@ 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 = ""; }; 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 = ""; }; @@ -1666,6 +1668,7 @@ BF66EEC92501AECA007EE018 /* Account.swift */, BF66EEC72501AECA007EE018 /* AppID.swift */, BF66EEC62501AECA007EE018 /* AppPermission.swift */, + D5F9821C2AB900060045751F /* AppScreenshot.swift */, D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */, BF66EECA2501AECA007EE018 /* DatabaseManager.swift */, D5FD4ECA2A9532960097BEE8 /* DatabaseManager+Async.swift */, @@ -3056,6 +3059,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 */, diff --git a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 13.xcdatamodel/contents b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 13.xcdatamodel/contents index a1866d15..2f921b6e 100644 --- a/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 13.xcdatamodel/contents +++ b/AltStoreCore/Model/AltStore.xcdatamodeld/AltStore 13.xcdatamodel/contents @@ -42,6 +42,21 @@ + + + + + + + + + + + + + + + @@ -207,6 +222,7 @@ + diff --git a/AltStoreCore/Model/AppScreenshot.swift b/AltStoreCore/Model/AppScreenshot.swift new file mode 100644 index 00000000..5f5ad149 --- /dev/null +++ b/AltStoreCore/Model/AppScreenshot.swift @@ -0,0 +1,85 @@ +// +// AppScreenshot.swift +// AltStoreCore +// +// Created by Riley Testut on 9/18/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import CoreData + +@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? + + @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?, context: NSManagedObjectContext) + { + super.init(entity: AppScreenshot.entity(), insertInto: context) + + self.imageURL = imageURL + self.size = size + } + + 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 extension AppScreenshot +{ + @nonobjc class func fetchRequest() -> NSFetchRequest + { + return NSFetchRequest(entityName: "AppScreenshot") + } +} diff --git a/AltStoreCore/Model/MergePolicy.swift b/AltStoreCore/Model/MergePolicy.swift index a2a2cdab..134800dd 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 sortedScreenshotURLsByGlobalAppID = [String: NSOrderedSet]() var featuredAppIDsBySourceID = [String: [String]]() @@ -212,10 +219,19 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy databaseVersion.managedObjectContext?.delete(databaseVersion) } + // Screenshots + let contextScreenshotURLs = NSOrderedSet(array: contextApp._screenshots.lazy.compactMap { $0 as? AppScreenshot }.map { $0.imageURL }) + for case let databaseScreenshot as AppScreenshot in databaseObject._screenshots where !contextScreenshotURLs.contains(databaseScreenshot.imageURL) + { + // Screenshot's imageURL does NOT exist in context, so delete existing databaseScreenshot. + databaseScreenshot.managedObjectContext?.delete(databaseScreenshot) + } + if let globallyUniqueID = contextApp.globallyUniqueID { permissionsByGlobalAppID[globallyUniqueID] = contextPermissions sortedVersionIDsByGlobalAppID[globallyUniqueID] = contextVersionIDs + sortedScreenshotURLsByGlobalAppID[globallyUniqueID] = contextScreenshotURLs } case let databaseObject as Source: @@ -295,6 +311,31 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy appVersions = fixedAppVersions } + + // Screenshots + if let sortedScreenshotURLs = sortedScreenshotURLsByGlobalAppID[globallyUniqueID], + let sortedScreenshotURLsArray = sortedScreenshotURLs.array as? [URL], + case let databaseScreenshotURLs = databaseObject.screenshots.map({ $0.imageURL }), + databaseScreenshotURLs != sortedScreenshotURLsArray + { + // Screenshot order is incorrect, so attempt to fix by re-sorting. + let fixedScreenshots = databaseObject.screenshots.sorted { (screenshotA, screenshotB) in + let indexA = sortedScreenshotURLs.index(of: screenshotA.imageURL) + let indexB = sortedScreenshotURLs.index(of: screenshotB.imageURL) + return indexA < indexB + } + + let appScreenshotURLs = fixedScreenshots.map { $0.imageURL } + if appScreenshotURLs == sortedScreenshotURLsArray + { + 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:", sortedScreenshotURLsArray) + } + } } // Always update versions post-merging to make sure latestSupportedVersion is correct. diff --git a/AltStoreCore/Model/StoreApp.swift b/AltStoreCore/Model/StoreApp.swift index 0ec2e56c..4741eb1a 100644 --- a/AltStoreCore/Model/StoreApp.swift +++ b/AltStoreCore/Model/StoreApp.swift @@ -136,6 +136,11 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable { permission.sourceID = self.sourceIdentifier ?? "" } + + for screenshot in self.screenshots + { + screenshot.sourceID = self.sourceIdentifier ?? "" + } } } @NSManaged private var primitiveSourceIdentifier: String? @@ -200,6 +205,10 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable guard let version = self.latestSupportedVersion else { return nil } return version.downloadURL } + @nonobjc public var screenshots: [AppScreenshot] { + return self._screenshots.array as! [AppScreenshot] + } + @NSManaged @objc(screenshots) private(set) var _screenshots: NSOrderedSet private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) { @@ -212,19 +221,24 @@ 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 platformURLs + 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 @@ -245,7 +259,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) ?? [] var downloadURL = try container.decodeIfPresent(URL.self, forKey: .downloadURL) let platformURLs = try container.decodeIfPresent(PlatformURLs.self.self, forKey: .platformURLs) @@ -291,6 +304,33 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable self.isBeta = try container.decodeIfPresent(Bool.self, forKey: .isBeta) ?? false + if let screenshots = try container.decodeIfPresent([AppScreenshot].self, forKey: .screenshots) + { + for screenshot in screenshots + { + screenshot.appBundleID = self.bundleIdentifier + } + + self.setScreenshots(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) + + let screenshots = screenshotURLs.map { imageURL in + let screenshot = AppScreenshot(imageURL: imageURL, size: legacyAspectRatio, context: context) + screenshot.appBundleID = self.bundleIdentifier + return screenshot + } + + self.setScreenshots(screenshots) + } + else + { + self.setScreenshots([]) + } + if let appPermissions = try container.decodeIfPresent(AppPermissions.self, forKey: .permissions) { let allPermissions = appPermissions.entitlements + appPermissions.privacy @@ -402,6 +442,26 @@ 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