diff --git a/Delta.xcodeproj/project.pbxproj b/Delta.xcodeproj/project.pbxproj index 41fa489..6c28a57 100644 --- a/Delta.xcodeproj/project.pbxproj +++ b/Delta.xcodeproj/project.pbxproj @@ -201,6 +201,7 @@ D5A9C01D29DE058C00A8D610 /* VariableFastForward.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A9C01C29DE058C00A8D610 /* VariableFastForward.swift */; }; D5AAF27729884F8600F21ACF /* CheatDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAF27629884F8600F21ACF /* CheatDevice.swift */; }; D5ADD12229F33FBF00CE0560 /* Features.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ADD12129F33FBF00CE0560 /* Features.swift */; }; + D5CDCCEE2A859DC200E22131 /* SyncValidationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CDCCEC2A859B2B00E22131 /* SyncValidationError.swift */; }; D5CDCCEF2A859E5300E22131 /* OSLog+Delta.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CDCCC32A85765900E22131 /* OSLog+Delta.swift */; }; D5CDCCF02A859E5500E22131 /* UserDefaults+Delta.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CDCCEA2A8593FC00E22131 /* UserDefaults+Delta.swift */; }; D5CDCCF12A859E7500E22131 /* ReviewSaveStatesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A1375A2A7D8F2600AB1372 /* ReviewSaveStatesViewController.swift */; }; @@ -478,6 +479,7 @@ D5ADD12129F33FBF00CE0560 /* Features.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Features.swift; sourceTree = ""; }; D5CDCCC32A85765900E22131 /* OSLog+Delta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSLog+Delta.swift"; sourceTree = ""; }; D5CDCCEA2A8593FC00E22131 /* UserDefaults+Delta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Delta.swift"; sourceTree = ""; }; + D5CDCCEC2A859B2B00E22131 /* SyncValidationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncValidationError.swift; sourceTree = ""; }; D5D78AE429F9BC3700E064F0 /* DSAirPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DSAirPlay.swift; sourceTree = ""; }; D5D78AE629F9D40A00E064F0 /* EnvironmentValues+FeatureOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+FeatureOption.swift"; sourceTree = ""; }; D5D797E5298D946200738869 /* Contributor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contributor.swift; sourceTree = ""; }; @@ -888,6 +890,7 @@ isa = PBXGroup; children = ( BFAB9F7C219A43380080EC7D /* SyncManager.swift */, + D5CDCCEC2A859B2B00E22131 /* SyncValidationError.swift */, BF1F45A321AF274D00EF9895 /* SyncResultViewController.swift */, BF1F45AA21AF4B5800EF9895 /* SyncResultsViewController.storyboard */, ); @@ -1660,6 +1663,7 @@ BF8CA9361F5F651900499FDD /* PopoverMenuController.swift in Sources */, BFEF24F31F7DD4FD00454C62 /* SaveStateMigrationPolicy.swift in Sources */, BF5942931E09BD1A0051894B /* NSFetchedResultsController+Conveniences.m in Sources */, + D5CDCCEE2A859DC200E22131 /* SyncValidationError.swift in Sources */, BF4828881F90290F00028B97 /* Action.swift in Sources */, BF6BF3271EB87EB8008E83CD /* PhotoLibraryImportOption.swift in Sources */, BF5942661E09BBB10051894B /* LoadImageURLOperation.swift in Sources */, diff --git a/Delta/Database/Model/Human/Game.swift b/Delta/Database/Model/Human/Game.swift index 42ab887..82b1a88 100644 --- a/Delta/Database/Model/Human/Game.swift +++ b/Delta/Database/Model/Human/Game.swift @@ -199,4 +199,14 @@ extension Game: Syncable public var syncableLocalizedName: String? { return self.name } + + public func awakeFromSync(_ record: AnyRecord) throws + { + guard let gameCollection = self.gameCollection else { throw SyncValidationError.incorrectGameCollection(nil) } + + if gameCollection.identifier != self.type.rawValue + { + throw SyncValidationError.incorrectGameCollection(gameCollection.name) + } + } } diff --git a/Delta/Database/Model/Human/GameSave.swift b/Delta/Database/Model/Human/GameSave.swift index 9e9f41d..3f66e59 100644 --- a/Delta/Database/Model/Human/GameSave.swift +++ b/Delta/Database/Model/Human/GameSave.swift @@ -53,7 +53,9 @@ extension GameSave: Syncable public var syncableMetadata: [HarmonyMetadataKey : String] { guard let game = self.game else { return [:] } - return [.gameID: game.identifier, .gameName: game.name] + + // Use self.identifier to always link with exact matching game. + return [.gameID: self.identifier, .gameName: game.name] } public var syncableLocalizedName: String? { @@ -66,4 +68,30 @@ extension GameSave: Syncable return self.game?.identifier != Game.melonDSBIOSIdentifier && self.game?.identifier != Game.melonDSDSiBIOSIdentifier } + + public func awakeFromSync(_ record: AnyRecord) throws + { + guard let game = self.game else { throw SyncValidationError.incorrectGame(nil) } + + if game.identifier != self.identifier + { + let fetchRequest = GameSave.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(GameSave.identifier), game.identifier) + + if let misplacedGameSave = try self.managedObjectContext?.fetch(fetchRequest).first, misplacedGameSave.game == nil + { + // Relink game with its correct gameSave, in case we accidentally misplaced it. + // Otherwise, corrupted records might displace already-downloaded GameSaves + // due to automatic Core Data relationship propagation, despite us throwing error. + game.gameSave = misplacedGameSave + } + else + { + // Either there is no misplacedGameSave, or there is but it's linked to another game somehow. + game.gameSave = nil + } + + throw SyncValidationError.incorrectGame(game.name) + } + } } diff --git a/Delta/Syncing/SyncResultViewController.swift b/Delta/Syncing/SyncResultViewController.swift index cff2758..e5dc812 100644 --- a/Delta/Syncing/SyncResultViewController.swift +++ b/Delta/Syncing/SyncResultViewController.swift @@ -173,6 +173,15 @@ private extension SyncResultViewController errorMessage = NSLocalizedString("The game for this item is missing. Please re-import the game to resume syncing its data.", comment: "") } + case .other(_, let error as SyncValidationError): + var message = error.failureReason ?? error.localizedDescription + if let recoverySuggestion = error.recoverySuggestion + { + message += "\n\n" + recoverySuggestion + } + + errorMessage = message + case .other(_, let error as NSError): errorMessage = error.localizedFailureReason ?? error.localizedDescription default: errorMessage = error.failureReason } @@ -249,9 +258,7 @@ private extension SyncResultViewController case .gameControllerInputMapping: group = .gameControllerInputMapping case .gameSave: - guard let gameID = metadata?[.gameID] else { continue } - - let recordID = RecordID(type: SyncManager.RecordType.game.rawValue, identifier: gameID) + let recordID = RecordID(type: SyncManager.RecordType.game.rawValue, identifier: error.record.recordID.identifier) group = .game(recordID) case .saveState: @@ -387,7 +394,11 @@ extension SyncResultViewController case .game: guard let error = section.errors.first as? RecordError else { return nil } - return error.record.localizedName + + let recordID = RecordID(type: SyncManager.RecordType.game.rawValue, identifier: error.record.recordID.identifier) + + let gameName = self.gameNamesByRecordID[recordID] // In case the remote record is corrupted, rely on local names. + return gameName ?? error.record.localizedName case .saveState(let gameID): guard let error = section.errors.first as? RecordError else { return nil } diff --git a/Delta/Syncing/SyncValidationError.swift b/Delta/Syncing/SyncValidationError.swift new file mode 100644 index 0000000..e83adf7 --- /dev/null +++ b/Delta/Syncing/SyncValidationError.swift @@ -0,0 +1,40 @@ +// +// SyncValidationError.swift +// Delta +// +// Created by Riley Testut on 8/10/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import Foundation + +enum SyncValidationError: LocalizedError +{ + case incorrectGame(String?) + case incorrectGameCollection(String?) + + var failureReason: String? { + switch self + { + case .incorrectGame(let name?): + return String(format: NSLocalizedString("The downloaded record is associated with the wrong game (%@).", comment: ""), name) + + case .incorrectGame(nil): + return NSLocalizedString("The downloaded record is not associated with a game.", comment: "") + + case .incorrectGameCollection(let name?): + return String(format: NSLocalizedString("The downloaded record is associated with the wrong game system (%@).", comment: ""), name) + + case .incorrectGameCollection(nil): + return NSLocalizedString("The downloaded record is not associated with a game system.", comment: "") + } + } + + var recoverySuggestion: String? { + switch self + { + case .incorrectGame: return NSLocalizedString("Try restoring an older version to resolve this issue.", comment: "") + case .incorrectGameCollection: return NSLocalizedString("Try restoring an older version, or manually re-import the game to resolve this issue.", comment: "") + } + } +}