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 */; };
|
D5A9C01D29DE058C00A8D610 /* VariableFastForward.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5A9C01C29DE058C00A8D610 /* VariableFastForward.swift */; };
|
||||||
D5AAF27729884F8600F21ACF /* CheatDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAF27629884F8600F21ACF /* CheatDevice.swift */; };
|
D5AAF27729884F8600F21ACF /* CheatDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAF27629884F8600F21ACF /* CheatDevice.swift */; };
|
||||||
D5ADD12229F33FBF00CE0560 /* Features.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ADD12129F33FBF00CE0560 /* Features.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 */; };
|
D5D78AE529F9BC3700E064F0 /* DSAirPlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D78AE429F9BC3700E064F0 /* DSAirPlay.swift */; };
|
||||||
D5D78AE729F9D40A00E064F0 /* EnvironmentValues+FeatureOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D78AE629F9D40A00E064F0 /* EnvironmentValues+FeatureOption.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 */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
D5D797E5298D946200738869 /* Contributor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contributor.swift; sourceTree = "<group>"; };
|
||||||
@ -545,6 +555,8 @@
|
|||||||
D5011C47281B6E8B00A0760B /* CharacterSet+Filename.swift */,
|
D5011C47281B6E8B00A0760B /* CharacterSet+Filename.swift */,
|
||||||
ACF7E30E29F743A3000FE071 /* PHPhotoLibrary+Authorization.swift */,
|
ACF7E30E29F743A3000FE071 /* PHPhotoLibrary+Authorization.swift */,
|
||||||
AC1C992629F9F1CF0020E6E4 /* GameViewController+ExperimentalToasts.swift */,
|
AC1C992629F9F1CF0020E6E4 /* GameViewController+ExperimentalToasts.swift */,
|
||||||
|
D5CDCCC32A85765900E22131 /* OSLog+Delta.swift */,
|
||||||
|
D5CDCCEA2A8593FC00E22131 /* UserDefaults+Delta.swift */,
|
||||||
);
|
);
|
||||||
path = Extensions;
|
path = Extensions;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -657,6 +669,7 @@
|
|||||||
BF5942711E09BC690051894B /* Model */,
|
BF5942711E09BC690051894B /* Model */,
|
||||||
BF95E2751E49763D0030E7AD /* OpenVGDB */,
|
BF95E2751E49763D0030E7AD /* OpenVGDB */,
|
||||||
D586496E297734060081477E /* Cheats */,
|
D586496E297734060081477E /* Cheats */,
|
||||||
|
D5CDCCE92A858DB900E22131 /* Repair */,
|
||||||
);
|
);
|
||||||
path = Database;
|
path = Database;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -1114,6 +1127,16 @@
|
|||||||
path = "Experimental Features";
|
path = "Experimental Features";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D5CDCCE92A858DB900E22131 /* Repair */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D5A137442A7D814000AB1372 /* RepairDatabaseViewController.swift */,
|
||||||
|
D5A1375A2A7D8F2600AB1372 /* ReviewSaveStatesViewController.swift */,
|
||||||
|
D5A137662A7DB37200AB1372 /* GamePickerViewController.swift */,
|
||||||
|
);
|
||||||
|
path = Repair;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D5D78AE329F9BC0200E064F0 /* Features */ = {
|
D5D78AE329F9BC0200E064F0 /* Features */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@ -1511,6 +1534,7 @@
|
|||||||
BFF6452E1F7CC5060056533E /* GameControllerInputMappingTransformer.swift in Sources */,
|
BFF6452E1F7CC5060056533E /* GameControllerInputMappingTransformer.swift in Sources */,
|
||||||
BF59427C1E09BC830051894B /* Cheat.swift in Sources */,
|
BF59427C1E09BC830051894B /* Cheat.swift in Sources */,
|
||||||
BFBAB2E31EB685A2004E0B0E /* DeltaCoreProtocol+Delta.swift in Sources */,
|
BFBAB2E31EB685A2004E0B0E /* DeltaCoreProtocol+Delta.swift in Sources */,
|
||||||
|
D5CDCCF22A859E7500E22131 /* RepairDatabaseViewController.swift in Sources */,
|
||||||
BF3540081C5DAFAD00C1184C /* PauseTransitionCoordinator.swift in Sources */,
|
BF3540081C5DAFAD00C1184C /* PauseTransitionCoordinator.swift in Sources */,
|
||||||
BF525EEA1FF6CD12004AA849 /* DeepLink.swift in Sources */,
|
BF525EEA1FF6CD12004AA849 /* DeepLink.swift in Sources */,
|
||||||
AC1C991029F8B8C30020E6E4 /* ToastNotificationOptions.swift in Sources */,
|
AC1C991029F8B8C30020E6E4 /* ToastNotificationOptions.swift in Sources */,
|
||||||
@ -1537,6 +1561,7 @@
|
|||||||
BF31878B1D489AAA00BD020D /* CheatValidator.swift in Sources */,
|
BF31878B1D489AAA00BD020D /* CheatValidator.swift in Sources */,
|
||||||
D560BD8629EDC45600289847 /* ExternalDisplaySceneDelegate.swift in Sources */,
|
D560BD8629EDC45600289847 /* ExternalDisplaySceneDelegate.swift in Sources */,
|
||||||
BFFC46201D59823500AF2CC6 /* InitialGamesStoryboardSegue.swift in Sources */,
|
BFFC46201D59823500AF2CC6 /* InitialGamesStoryboardSegue.swift in Sources */,
|
||||||
|
D5CDCCF02A859E5500E22131 /* UserDefaults+Delta.swift in Sources */,
|
||||||
BF15AF841F54B43B009B6AAB /* ActionInput.swift in Sources */,
|
BF15AF841F54B43B009B6AAB /* ActionInput.swift in Sources */,
|
||||||
D5D78AE529F9BC3700E064F0 /* DSAirPlay.swift in Sources */,
|
D5D78AE529F9BC3700E064F0 /* DSAirPlay.swift in Sources */,
|
||||||
BF4828841F9027B600028B97 /* Delta.xcdatamodeld in Sources */,
|
BF4828841F9027B600028B97 /* Delta.xcdatamodeld in Sources */,
|
||||||
@ -1576,12 +1601,14 @@
|
|||||||
BF34FA071CF0F510006624C7 /* EditCheatViewController.swift in Sources */,
|
BF34FA071CF0F510006624C7 /* EditCheatViewController.swift in Sources */,
|
||||||
BF1F45A421AF274D00EF9895 /* SyncResultViewController.swift in Sources */,
|
BF1F45A421AF274D00EF9895 /* SyncResultViewController.swift in Sources */,
|
||||||
BF3D6C53220286750083E05A /* Delta3ToDelta4.xcmappingmodel in Sources */,
|
BF3D6C53220286750083E05A /* Delta3ToDelta4.xcmappingmodel in Sources */,
|
||||||
|
D5CDCCF12A859E7500E22131 /* ReviewSaveStatesViewController.swift in Sources */,
|
||||||
BF5942881E09BC8B0051894B /* _Game.swift in Sources */,
|
BF5942881E09BC8B0051894B /* _Game.swift in Sources */,
|
||||||
BF56450D220239B800A8EA26 /* GameControllerInputMappingMigrationPolicy.swift in Sources */,
|
BF56450D220239B800A8EA26 /* GameControllerInputMappingMigrationPolicy.swift in Sources */,
|
||||||
BF95E2771E4977BF0030E7AD /* GameMetadata.swift in Sources */,
|
BF95E2771E4977BF0030E7AD /* GameMetadata.swift in Sources */,
|
||||||
BFFC461F1D59823500AF2CC6 /* GamesStoryboardSegue.swift in Sources */,
|
BFFC461F1D59823500AF2CC6 /* GamesStoryboardSegue.swift in Sources */,
|
||||||
BF2B98E61C97E32F00F6D57D /* SaveStatesCollectionHeaderView.swift in Sources */,
|
BF2B98E61C97E32F00F6D57D /* SaveStatesCollectionHeaderView.swift in Sources */,
|
||||||
BFC9B7391CEFCD34008629BB /* CheatsViewController.swift in Sources */,
|
BFC9B7391CEFCD34008629BB /* CheatsViewController.swift in Sources */,
|
||||||
|
D5CDCCF32A859E7500E22131 /* GamePickerViewController.swift in Sources */,
|
||||||
D5864978297756CE0081477E /* CheatBaseView.swift in Sources */,
|
D5864978297756CE0081477E /* CheatBaseView.swift in Sources */,
|
||||||
BF353FFF1C5DA3C500C1184C /* PausePresentationController.swift in Sources */,
|
BF353FFF1C5DA3C500C1184C /* PausePresentationController.swift in Sources */,
|
||||||
BFFC464C1D5998D600AF2CC6 /* CheatTableViewCell.swift in Sources */,
|
BFFC464C1D5998D600AF2CC6 /* CheatTableViewCell.swift in Sources */,
|
||||||
@ -1612,6 +1639,7 @@
|
|||||||
BF107EC41BF413F000E0C32C /* GamesViewController.swift in Sources */,
|
BF107EC41BF413F000E0C32C /* GamesViewController.swift in Sources */,
|
||||||
BF3D6C512202865F0083E05A /* Delta2ToDelta3.xcmappingmodel in Sources */,
|
BF3D6C512202865F0083E05A /* Delta2ToDelta3.xcmappingmodel in Sources */,
|
||||||
BF59426F1E09BC5D0051894B /* DatabaseManager.swift in Sources */,
|
BF59426F1E09BC5D0051894B /* DatabaseManager.swift in Sources */,
|
||||||
|
D5CDCCEF2A859E5300E22131 /* OSLog+Delta.swift in Sources */,
|
||||||
BF4828861F9028F500028B97 /* System.swift in Sources */,
|
BF4828861F9028F500028B97 /* System.swift in Sources */,
|
||||||
BF13A7561D5D29B0000BB055 /* PreviewGameViewController.swift in Sources */,
|
BF13A7561D5D29B0000BB055 /* PreviewGameViewController.swift in Sources */,
|
||||||
BF6866171DCAC8B900BF2D06 /* ControllerSkin+Configuring.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)
|
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)
|
override func handleLaunchError(_ error: Error)
|
||||||
|
|||||||
@ -53,7 +53,7 @@ struct Settings
|
|||||||
|
|
||||||
static func registerDefaults()
|
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.gameShortcutsMode): GameShortcutsMode.recent.rawValue,
|
||||||
#keyPath(UserDefaults.isButtonHapticFeedbackEnabled): true,
|
#keyPath(UserDefaults.isButtonHapticFeedbackEnabled): true,
|
||||||
#keyPath(UserDefaults.isThumbstickHapticFeedbackEnabled): true,
|
#keyPath(UserDefaults.isThumbstickHapticFeedbackEnabled): true,
|
||||||
@ -62,15 +62,21 @@ struct Settings
|
|||||||
#keyPath(UserDefaults.isAltJITEnabled): false,
|
#keyPath(UserDefaults.isAltJITEnabled): false,
|
||||||
#keyPath(UserDefaults.respectSilentMode): true,
|
#keyPath(UserDefaults.respectSilentMode): true,
|
||||||
Settings.preferredCoreSettingsKey(for: .ds): MelonDS.core.identifier] as [String : Any]
|
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.
|
// 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))
|
UserDefaults.standard.set(MelonDS.core.identifier, forKey: Settings.preferredCoreSettingsKey(for: .ds))
|
||||||
|
|
||||||
// Manually disable AltJIT for public builds.
|
// Manually disable AltJIT for public builds.
|
||||||
UserDefaults.standard.isAltJITEnabled = false
|
UserDefaults.standard.isAltJITEnabled = false
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
UserDefaults.standard.register(defaults: defaults)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -221,6 +221,9 @@ extension SyncManager
|
|||||||
|
|
||||||
func sync()
|
func sync()
|
||||||
{
|
{
|
||||||
|
// Don't sync until we've repaired database.
|
||||||
|
guard !UserDefaults.standard.shouldRepairDatabase else { return }
|
||||||
|
|
||||||
let progress = self.coordinator?.sync()
|
let progress = self.coordinator?.sync()
|
||||||
self.syncProgress = progress
|
self.syncProgress = progress
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user