diff --git a/Delta.xcodeproj/project.pbxproj b/Delta.xcodeproj/project.pbxproj index c3cf78d..8ea0217 100644 --- a/Delta.xcodeproj/project.pbxproj +++ b/Delta.xcodeproj/project.pbxproj @@ -200,6 +200,11 @@ 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 */; }; + 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 */; }; + D5CDCCF22A859E7500E22131 /* RepairDatabaseViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A137442A7D814000AB1372 /* RepairDatabaseViewController.swift */; }; + D5CDCCF32A859E7500E22131 /* GamePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A137662A7DB37200AB1372 /* GamePickerViewController.swift */; }; D5D78AE529F9BC3700E064F0 /* DSAirPlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D78AE429F9BC3700E064F0 /* DSAirPlay.swift */; }; D5D78AE729F9D40A00E064F0 /* EnvironmentValues+FeatureOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D78AE629F9D40A00E064F0 /* EnvironmentValues+FeatureOption.swift */; }; D5D797E6298D946200738869 /* Contributor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D797E5298D946200738869 /* Contributor.swift */; }; @@ -457,6 +462,9 @@ D58F39C529E0A473008B4100 /* Option.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Option.swift; sourceTree = ""; }; D58F39C829E0A702008B4100 /* UserDefaults+OptionValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+OptionValues.swift"; sourceTree = ""; }; D592D6FE29E48FFB008D218A /* OptionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionPickerView.swift; sourceTree = ""; }; + D5A137442A7D814000AB1372 /* RepairDatabaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepairDatabaseViewController.swift; sourceTree = ""; }; + D5A1375A2A7D8F2600AB1372 /* ReviewSaveStatesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewSaveStatesViewController.swift; sourceTree = ""; }; + D5A137662A7DB37200AB1372 /* GamePickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePickerViewController.swift; sourceTree = ""; }; D5A817AF29DF4E6E00904AFE /* CustomTintColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTintColor.swift; sourceTree = ""; }; D5A817B229DF6C6C00904AFE /* ExperimentalFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalFeatures.swift; sourceTree = ""; }; D5A98CE1284EF14B00E023E5 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -466,6 +474,8 @@ D5A9C01C29DE058C00A8D610 /* VariableFastForward.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariableFastForward.swift; sourceTree = ""; }; D5AAF27629884F8600F21ACF /* CheatDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatDevice.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -545,6 +555,8 @@ D5011C47281B6E8B00A0760B /* CharacterSet+Filename.swift */, ACF7E30E29F743A3000FE071 /* PHPhotoLibrary+Authorization.swift */, AC1C992629F9F1CF0020E6E4 /* GameViewController+ExperimentalToasts.swift */, + D5CDCCC32A85765900E22131 /* OSLog+Delta.swift */, + D5CDCCEA2A8593FC00E22131 /* UserDefaults+Delta.swift */, ); path = Extensions; sourceTree = ""; @@ -657,6 +669,7 @@ BF5942711E09BC690051894B /* Model */, BF95E2751E49763D0030E7AD /* OpenVGDB */, D586496E297734060081477E /* Cheats */, + D5CDCCE92A858DB900E22131 /* Repair */, ); path = Database; sourceTree = ""; @@ -1114,6 +1127,16 @@ path = "Experimental Features"; sourceTree = ""; }; + D5CDCCE92A858DB900E22131 /* Repair */ = { + isa = PBXGroup; + children = ( + D5A137442A7D814000AB1372 /* RepairDatabaseViewController.swift */, + D5A1375A2A7D8F2600AB1372 /* ReviewSaveStatesViewController.swift */, + D5A137662A7DB37200AB1372 /* GamePickerViewController.swift */, + ); + path = Repair; + sourceTree = ""; + }; D5D78AE329F9BC0200E064F0 /* Features */ = { isa = PBXGroup; children = ( @@ -1511,6 +1534,7 @@ BFF6452E1F7CC5060056533E /* GameControllerInputMappingTransformer.swift in Sources */, BF59427C1E09BC830051894B /* Cheat.swift in Sources */, BFBAB2E31EB685A2004E0B0E /* DeltaCoreProtocol+Delta.swift in Sources */, + D5CDCCF22A859E7500E22131 /* RepairDatabaseViewController.swift in Sources */, BF3540081C5DAFAD00C1184C /* PauseTransitionCoordinator.swift in Sources */, BF525EEA1FF6CD12004AA849 /* DeepLink.swift in Sources */, AC1C991029F8B8C30020E6E4 /* ToastNotificationOptions.swift in Sources */, @@ -1537,6 +1561,7 @@ BF31878B1D489AAA00BD020D /* CheatValidator.swift in Sources */, D560BD8629EDC45600289847 /* ExternalDisplaySceneDelegate.swift in Sources */, BFFC46201D59823500AF2CC6 /* InitialGamesStoryboardSegue.swift in Sources */, + D5CDCCF02A859E5500E22131 /* UserDefaults+Delta.swift in Sources */, BF15AF841F54B43B009B6AAB /* ActionInput.swift in Sources */, D5D78AE529F9BC3700E064F0 /* DSAirPlay.swift in Sources */, BF4828841F9027B600028B97 /* Delta.xcdatamodeld in Sources */, @@ -1576,12 +1601,14 @@ BF34FA071CF0F510006624C7 /* EditCheatViewController.swift in Sources */, BF1F45A421AF274D00EF9895 /* SyncResultViewController.swift in Sources */, BF3D6C53220286750083E05A /* Delta3ToDelta4.xcmappingmodel in Sources */, + D5CDCCF12A859E7500E22131 /* ReviewSaveStatesViewController.swift in Sources */, BF5942881E09BC8B0051894B /* _Game.swift in Sources */, BF56450D220239B800A8EA26 /* GameControllerInputMappingMigrationPolicy.swift in Sources */, BF95E2771E4977BF0030E7AD /* GameMetadata.swift in Sources */, BFFC461F1D59823500AF2CC6 /* GamesStoryboardSegue.swift in Sources */, BF2B98E61C97E32F00F6D57D /* SaveStatesCollectionHeaderView.swift in Sources */, BFC9B7391CEFCD34008629BB /* CheatsViewController.swift in Sources */, + D5CDCCF32A859E7500E22131 /* GamePickerViewController.swift in Sources */, D5864978297756CE0081477E /* CheatBaseView.swift in Sources */, BF353FFF1C5DA3C500C1184C /* PausePresentationController.swift in Sources */, BFFC464C1D5998D600AF2CC6 /* CheatTableViewCell.swift in Sources */, @@ -1612,6 +1639,7 @@ BF107EC41BF413F000E0C32C /* GamesViewController.swift in Sources */, BF3D6C512202865F0083E05A /* Delta2ToDelta3.xcmappingmodel in Sources */, BF59426F1E09BC5D0051894B /* DatabaseManager.swift in Sources */, + D5CDCCEF2A859E5300E22131 /* OSLog+Delta.swift in Sources */, BF4828861F9028F500028B97 /* System.swift in Sources */, BF13A7561D5D29B0000BB055 /* PreviewGameViewController.swift in Sources */, BF6866171DCAC8B900BF2D06 /* ControllerSkin+Configuring.swift in Sources */, diff --git a/Delta/Database/Repair/GamePickerViewController.swift b/Delta/Database/Repair/GamePickerViewController.swift new file mode 100644 index 0000000..95b1f10 --- /dev/null +++ b/Delta/Database/Repair/GamePickerViewController.swift @@ -0,0 +1,126 @@ +// +// GamePickerViewController.swift +// Delta +// +// Created by Riley Testut on 8/4/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit + +import Roxas + +class GamePickerViewController: UITableViewController +{ + private lazy var dataSource = self.makeDataSource() + + var gameHandler: ((Game?) -> Void)? + + init() + { + super.init(style: .insetGrouped) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() + { + super.viewDidLoad() + + self.navigationController?.delegate = self + + self.dataSource.proxy = self + self.tableView.dataSource = self.dataSource + self.tableView.prefetchDataSource = self.dataSource + + self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: RSTCellContentGenericCellIdentifier) + + self.navigationItem.title = NSLocalizedString("Choose Game", comment: "") + self.navigationItem.searchController = self.dataSource.searchController + self.navigationItem.hidesSearchBarWhenScrolling = false + } +} + +private extension GamePickerViewController +{ + func makeDataSource() -> RSTFetchedResultsTableViewPrefetchingDataSource + { + let fetchRequest = Game.fetchRequest() + fetchRequest.propertiesToFetch = [#keyPath(Game.name), #keyPath(Game.identifier), #keyPath(Game.artworkURL)] + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \Game.gameCollection?.index, ascending: true), NSSortDescriptor(keyPath: \Game.name, ascending: true)] + + let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(Game.gameCollection.name), cacheName: nil) + let dataSource = RSTFetchedResultsTableViewPrefetchingDataSource(fetchedResultsController: fetchedResultsController) + dataSource.cellConfigurationHandler = { (cell, game, indexPath) in + var configuration = UIListContentConfiguration.valueCell() + configuration.prefersSideBySideTextAndSecondaryText = false + + configuration.text = game.name + + configuration.secondaryText = game.identifier + configuration.secondaryTextProperties.font = .preferredFont(forTextStyle: .caption1) + + configuration.image = UIImage(resource: .boxArt) + configuration.imageProperties.maximumSize = CGSize(width: 48, height: 48) + configuration.imageProperties.reservedLayoutSize = CGSize(width: 48, height: 48) + configuration.imageProperties.cornerRadius = 4 + + cell.contentConfiguration = configuration + } + dataSource.prefetchHandler = { (game, indexPath, completionHandler) in + guard let artworkURL = game.artworkURL else { + completionHandler(nil, nil) + return nil + } + + let imageOperation = LoadImageURLOperation(url: artworkURL) + imageOperation.resultHandler = { (image, error) in + completionHandler(image, error) + } + + return imageOperation + } + dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in + guard let image = image, var config = cell.contentConfiguration as? UIListContentConfiguration else { return } + config.image = image + cell.contentConfiguration = config + } + + dataSource.searchController.searchableKeyPaths = [#keyPath(Game.name), #keyPath(Game.identifier)] + + return dataSource + } +} + +extension GamePickerViewController +{ + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) + { + let game = self.dataSource.item(at: indexPath) + self.gameHandler?(game) + + self.navigationController?.delegate = nil // Prevent calling navigationController(_:willShow:) + self.navigationController?.popViewController(animated: true) + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? + { + guard let section = self.dataSource.fetchedResultsController.sections?[section], !section.name.isEmpty else { + return NSLocalizedString("Unknown System", comment: "") + } + + return section.name + } +} + +extension GamePickerViewController: UINavigationControllerDelegate +{ + func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) + { + guard viewController != self else { return } + + self.gameHandler?(nil) + } +} diff --git a/Delta/Database/Repair/RepairDatabaseViewController.swift b/Delta/Database/Repair/RepairDatabaseViewController.swift new file mode 100644 index 0000000..5587c0e --- /dev/null +++ b/Delta/Database/Repair/RepairDatabaseViewController.swift @@ -0,0 +1,473 @@ +// +// RepairDatabaseViewController.swift +// Delta +// +// Created by Riley Testut on 8/4/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit +import OSLog + +import DeltaCore + +import Roxas +import Harmony + +private extension String +{ + func sanitizedFilePath() -> String + { + let sanitizedFilePath = self.components(separatedBy: .urlFilenameAllowed.inverted).joined() + return sanitizedFilePath + } +} + +class RepairDatabaseViewController: UIViewController +{ + var completionHandler: (() -> Void)? + + private var _viewDidAppear = false + + private lazy var managedObjectContext = DatabaseManager.shared.newBackgroundSavingViewContext() + private lazy var gameSavesContext = DatabaseManager.shared.newBackgroundContext(withParent: self.managedObjectContext) + + private var gamesByID: [String: Game]? + + private lazy var backupsDirectory = FileManager.default.documentsDirectory.appendingPathComponent("Backups") + private lazy var gameSavesDirectory = DatabaseManager.gamesDirectoryURL + + override func viewDidLoad() + { + super.viewDidLoad() + + self.view.backgroundColor = .systemBackground + + self.isModalInPresentation = true + + let placeholderView = RSTPlaceholderView() + placeholderView.textLabel.text = NSLocalizedString("Verifying Database…", comment: "") + placeholderView.detailTextLabel.text = nil + placeholderView.activityIndicatorView.startAnimating() + placeholderView.stackView.spacing = 15 + self.view.addSubview(placeholderView, pinningEdgesWith: .zero) + } + + override func viewDidAppear(_ animated: Bool) + { + super.viewDidAppear(animated) + + if !_viewDidAppear + { + self.repairDatabase() + } + + _viewDidAppear = true + } +} + +private extension RepairDatabaseViewController +{ + func repairDatabase() + { + Logger.database.info("Begin repairing database...") + + self.repairGames { result in + switch result + { + case .failure(let error): + DispatchQueue.main.async { + let alertController = UIAlertController(title: "Unable to Repair Games", error: error) + self.present(alertController, animated: true) + } + + case .success: + self.repairGameSaves { result in + DispatchQueue.main.async { + switch result + { + case .failure(let error): + let alertController = UIAlertController(title: "Unable to Repair Save Files", error: error) + self.present(alertController, animated: true) + + case .success: + self.showReviewViewController() + } + } + } + } + } + } + + func repairGames(completion: @escaping (Result) -> Void) + { + self.managedObjectContext.perform { + do + { + let fetchRequest = Game.fetchRequest() + fetchRequest.propertiesToFetch = [#keyPath(Game.type)] + fetchRequest.relationshipKeyPathsForPrefetching = [#keyPath(Game.gameCollection)] + + let allGames = try self.managedObjectContext.fetch(fetchRequest) + let affectedGames = allGames.filter { $0.type.rawValue != $0.gameCollection?.identifier } + + let gameCollections = try self.managedObjectContext.fetch(GameCollection.fetchRequest()) + let gameCollectionsByID = gameCollections.reduce(into: [:]) { $0[$1.identifier] = $1 } + + for game in affectedGames + { + let gameCollection = gameCollectionsByID[game.type.rawValue] + game.gameCollection = gameCollection + + Logger.database.debug("Re-associating “\(game.name, privacy: .public)” with GameCollection: \(gameCollection?.identifier ?? "nil", privacy: .public)") + } + + try self.managedObjectContext.save() + + completion(.success) + } + catch + { + completion(.failure(error)) + } + } + } + + func repairGameSaves(completion: @escaping (Result) -> Void) + { + self.managedObjectContext.perform { + do + { + // Fetch GameSaves that don't have same identifier as their Game, + // OR GameSaves that have a non-nil SHA1 hash. + // + // This covers GameSaves connected to wrong games and GameSaves with nil Games, + // as well as any GameSaves modified since last beta (which we assume are corrupted). + + let fetchRequest = GameSave.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "(%K == nil) OR (%K != %K) OR (%K != nil)", + #keyPath(GameSave.game), + #keyPath(GameSave.identifier), #keyPath(GameSave.game.identifier), + #keyPath(GameSave.sha1)) + + let gameSaves = try self.managedObjectContext.fetch(fetchRequest) + let gameSavesByID = gameSaves.reduce(into: [:]) { $0[$1.identifier] = $1 } + + let gamesFetchRequest = Game.fetchRequest() + gamesFetchRequest.predicate = NSPredicate(format: "%K IN %@", #keyPath(Game.identifier), Set(gameSavesByID.keys)) + + let games = try self.managedObjectContext.fetch(gamesFetchRequest) + self.gamesByID = games.reduce(into: [:]) { $0[$1.identifier] = $1 } + + let savesBackupsDirectory = self.backupsDirectory.appendingPathComponent("Saves") + try FileManager.default.createDirectory(at: savesBackupsDirectory, withIntermediateDirectories: true) + + for gameSave in gameSaves + { + self.repair(gameSave, backupsDirectory: savesBackupsDirectory) + } + + if let coordinator = SyncManager.shared.coordinator + { + let records = try coordinator.recordController.fetchRecords(for: gameSaves) + + if let context = records.first?.recordedObject?.managedObjectContext + { + try context.performAndWait { + for record in records + { + record.perform { managedRecord in + // Mark ALL affected GameSaves as conflicted. + Logger.database.debug("Marking record \(managedRecord.recordID, privacy: .public) as conflicted.") + managedRecord.isConflicted = true + } + } + + try context.save() + } + } + } + + try self.gameSavesContext.performAndWait { + try self.gameSavesContext.save() + } + + try self.managedObjectContext.save() + + completion(.success) + } + catch + { + completion(.failure(error)) + } + } + } + + func repair(_ gameSave: GameSave, backupsDirectory: URL) + { + Logger.database.debug("Repairing GameSave \(gameSave.identifier, privacy: .public)...") + + guard let expectedGame = self.gamesByID?[gameSave.identifier] else { + // Game doesn't exist, so we'll back up save file and delete record. + + Logger.database.warning("Orphaning GameSave \(gameSave.identifier, privacy: .public) due to no matching game.") + + do + { + try self.backup(gameSave, for: nil, to: backupsDirectory) + } + catch + { + Logger.database.error("Failed to back up save file for orphaned GameSave \(gameSave.identifier, privacy: .public). \(error, privacy: .public)") + } + + self.gameSavesContext.performAndWait { + let gameSave = self.gameSavesContext.object(with: gameSave.objectID) as! GameSave + gameSave.game = nil + } + + return + } + + let misplacedGameSave: GameSave? + if let otherGameSave = expectedGame.gameSave, otherGameSave != gameSave + { + misplacedGameSave = otherGameSave + + Logger.database.info("GameSave \(gameSave.identifier, privacy: .public) will misplace \(otherGameSave.identifier, privacy: .public)") + } + else + { + misplacedGameSave = nil + } + + do + { + // Back up the save file gameSave (incorrectly) refers to, but name it after the _expected_ game. + try self.backup(gameSave, for: expectedGame, to: backupsDirectory) + } + catch + { + Logger.database.error("Failed to back up save file for GameSave \(gameSave.identifier, privacy: .public). Expected Game: \(expectedGame.identifier). \(error, privacy: .public)") + } + + // Ignore error if we can't hash file, not that big a deal. + let hash = try? RSTHasher.sha1HashOfFile(at: expectedGame.gameSaveURL) + + // Make changes on separate context so we don't change any relationships until we're finished. + // This allows us to refer to previous relationships. + self.gameSavesContext.performAndWait { + let gameSave = self.gameSavesContext.object(with: gameSave.objectID) as! GameSave + let expectedGame = self.gameSavesContext.object(with: expectedGame.objectID) as! Game + let misplacedGameSave: GameSave? = misplacedGameSave.map { self.gameSavesContext.object(with: $0.objectID) as! GameSave } + + if hash == gameSave.sha1 + { + // .sav has same hash as GameSave SHA1, + // so we can relink without changes. + + Logger.database.info("GameSave \(gameSave.identifier, privacy: .public)'s hash matches .sav, relinking without changes.") + } + else if let misplacedGameSave + { + // GameSave data differs from actual .sav file, + // so copy metadata from misplacedGameSave. + + Logger.database.info("GameSave \(gameSave.identifier, privacy: .public)'s hash does NOT match .sav, updating GameSave to match misplaced save \(misplacedGameSave.identifier, privacy: .public).") + + gameSave.sha1 = misplacedGameSave.sha1 + gameSave.modifiedDate = misplacedGameSave.modifiedDate + } + else + { + // GameSave data differs from actual .sav file, + // so copy metadata from disk. + Logger.database.info("GameSave \(gameSave.identifier, privacy: .public)'s hash does NOT match .sav, updating GameSave from disk.") + + let modifiedDate = try? FileManager.default.attributesOfItem(atPath: expectedGame.gameSaveURL.path)[.modificationDate] as? Date + + gameSave.sha1 = hash + gameSave.modifiedDate = modifiedDate ?? Date() + } + + gameSave.game = expectedGame + } + } + + func backup(_ gameSave: GameSave, for expectedGame: Game?, to backupsDirectory: URL) throws + { + Logger.database.debug("Backing up GameSave \(gameSave.identifier, privacy: .public). Expected Game: \(expectedGame?.name ?? "nil", privacy: .public)") + + if let game = gameSave.game + { + // GameSave is linked with incorrect game. + + // Prefer using expectedGame's saveFileExtension over game's. + let saveFileExtension: String + if let deltaCore = Delta.core(for: expectedGame?.type ?? game.type) + { + saveFileExtension = deltaCore.gameSaveFileExtension + } + else + { + saveFileExtension = "sav" + } + + // 1. Backup existing file at `game`'s expected save file location + if FileManager.default.fileExists(atPath: game.gameSaveURL.path) + { + // Filename = expectedGame.name? + game.identifier + + let filename: String + if let expectedGame + { + filename = expectedGame.name + "_" + game.identifier + } + else + { + filename = game.identifier + } + + let sanitizedFilename = filename.sanitizedFilePath() + + let destinationURL = backupsDirectory.appendingPathComponent(sanitizedFilename).appendingPathExtension(saveFileExtension) + try FileManager.default.copyItem(at: game.gameSaveURL, to: destinationURL, shouldReplace: true) + + Logger.database.debug("Backed up save file \(game.gameSaveURL.lastPathComponent, privacy: .public) to \(destinationURL.lastPathComponent, privacy: .public)") + + let rtcFileURL = game.gameSaveURL.deletingPathExtension().appendingPathExtension("rtc") + if FileManager.default.fileExists(atPath: rtcFileURL.path) + { + let destinationURL = backupsDirectory.appendingPathComponent(sanitizedFilename).appendingPathExtension("rtc") + try FileManager.default.copyItem(at: rtcFileURL, to: destinationURL, shouldReplace: true) + + Logger.database.debug("Backed up RTC save file \(rtcFileURL.lastPathComponent, privacy: .public) to \(destinationURL.lastPathComponent, privacy: .public)") + } + } + + // 2. Backup existing file at `expectedGame`'s save file location + if let expectedGame, FileManager.default.fileExists(atPath: expectedGame.gameSaveURL.path) + { + // Filename = expectedGame.name + (misplacedGameSave.identifier ?? expectedGame.identifier) + + let filename = expectedGame.name + "_" + (expectedGame.gameSave?.identifier ?? expectedGame.identifier) + let sanitizedFilename = filename.sanitizedFilePath() + + let destinationURL = backupsDirectory.appendingPathComponent(sanitizedFilename).appendingPathExtension(saveFileExtension) + try FileManager.default.copyItem(at: expectedGame.gameSaveURL, to: destinationURL, shouldReplace: true) + + Logger.database.debug("Backed up expected save file \(expectedGame.gameSaveURL.lastPathComponent, privacy: .public) to \(destinationURL.lastPathComponent, privacy: .public)") + + let rtcFileURL = expectedGame.gameSaveURL.deletingPathExtension().appendingPathExtension("rtc") + if FileManager.default.fileExists(atPath: rtcFileURL.path) + { + let destinationURL = backupsDirectory.appendingPathComponent(sanitizedFilename).appendingPathExtension("rtc") + try FileManager.default.copyItem(at: rtcFileURL, to: destinationURL, shouldReplace: true) + + Logger.database.debug("Backed up expected RTC save file \(rtcFileURL.lastPathComponent, privacy: .public) to \(destinationURL.lastPathComponent, privacy: .public)") + } + } + } + else + { + @discardableResult + func backUp(_ saveFileURL: URL) throws -> Bool + { + guard FileManager.default.fileExists(atPath: saveFileURL.path) else { return false } + + // Filename = expectedGame.name? + gameSave.identifier + + let filename: String + if let expectedGame + { + filename = expectedGame.name + "_" + gameSave.identifier + } + else + { + filename = gameSave.identifier + } + + let sanitizedFilename = filename.sanitizedFilePath() + + let destinationURL = backupsDirectory.appendingPathComponent(sanitizedFilename).appendingPathExtension(saveFileURL.pathExtension) + try FileManager.default.copyItem(at: saveFileURL, to: destinationURL, shouldReplace: true) + + Logger.database.debug("Backed up discovered save file \(saveFileURL.lastPathComponent, privacy: .public) to \(destinationURL.lastPathComponent, privacy: .public)") + + return true + } + + // GameSave is _not_ linked to a Game, so instead we iterate through all save files on disk to find match. + let savURL = self.gameSavesDirectory.appendingPathComponent(gameSave.identifier).appendingPathExtension("sav") + let srmURL = self.gameSavesDirectory.appendingPathComponent(gameSave.identifier).appendingPathExtension("srm") + let dsvURL = self.gameSavesDirectory.appendingPathComponent(gameSave.identifier).appendingPathExtension("dsv") + + let saveFileURLs = [savURL, srmURL, dsvURL] + for saveFileURL in saveFileURLs + { + if try backUp(saveFileURL) + { + break + } + } + + // ALWAYS attempt to back up RTC file. + let rtcURL = self.gameSavesDirectory.appendingPathComponent(gameSave.identifier).appendingPathExtension("rtc") + try backUp(rtcURL) + } + } + + func showReviewViewController() + { + Logger.database.info("Finished repairing Games and GameSaves, reviewing recent SaveStates...") + + let viewController = ReviewSaveStatesViewController() + viewController.completionHandler = { [weak self] in + self?.finish() + } + self.navigationController?.pushViewController(viewController, animated: true) + } + + func finish() + { + Logger.database.info("Finished repairing database!") + + DispatchQueue.global(qos: .userInitiated).async { + if #available(iOS 15, *) + { + do + { + let store = try OSLogStore(scope: .currentProcessIdentifier) + + // All logs since the app launched. + let position = store.position(timeIntervalSinceLatestBoot: 0) + + let entries = try store.getEntries(at: position) + .compactMap { $0 as? OSLogEntryLog } + .filter { $0.subsystem == Logger.deltaSubsystem || $0.subsystem == Logger.harmonySubsystem } + .map { "[\($0.date.formatted())] [\($0.level.localizedName)] \($0.composedMessage)" } + + let outputURL = self.backupsDirectory.appendingPathComponent("repair.log") + try FileManager.default.createDirectory(at: self.backupsDirectory, withIntermediateDirectories: true) + + let outputText = entries.joined(separator: "\n") + try outputText.write(to: outputURL, atomically: true, encoding: .utf8) + } + catch + { + print("Failed to export Harmony logs.", error) + } + } + + DispatchQueue.main.async { + let alertController = UIAlertController(title: NSLocalizedString("Successfully Repaired Database", comment: ""), + message: NSLocalizedString("Some save files might be conflicted and require your attentio before syncing.\n\nAs a precaution, Delta has backed up all conflicted save files to Delta/Backups/Saves in the Files app.", comment: ""), + preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: UIAlertAction.ok.title, style: UIAlertAction.ok.style) { _ in + self.completionHandler?() + }) + self.present(alertController, animated: true) + } + } + } +} diff --git a/Delta/Database/Repair/ReviewSaveStatesViewController.swift b/Delta/Database/Repair/ReviewSaveStatesViewController.swift new file mode 100644 index 0000000..d4b436c --- /dev/null +++ b/Delta/Database/Repair/ReviewSaveStatesViewController.swift @@ -0,0 +1,235 @@ +// +// ReviewSaveStatesViewController.swift +// Delta +// +// Created by Riley Testut on 8/4/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit +import OSLog + +import Roxas + +class ReviewSaveStatesViewController: UITableViewController +{ + var completionHandler: (() -> Void)? + + private lazy var managedObjectContext = DatabaseManager.shared.newBackgroundSavingViewContext() + + private lazy var dataSource = self.makeDataSource() + private lazy var descriptionDataSource = self.makeDescriptionDataSource() + private lazy var saveStatesDataSource = self.makeSaveStatesDataSource() + + init() + { + super.init(style: .insetGrouped) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() + { + super.viewDidLoad() + + self.dataSource.proxy = self + self.tableView.dataSource = self.dataSource + self.tableView.prefetchDataSource = self.dataSource + + self.tableView.register(UITableViewCell.self, forCellReuseIdentifier: RSTCellContentGenericCellIdentifier) + + let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(ReviewSaveStatesViewController.finish)) + self.navigationItem.rightBarButtonItem = doneButton + + self.navigationItem.title = NSLocalizedString("Review Save States", comment: "") + + // Disable going back to RepairDatabaseViewController. + self.navigationItem.setHidesBackButton(true, animated: false) + self.navigationController?.interactivePopGestureRecognizer?.isEnabled = false + } + + override func viewWillAppear(_ animated: Bool) + { + super.viewWillAppear(animated) + + // Must set parent's navigationItem.title for when we're contained in SwiftUI View. + self.parent?.navigationItem.title = NSLocalizedString("Review Save States", comment: "") + } +} + +private extension ReviewSaveStatesViewController +{ + func makeDataSource() -> RSTCompositeTableViewPrefetchingDataSource + { + let dataSource = RSTCompositeTableViewPrefetchingDataSource(dataSources: [self.descriptionDataSource, self.saveStatesDataSource]) + return dataSource + } + + func makeDescriptionDataSource() -> RSTDynamicTableViewPrefetchingDataSource + { + let dataSource = RSTDynamicTableViewPrefetchingDataSource() + dataSource.numberOfSectionsHandler = { 1 } + dataSource.numberOfItemsHandler = { _ in 0 } + return dataSource + } + + func makeSaveStatesDataSource() -> RSTFetchedResultsTableViewPrefetchingDataSource + { + let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date().addingTimeInterval(-1 * 60 * 60 * 24 * 30) + + let fetchRequest = SaveState.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "%K > %@", #keyPath(SaveState.modifiedDate), oneMonthAgo as NSDate) + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \SaveState.game?.name, ascending: true), NSSortDescriptor(keyPath: \SaveState.modifiedDate, ascending: false)] + + let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: self.managedObjectContext, sectionNameKeyPath: #keyPath(SaveState.game.name), cacheName: nil) + + let dataSource = RSTFetchedResultsTableViewPrefetchingDataSource(fetchedResultsController: fetchedResultsController) + dataSource.cellConfigurationHandler = { (cell, saveState, indexPath) in + var configuration = UIListContentConfiguration.valueCell() + configuration.prefersSideBySideTextAndSecondaryText = false + + let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body).withSymbolicTraits(.traitBold) ?? .preferredFontDescriptor(withTextStyle: .body) + configuration.text = saveState.name ?? NSLocalizedString("Untitled", comment: "") + configuration.textProperties.font = UIFont(descriptor: fontDescriptor, size: 0) + + configuration.secondaryText = SaveState.localizedDateFormatter.string(from: saveState.modifiedDate) + configuration.secondaryTextProperties.font = .preferredFont(forTextStyle: .caption1) + + configuration.image = nil + configuration.imageProperties.maximumSize = CGSize(width: 80, height: 80) + configuration.imageProperties.reservedLayoutSize = CGSize(width: 80, height: 80) + configuration.imageProperties.cornerRadius = 6 + + cell.contentConfiguration = configuration + + cell.accessoryType = .disclosureIndicator + } + dataSource.prefetchHandler = { (saveState, indexPath, completionHandler) in + guard saveState.game != nil else { + completionHandler(nil, nil) + return nil + } + + let imageOperation = LoadImageURLOperation(url: saveState.imageFileURL) + imageOperation.resultHandler = { (image, error) in + completionHandler(image, error) + } + + if self.isAppearing + { + imageOperation.start() + imageOperation.waitUntilFinished() + return nil + } + + return imageOperation + } + dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in + guard let image = image, var config = cell.contentConfiguration as? UIListContentConfiguration else { return } + config.image = image + cell.contentConfiguration = config + } + + return dataSource + } +} + +private extension ReviewSaveStatesViewController +{ + func pickGame(for saveState: SaveState) + { + let gamePickerViewController = GamePickerViewController() + gamePickerViewController.gameHandler = { game in + guard let game else { return } + + let previousGame = saveState.game + if previousGame != nil + { + // Move files to new location. + + let destinationDirectory = DatabaseManager.saveStatesDirectoryURL(for: game) + + for fileURL in [saveState.fileURL, saveState.imageFileURL] + { + guard FileManager.default.fileExists(atPath: fileURL.path) else { continue } + + let destinationURL = destinationDirectory.appendingPathComponent(fileURL.lastPathComponent) + + do + { + try FileManager.default.copyItem(at: fileURL, to: destinationURL, shouldReplace: true) // Copy, don't move, in case app quits before user confirms. + } + catch + { + Logger.database.error("Failed to copy SaveState “\(saveState.localizedName, privacy: .public)” from \(fileURL, privacy: .public) to \(destinationURL, privacy: .public). \(error.localizedDescription, privacy: .public)") + } + } + } + + let tempGame = self.managedObjectContext.object(with: game.objectID) as! Game + saveState.game = tempGame + + Logger.database.debug("Re-associated SaveState “\(saveState.localizedName, privacy: .public)” with game “\(tempGame.name, privacy: .public)”. Previously: \(previousGame?.name ?? "nil", privacy: .public)") + } + + self.navigationController?.pushViewController(gamePickerViewController, animated: true) + } + + @objc func finish() + { + self.navigationItem.rightBarButtonItem?.isIndicatingActivity = true + + self.managedObjectContext.perform { + do + { + try self.managedObjectContext.save() + + DispatchQueue.main.async { + self.completionHandler?() + } + } + catch + { + DispatchQueue.main.async { + self.navigationItem.rightBarButtonItem?.isIndicatingActivity = false + + let alertController = UIAlertController(title: NSLocalizedString("Unable to Save Changes", comment: ""), error: error) + self.present(alertController, animated: true) + } + } + } + } +} + +extension ReviewSaveStatesViewController +{ + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) + { + let saveState = self.dataSource.item(at: indexPath) + self.pickGame(for: saveState) + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? + { + if section == 0 + { + return nil + } + else + { + let section = section - 1 + + guard let gameName = self.saveStatesDataSource.fetchedResultsController.sections?[section].name else { return NSLocalizedString("Unknown Game", comment: "") } + return gameName + } + } + + override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? + { + guard section == 0 else { return nil } + + return NSLocalizedString("These save states have been modified recently and may be associated with the wrong game.\n\nPlease change any incorrectly associated save states to the correct game by tapping them.", comment: "") + } +} diff --git a/Delta/Extensions/NSManagedObjectContext+Conveniences.swift b/Delta/Extensions/NSManagedObjectContext+Conveniences.swift index 7f0f63a..bed6eb5 100644 --- a/Delta/Extensions/NSManagedObjectContext+Conveniences.swift +++ b/Delta/Extensions/NSManagedObjectContext+Conveniences.swift @@ -23,4 +23,29 @@ extension NSManagedObjectContext print("Error saving NSManagedObjectContext: ", error, error.userInfo) } } + + // MARK: - Perform - + + func performAndWait(_ block: @escaping () -> T) -> T + { + var result: T! = nil + + self.performAndWait { + result = block() + } + + return result + } + + func performAndWait(_ block: @escaping () throws -> T) throws -> T + { + var result: Result! = nil + + self.performAndWait { + result = Result { try block() } + } + + let value = try result.get() + return value + } } diff --git a/Delta/Extensions/OSLog+Delta.swift b/Delta/Extensions/OSLog+Delta.swift new file mode 100644 index 0000000..af16879 --- /dev/null +++ b/Delta/Extensions/OSLog+Delta.swift @@ -0,0 +1,38 @@ +// +// OSLog+Delta.swift +// Delta +// +// Created by Riley Testut on 8/10/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import OSLog + +extension OSLog.Category +{ + static let database = "Database" +} + +extension Logger +{ + static let deltaSubsystem = "com.rileytestut.Delta" + + static let database = Logger(subsystem: deltaSubsystem, category: OSLog.Category.database) +} + +@available(iOS 15, *) +extension OSLogEntryLog.Level +{ + var localizedName: String { + switch self + { + case .undefined: NSLocalizedString("Undefined", comment: "") + case .debug: NSLocalizedString("Debug", comment: "") + case .info: NSLocalizedString("Info", comment: "") + case .notice: NSLocalizedString("Notice", comment: "") + case .error: NSLocalizedString("Error", comment: "") + case .fault: NSLocalizedString("Fault", comment: "") + @unknown default: NSLocalizedString("Unknown", comment: "") + } + } +} diff --git a/Delta/Extensions/UserDefaults+Delta.swift b/Delta/Extensions/UserDefaults+Delta.swift new file mode 100644 index 0000000..e59b1c8 --- /dev/null +++ b/Delta/Extensions/UserDefaults+Delta.swift @@ -0,0 +1,14 @@ +// +// UserDefaults+Delta.swift +// Delta +// +// Created by Riley Testut on 8/10/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import Foundation + +extension UserDefaults +{ + @NSManaged var shouldRepairDatabase: Bool +} diff --git a/Delta/Launch/LaunchViewController.swift b/Delta/Launch/LaunchViewController.swift index 7a561cb..026f8c4 100644 --- a/Delta/Launch/LaunchViewController.swift +++ b/Delta/Launch/LaunchViewController.swift @@ -76,7 +76,42 @@ extension LaunchViewController } } - return [isDatabaseManagerStarted, isSyncingManagerStarted] + // Repair database _after_ starting SyncManager so we can access RecordController. + let isDatabaseRepaired = RSTLaunchCondition(condition: { !UserDefaults.standard.shouldRepairDatabase }) { completionHandler in + func finish() + { + UserDefaults.standard.shouldRepairDatabase = false + completionHandler(nil) + } + + do + { + let fetchRequest = Game.fetchRequest() + fetchRequest.fetchLimit = 1 + + let isDatabaseEmpty = try DatabaseManager.shared.viewContext.count(for: fetchRequest) == 0 + guard !isDatabaseEmpty else { + // Database has no games, so no need to repair database. + finish() + return + } + } + catch + { + print("Failed to fetch games at launch, repairing database just to be safe.", error) + } + + let repairViewController = RepairDatabaseViewController() + repairViewController.completionHandler = { [weak repairViewController] in + repairViewController?.dismiss(animated: true) + finish() + } + + let navigationController = UINavigationController(rootViewController: repairViewController) + self.present(navigationController, animated: true) + } + + return [isDatabaseManagerStarted, isSyncingManagerStarted, isDatabaseRepaired] } override func handleLaunchError(_ error: Error) diff --git a/Delta/Settings/Settings.swift b/Delta/Settings/Settings.swift index 24ad1cf..8ef49e1 100644 --- a/Delta/Settings/Settings.swift +++ b/Delta/Settings/Settings.swift @@ -53,7 +53,7 @@ struct Settings static func registerDefaults() { - let defaults = [#keyPath(UserDefaults.translucentControllerSkinOpacity): 0.7, + var defaults = [#keyPath(UserDefaults.translucentControllerSkinOpacity): 0.7, #keyPath(UserDefaults.gameShortcutsMode): GameShortcutsMode.recent.rawValue, #keyPath(UserDefaults.isButtonHapticFeedbackEnabled): true, #keyPath(UserDefaults.isThumbstickHapticFeedbackEnabled): true, @@ -62,15 +62,21 @@ struct Settings #keyPath(UserDefaults.isAltJITEnabled): false, #keyPath(UserDefaults.respectSilentMode): true, Settings.preferredCoreSettingsKey(for: .ds): MelonDS.core.identifier] as [String : Any] - UserDefaults.standard.register(defaults: defaults) - #if !BETA + #if BETA + + // Assume we need to repair database relationships until explicitly set to false. + defaults[#keyPath(UserDefaults.shouldRepairDatabase)] = true + + #else // Manually set MelonDS as preferred DS core in case DeSmuME is cached from a previous version. UserDefaults.standard.set(MelonDS.core.identifier, forKey: Settings.preferredCoreSettingsKey(for: .ds)) // Manually disable AltJIT for public builds. UserDefaults.standard.isAltJITEnabled = false #endif + + UserDefaults.standard.register(defaults: defaults) } } diff --git a/Delta/Syncing/SyncManager.swift b/Delta/Syncing/SyncManager.swift index a9fb397..fc9b615 100644 --- a/Delta/Syncing/SyncManager.swift +++ b/Delta/Syncing/SyncManager.swift @@ -221,6 +221,9 @@ extension SyncManager func sync() { + // Don't sync until we've repaired database. + guard !UserDefaults.standard.shouldRepairDatabase else { return } + let progress = self.coordinator?.sync() self.syncProgress = progress }