Repairs corrupted Game, GameSave, and SaveState relationships on initial launch
Automatically fixes Game and GameSaves, but requires user to manually review + fix all recent SaveStates.
This commit is contained in:
parent
a80ac04650
commit
a9f15144ed
@ -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 = "<group>"; };
|
||||
D58F39C829E0A702008B4100 /* UserDefaults+OptionValues.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+OptionValues.swift"; sourceTree = "<group>"; };
|
||||
D592D6FE29E48FFB008D218A /* OptionPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionPickerView.swift; sourceTree = "<group>"; };
|
||||
D5A137442A7D814000AB1372 /* RepairDatabaseViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepairDatabaseViewController.swift; sourceTree = "<group>"; };
|
||||
D5A1375A2A7D8F2600AB1372 /* ReviewSaveStatesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewSaveStatesViewController.swift; sourceTree = "<group>"; };
|
||||
D5A137662A7DB37200AB1372 /* GamePickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamePickerViewController.swift; sourceTree = "<group>"; };
|
||||
D5A817AF29DF4E6E00904AFE /* CustomTintColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTintColor.swift; sourceTree = "<group>"; };
|
||||
D5A817B229DF6C6C00904AFE /* ExperimentalFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExperimentalFeatures.swift; sourceTree = "<group>"; };
|
||||
D5A98CE1284EF14B00E023E5 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||
@ -466,6 +474,8 @@
|
||||
D5A9C01C29DE058C00A8D610 /* VariableFastForward.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariableFastForward.swift; sourceTree = "<group>"; };
|
||||
D5AAF27629884F8600F21ACF /* CheatDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatDevice.swift; sourceTree = "<group>"; };
|
||||
D5ADD12129F33FBF00CE0560 /* Features.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Features.swift; sourceTree = "<group>"; };
|
||||
D5CDCCC32A85765900E22131 /* OSLog+Delta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSLog+Delta.swift"; sourceTree = "<group>"; };
|
||||
D5CDCCEA2A8593FC00E22131 /* UserDefaults+Delta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserDefaults+Delta.swift"; sourceTree = "<group>"; };
|
||||
D5D78AE429F9BC3700E064F0 /* DSAirPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DSAirPlay.swift; sourceTree = "<group>"; };
|
||||
D5D78AE629F9D40A00E064F0 /* EnvironmentValues+FeatureOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+FeatureOption.swift"; sourceTree = "<group>"; };
|
||||
D5D797E5298D946200738869 /* Contributor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contributor.swift; sourceTree = "<group>"; };
|
||||
@ -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 = "<group>";
|
||||
@ -657,6 +669,7 @@
|
||||
BF5942711E09BC690051894B /* Model */,
|
||||
BF95E2751E49763D0030E7AD /* OpenVGDB */,
|
||||
D586496E297734060081477E /* Cheats */,
|
||||
D5CDCCE92A858DB900E22131 /* Repair */,
|
||||
);
|
||||
path = Database;
|
||||
sourceTree = "<group>";
|
||||
@ -1114,6 +1127,16 @@
|
||||
path = "Experimental Features";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D5CDCCE92A858DB900E22131 /* Repair */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D5A137442A7D814000AB1372 /* RepairDatabaseViewController.swift */,
|
||||
D5A1375A2A7D8F2600AB1372 /* ReviewSaveStatesViewController.swift */,
|
||||
D5A137662A7DB37200AB1372 /* GamePickerViewController.swift */,
|
||||
);
|
||||
path = Repair;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
||||
126
Delta/Database/Repair/GamePickerViewController.swift
Normal file
126
Delta/Database/Repair/GamePickerViewController.swift
Normal file
@ -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<Game, UIImage>
|
||||
{
|
||||
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<Game, UIImage>(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)
|
||||
}
|
||||
}
|
||||
473
Delta/Database/Repair/RepairDatabaseViewController.swift
Normal file
473
Delta/Database/Repair/RepairDatabaseViewController.swift
Normal file
@ -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, Error>) -> 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, Error>) -> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
235
Delta/Database/Repair/ReviewSaveStatesViewController.swift
Normal file
235
Delta/Database/Repair/ReviewSaveStatesViewController.swift
Normal file
@ -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<SaveState, UIImage>
|
||||
{
|
||||
let dataSource = RSTCompositeTableViewPrefetchingDataSource<SaveState, UIImage>(dataSources: [self.descriptionDataSource, self.saveStatesDataSource])
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func makeDescriptionDataSource() -> RSTDynamicTableViewPrefetchingDataSource<SaveState, UIImage>
|
||||
{
|
||||
let dataSource = RSTDynamicTableViewPrefetchingDataSource<SaveState, UIImage>()
|
||||
dataSource.numberOfSectionsHandler = { 1 }
|
||||
dataSource.numberOfItemsHandler = { _ in 0 }
|
||||
return dataSource
|
||||
}
|
||||
|
||||
func makeSaveStatesDataSource() -> RSTFetchedResultsTableViewPrefetchingDataSource<SaveState, UIImage>
|
||||
{
|
||||
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<SaveState, UIImage>(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: "")
|
||||
}
|
||||
}
|
||||
@ -23,4 +23,29 @@ extension NSManagedObjectContext
|
||||
print("Error saving NSManagedObjectContext: ", error, error.userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Perform -
|
||||
|
||||
func performAndWait<T>(_ block: @escaping () -> T) -> T
|
||||
{
|
||||
var result: T! = nil
|
||||
|
||||
self.performAndWait {
|
||||
result = block()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func performAndWait<T>(_ block: @escaping () throws -> T) throws -> T
|
||||
{
|
||||
var result: Result<T, Error>! = nil
|
||||
|
||||
self.performAndWait {
|
||||
result = Result { try block() }
|
||||
}
|
||||
|
||||
let value = try result.get()
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
38
Delta/Extensions/OSLog+Delta.swift
Normal file
38
Delta/Extensions/OSLog+Delta.swift
Normal file
@ -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: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
14
Delta/Extensions/UserDefaults+Delta.swift
Normal file
14
Delta/Extensions/UserDefaults+Delta.swift
Normal file
@ -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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user