// // MergePolicy.swift // AltStore // // Created by Riley Testut on 7/23/19. // Copyright © 2019 Riley Testut. All rights reserved. // import CoreData import AltSign import Roxas extension MergeError { public enum Code: Int, ALTErrorCode { public typealias Error = MergeError case noVersions case incorrectVersionOrder case incorrectPermissions } static func noVersions(for app: StoreApp) -> MergeError { .init(code: .noVersions, appName: app.name, appBundleID: app.bundleIdentifier, sourceID: app.sourceIdentifier) } static func incorrectVersionOrder(for app: StoreApp) -> MergeError { .init(code: .incorrectVersionOrder, appName: app.name, appBundleID: app.bundleIdentifier, sourceID: app.sourceIdentifier) } static func incorrectPermissions(for app: StoreApp) -> MergeError { .init(code: .incorrectPermissions, appName: app.name, appBundleID: app.bundleIdentifier, sourceID: app.sourceIdentifier) } } public struct MergeError: ALTLocalizedError { public static var errorDomain: String { "AltStore.MergeError" } public let code: Code public var errorTitle: String? public var errorFailure: String? public var appName: String? public var appBundleID: String? public var sourceID: String? public var errorFailureReason: String { switch self.code { case .noVersions: var appName = NSLocalizedString("At least one app", comment: "") if let name = self.appName, let bundleID = self.appBundleID { appName = name + " (\(bundleID))" } return String(format: NSLocalizedString("%@ does not have any app versions.", comment: ""), appName) case .incorrectVersionOrder: var appName = NSLocalizedString("one or more apps", comment: "") if let name = self.appName, let bundleID = self.appBundleID { appName = name + " (\(bundleID))" } return String(format: NSLocalizedString("The cached versions for %@ do not match the source.", comment: ""), appName) case .incorrectPermissions: var appName = NSLocalizedString("one or more apps", comment: "") if let name = self.appName, let bundleID = self.appBundleID { appName = name + " (\(bundleID))" } return String(format: NSLocalizedString("The cached permissions for %@ do not match the source.", comment: ""), appName) } } public var recoverySuggestion: String? { switch self.code { case .incorrectVersionOrder: return NSLocalizedString("Please try again later.", comment: "") default: return nil } } } // Necessary to cast back to MergeError from NSError when thrown from NSMergePolicy. extension MergeError: _ObjectiveCBridgeableError { public var errorUserInfo: [String : Any] { // Copied from ALTLocalizedError var userInfo: [String: Any?] = [ NSLocalizedFailureErrorKey: self.errorFailure, ALTLocalizedTitleErrorKey: self.errorTitle, ALTSourceFileErrorKey: self.sourceFile, ALTSourceLineErrorKey: self.sourceLine, ] userInfo["appName"] = self.appName userInfo["appBundleID"] = self.appBundleID userInfo["sourceID"] = self.sourceID return userInfo.compactMapValues { $0 } } public init?(_bridgedNSError error: NSError) { guard error.domain == MergeError.errorDomain, let code = Code(rawValue: error.code) else { return nil } self.code = code self.errorTitle = error.localizedTitle self.errorFailure = error.localizedFailure self.appName = error.userInfo["appName"] as? String self.appBundleID = error.userInfo["appBundleID"] as? String self.sourceID = error.userInfo["sourceID"] as? String } } private extension Error { func serialized(withFailure failure: String) -> NSError { // We need to serialize Swift errors thrown during merge conflict to preserve error messages. let serializedError = (self as NSError).withLocalizedFailure(failure).sanitizedForSerialization() var userInfo = serializedError.userInfo userInfo[NSLocalizedDescriptionKey] = nil // Remove NSLocalizedDescriptionKey value to prevent duplicating localized failure in localized description. let error = NSError(domain: serializedError.domain, code: serializedError.code, userInfo: userInfo) return error } } open class MergePolicy: RSTRelationshipPreservingMergePolicy { var permissionsByGlobalAppID = [String: Set]() var sortedVersionIDsByGlobalAppID = [String: NSOrderedSet]() var sortedScreenshotIDsByGlobalAppID = [String: NSOrderedSet]() var featuredAppIDsBySourceID = [String: [String]]() // MARK: - Actual Constraint conflict resolution takes place here! open override func resolve(constraintConflicts conflicts: [NSConstraintConflict]) throws { // When conflict.databaseObject is unavailable, it means this is the first time insertion guard conflicts.allSatisfy({ $0.databaseObject != nil }) else { try self.resolveWhenDatabaseObjectUnavailable(conflicts) try super.resolve(constraintConflicts: conflicts) return } permissionsByGlobalAppID.removeAll() sortedVersionIDsByGlobalAppID.removeAll() sortedScreenshotIDsByGlobalAppID.removeAll() featuredAppIDsBySourceID.removeAll() // When conflict.databaseObject is available, it means this is replace (delete + insert) or update try self.resolveWhenDatabaseObjectAvailable(conflicts) try super.resolve(constraintConflicts: conflicts) try self.performPostMergeValidationAndCorrections(for: conflicts) } } extension MergePolicy{ // When conflict.databaseObject is unavailable, the conflicts exist only in context level and they must be new insertions private func resolveWhenDatabaseObjectUnavailable(_ conflicts: [NSConstraintConflict]) throws{ for conflict in conflicts { switch conflict.conflictingObjects.first { case is StoreApp where conflict.conflictingObjects.count == 2: // Modified cached StoreApp while replacing it with new one, causing context-level conflict. // Most likely, we set up a relationship between the new StoreApp and a NewsItem, // causing cached StoreApp to delete it's NewsItem relationship, resulting in (resolvable) conflict. if let previousApp = conflict.conflictingObjects.first(where: { !$0.isInserted }) as? StoreApp { // Delete previous permissions (different than below). for case let permission as AppPermission in previousApp._permissions where permission.app == nil { permission.managedObjectContext?.delete(permission) } // Delete previous versions (different than below). for case let appVersion as AppVersion in previousApp._versions where appVersion.app == nil { 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: // Occurs first time fetching sources after migrating from pre-AppVersion database model. let conflictingAppVersions = conflict.conflictingObjects.lazy.compactMap { $0 as? AppVersion } // Primary AppVersion == AppVersion whose latestVersionApp.latestVersion points back to itself. if let primaryAppVersion = conflictingAppVersions.first(where: { $0.latestSupportedVersionApp?.latestSupportedVersion == $0 }), let secondaryAppVersion = conflictingAppVersions.first(where: { $0 != primaryAppVersion }) { secondaryAppVersion.managedObjectContext?.delete(secondaryAppVersion) print("[ALTLog] Resolving AppVersion context-level conflict. Most likely due to migrating from pre-AppVersion model version.", primaryAppVersion) } default: // break // Unknown context-level conflict. // assertionFailure("MergePolicy is only intended to work with database-level conflicts.") assertionFailure("Context Conflict Detected: is there ambigious data in your incoming sources?\nConflict:\(conflict)") } } } // When conflict.databaseObject is available, it means this is replace (delete + insert) or update private func resolveWhenDatabaseObjectAvailable(_ conflicts: [NSConstraintConflict]) throws { for conflict in conflicts { switch conflict.databaseObject { case let databaseObject as StoreApp: guard let contextApp = conflict.conflictingObjects.first as? StoreApp else { break } // Permissions let contextPermissions = Set(contextApp._permissions.lazy.compactMap { $0 as? AppPermission }.map { AnyHashable($0.permission) }) for case let databasePermission as AppPermission in databaseObject._permissions /* where !contextPermissions.contains(AnyHashable(databasePermission.permission)) */ // Compiler error as of Xcode 15 { if !contextPermissions.contains(AnyHashable(databasePermission.permission)) { // Permission does NOT exist in context, so delete existing databasePermission. databasePermission.managedObjectContext?.delete(databasePermission) } } // Versions let contextVersionIDs = NSOrderedSet(array: contextApp._versions.lazy.compactMap { $0 as? AppVersion }.map { $0.versionID }) for case let databaseVersion as AppVersion in databaseObject._versions where !contextVersionIDs.contains(databaseVersion.versionID) { // Version # does NOT exist in context, so delete existing databaseVersion. databaseVersion.managedObjectContext?.delete(databaseVersion) } // Screenshots let contextScreenshotIDs = NSOrderedSet(array: contextApp._screenshots.lazy.compactMap { $0 as? AppScreenshot }.map { $0.screenshotID }) for case let databaseScreenshot as AppScreenshot in databaseObject._screenshots where !contextScreenshotIDs.contains(databaseScreenshot.screenshotID) { // Screenshot ID does NOT exist in context, so delete existing databaseScreenshot. databaseScreenshot.managedObjectContext?.delete(databaseScreenshot) } if let globallyUniqueID = contextApp.globallyUniqueID { permissionsByGlobalAppID[globallyUniqueID] = contextPermissions sortedVersionIDsByGlobalAppID[globallyUniqueID] = contextVersionIDs sortedScreenshotIDsByGlobalAppID[globallyUniqueID] = contextScreenshotIDs } // Revert contextApp.featuredSortID to database value (if it exists). if let featuredSortID = databaseObject.featuredSortID { contextApp.featuredSortID = featuredSortID } case let databaseObject as Source: guard let conflictedObject = conflict.conflictingObjects.first as? Source else { break } let bundleIdentifiers = Set(conflictedObject.apps.map { $0.bundleIdentifier }) let newsItemIdentifiers = Set(conflictedObject.newsItems.map { $0.identifier }) for app in databaseObject.apps { if !bundleIdentifiers.contains(app.bundleIdentifier) { // No longer listed in Source, so remove it from database. app.managedObjectContext?.delete(app) } } for newsItem in databaseObject.newsItems { if !newsItemIdentifiers.contains(newsItem.identifier) { // No longer listed in Source, so remove it from database. newsItem.managedObjectContext?.delete(newsItem) } } if let contextSource = conflict.conflictingObjects.first as? Source { featuredAppIDsBySourceID[databaseObject.identifier] = contextSource.featuredApps?.map { $0.bundleIdentifier } } // Revert conflictedObject.featuredSortID to database value (if it exists). if let featuredSortID = databaseObject.featuredSortID { conflictedObject.featuredSortID = featuredSortID } case let databasePledge as Pledge: guard let contextPledge = conflict.conflictingObjects.first as? Pledge else { break } // Tiers let contextTierIDs = Set(contextPledge._tiers.lazy.compactMap { $0 as? PledgeTier }.map { $0.identifier }) for case let databaseTier as PledgeTier in databasePledge._tiers where !contextTierIDs.contains(databaseTier.identifier) { // Tier ID does NOT exist in context, so delete existing databaseTier. databaseTier.managedObjectContext?.delete(databaseTier) } // Rewards let contextRewardIDs = Set(contextPledge._rewards.lazy.compactMap { $0 as? PledgeReward }.map { $0.identifier }) for case let databaseReward as PledgeReward in databasePledge._rewards where !contextRewardIDs.contains(databaseReward.identifier) { // Reward ID does NOT exist in context, so delete existing databaseReward. databaseReward.managedObjectContext?.delete(databaseReward) } case let databaseAccount as PatreonAccount: guard let contextAccount = conflict.conflictingObjects.first as? PatreonAccount else { break } let contextPledgeIDs = Set(contextAccount._pledges.lazy.compactMap { $0 as? Pledge }.map { $0.identifier }) for case let databasePledge as Pledge in databaseAccount._pledges where !contextPledgeIDs.contains(databasePledge.identifier) { // Pledge ID does NOT exist in context, so delete existing databasePledge. databasePledge.managedObjectContext?.delete(databasePledge) } default: break } } } } extension MergePolicy{ func performPostMergeValidationAndCorrections(for conflicts: [NSConstraintConflict]) throws{ for conflict in conflicts { switch conflict.databaseObject { case let databaseObject as StoreApp: do { var appVersions = databaseObject.versions if let globallyUniqueID = databaseObject.globallyUniqueID { // Permissions if let appPermissions = permissionsByGlobalAppID[globallyUniqueID], case let databasePermissions = Set(databaseObject.permissions.map({ AnyHashable($0.permission) })), databasePermissions != appPermissions { // Sorting order doesn't matter, but elements themselves don't match so throw error. throw MergeError.incorrectPermissions(for: databaseObject) } // App versions if let sortedAppVersionIDs = sortedVersionIDsByGlobalAppID[globallyUniqueID], let sortedAppVersionsIDsArray = sortedAppVersionIDs.array as? [String], case let databaseVersionIDs = databaseObject.versions.map({ $0.versionID }), databaseVersionIDs != sortedAppVersionsIDsArray { // databaseObject.versions post-merge doesn't match contextApp.versions pre-merge, so attempt to fix by re-sorting. let fixedAppVersions = databaseObject.versions.sorted { (versionA, versionB) in let indexA = sortedAppVersionIDs.index(of: versionA.versionID) let indexB = sortedAppVersionIDs.index(of: versionB.versionID) return indexA < indexB } let appVersionIDs = fixedAppVersions.map { $0.versionID } guard appVersionIDs == sortedAppVersionsIDsArray else { // fixedAppVersions still doesn't match source's versions, so throw MergeError. throw MergeError.incorrectVersionOrder(for: databaseObject) } appVersions = fixedAppVersions // Always update versions post-merging to make sure latestSupportedVersion is correct. try databaseObject.setVersions(appVersions) } // 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) } } } } catch { let nsError = error.serialized(withFailure: NSLocalizedString("SideStore's database could not be saved.", comment: "")) throw nsError } case let databaseObject as Source: guard let featuredAppIDs = featuredAppIDsBySourceID[databaseObject.identifier] else { databaseObject.setFeaturedApps(nil) break } let featuredApps: [StoreApp]? let databaseFeaturedAppIDs = databaseObject.featuredApps?.map { $0.bundleIdentifier } if databaseFeaturedAppIDs != featuredAppIDs { let fixedFeaturedApps = databaseObject.apps.lazy.filter { featuredAppIDs.contains($0.bundleIdentifier) }.sorted { (appA, appB) in let indexA = featuredAppIDs.firstIndex(of: appA.bundleIdentifier)! let indexB = featuredAppIDs.firstIndex(of: appB.bundleIdentifier)! return indexA < indexB } featuredApps = fixedFeaturedApps } else { featuredApps = databaseObject.featuredApps } // Update featuredApps post-merging to make sure relationships are correct, // even if the ordering is correct. databaseObject.setFeaturedApps(featuredApps) default: break } } } } extension MergePolicy{ class func getHeader(_ obj: AnyObject) -> String { return obj.debugDescription.components(separatedBy: "; data:").first ?? "" } }