Merge branch 'develop'
This commit is contained in:
commit
aa5d53097f
@ -1 +1 @@
|
||||
Subproject commit 1054482de95f60de78c48b01af3c04fb4eca9168
|
||||
Subproject commit 6c84366b3a76045782905293c9616e33f5da1a35
|
||||
@ -1 +1 @@
|
||||
Subproject commit 81362dd5def310f4dd908513574c7e2fd10c7d75
|
||||
Subproject commit cdd384dbacd5033183bbc3697c9738e3fb0b1d07
|
||||
@ -1 +1 @@
|
||||
Subproject commit c07950a584ed3153b90349059629db85b1c330ac
|
||||
Subproject commit 8ea36dff87bc1f787765de45fa8ccbcc1256a0e3
|
||||
@ -1 +1 @@
|
||||
Subproject commit 338a3b32c01413999e390c681dc336fdcdff1ca1
|
||||
Subproject commit 81f8ffba56823637706689fb5c6bc634ee4d9b32
|
||||
@ -1 +1 @@
|
||||
Subproject commit aed30506c77096c3e854c9a38fe9cc893e9480a1
|
||||
Subproject commit 18c595887a12ef23e0d54c63f83c91c99e7f4827
|
||||
@ -1 +1 @@
|
||||
Subproject commit 16f79982e468137c3bfe11a2dbff97423a5ce128
|
||||
Subproject commit 3d61116876fe174dcbcf60d3baadd2a0a8818de4
|
||||
@ -1 +1 @@
|
||||
Subproject commit c581c6f51efd61dc5891fac22dbada7df347c003
|
||||
Subproject commit c8816c51f82210a9c4cc62b1a7c53fa21bc705ee
|
||||
@ -1 +1 @@
|
||||
Subproject commit e5221a06ff2ff830bf60302ef0678fe5e554a925
|
||||
Subproject commit bc3e0178caa29b4c1e8872133dd00aa55cc9da2a
|
||||
@ -1 +1 @@
|
||||
Subproject commit 2ef4c123b43565688f7b7d3aa5099559b633f7ba
|
||||
Subproject commit d5717291325578f64d519822aeb2be81217c67f3
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1420"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D5D7C1F029E60DFF00663793"
|
||||
BuildableName = "libDeltaFeatures.a"
|
||||
BlueprintName = "DeltaFeatures"
|
||||
ReferencedContainer = "container:Delta.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D5D7C1F029E60DFF00663793"
|
||||
BuildableName = "libDeltaFeatures.a"
|
||||
BlueprintName = "DeltaFeatures"
|
||||
ReferencedContainer = "container:Delta.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D539102E29E88B6B0006B350"
|
||||
BuildableName = "DeltaPreviews.framework"
|
||||
BlueprintName = "DeltaPreviews"
|
||||
ReferencedContainer = "container:Delta.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D539102E29E88B6B0006B350"
|
||||
BuildableName = "DeltaPreviews.framework"
|
||||
BlueprintName = "DeltaPreviews"
|
||||
ReferencedContainer = "container:Delta.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
66
Delta.xcodeproj/xcshareddata/xcschemes/mogenerator.xcscheme
Normal file
66
Delta.xcodeproj/xcshareddata/xcschemes/mogenerator.xcscheme
Normal file
@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BF14D8941DE7A512002CA1BE"
|
||||
BuildableName = "mogenerator"
|
||||
BlueprintName = "mogenerator"
|
||||
ReferencedContainer = "container:Delta.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BF14D8941DE7A512002CA1BE"
|
||||
BuildableName = "mogenerator"
|
||||
BlueprintName = "mogenerator"
|
||||
ReferencedContainer = "container:Delta.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
@ -14,8 +14,8 @@
|
||||
"package": "DeltaCore",
|
||||
"repositoryURL": "https://github.com/rileytestut/DeltaCore.git",
|
||||
"state": {
|
||||
"branch": "main",
|
||||
"revision": "27a574a46238817084f80d811b3e6c2884d9cdc0",
|
||||
"branch": "ios14",
|
||||
"revision": "cdd384dbacd5033183bbc3697c9738e3fb0b1d07",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
|
||||
@ -12,9 +12,6 @@ import DeltaCore
|
||||
import Harmony
|
||||
import AltKit
|
||||
|
||||
import Fabric
|
||||
import Crashlytics
|
||||
|
||||
private extension CFNotificationName
|
||||
{
|
||||
static let altstoreRequestAppState: CFNotificationName = CFNotificationName("com.altstore.RequestAppState.com.rileytestut.Delta" as CFString)
|
||||
@ -40,25 +37,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate
|
||||
Settings.registerDefaults()
|
||||
|
||||
self.registerCores()
|
||||
|
||||
#if DEBUG
|
||||
|
||||
// Must go AFTER registering cores, or else NESDeltaCore may not work correctly when not connected to debugger 🤷♂️
|
||||
Fabric.with([Crashlytics.self])
|
||||
|
||||
#else
|
||||
|
||||
// Fabric doesn't allow us to change what value it uses for the bundle identifier.
|
||||
// Normally this wouldn't be an issue, except AltStore creates a unique bundle identifier per user.
|
||||
// Rather than have every copy of Delta be listed separately in Fabric, we temporarily swizzle Bundle.infoDictionary
|
||||
// to return a constant identifier while Fabric is starting up. This way, Fabric will now group
|
||||
// all copies of Delta under the bundle identifier "com.rileytestut.Delta.AltStore".
|
||||
Bundle.swizzleBundleID {
|
||||
Fabric.with([Crashlytics.self])
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
self.configureAppearance()
|
||||
|
||||
// Controllers
|
||||
@ -121,7 +99,17 @@ extension AppDelegate
|
||||
{
|
||||
// Called when a new scene session is being created.
|
||||
// Use this method to select a configuration to create the new scene with.
|
||||
return UISceneConfiguration(name: "Main", sessionRole: connectingSceneSession.role)
|
||||
|
||||
if connectingSceneSession.role == .windowExternalDisplay
|
||||
{
|
||||
// External Display
|
||||
return UISceneConfiguration(name: "External Display", sessionRole: connectingSceneSession.role)
|
||||
}
|
||||
else
|
||||
{
|
||||
// Default Scene
|
||||
return UISceneConfiguration(name: "Main", sessionRole: connectingSceneSession.role)
|
||||
}
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -116,6 +116,7 @@ class GridCollectionViewCell: UICollectionViewCell
|
||||
self.imageViewWidthConstraint.isActive = true
|
||||
|
||||
self.imageViewHeightConstraint = self.imageView.heightAnchor.constraint(equalToConstant: self.maximumImageSize.height)
|
||||
self.imageViewHeightConstraint.priority = UILayoutPriority(999) // Fixes "Unable to simultaneously satisfy constraints" runtime error when inserting new grid row.
|
||||
self.imageViewHeightConstraint.isActive = true
|
||||
|
||||
|
||||
|
||||
@ -50,7 +50,7 @@ class GridCollectionViewLayout: UICollectionViewFlowLayout
|
||||
return interitemSpacing
|
||||
}
|
||||
|
||||
private var cachedLayoutAttributes = [IndexPath: UICollectionViewLayoutAttributes]()
|
||||
private var cachedCellLayoutAttributes = [IndexPath: UICollectionViewLayoutAttributes]()
|
||||
|
||||
override var estimatedItemSize: CGSize {
|
||||
didSet {
|
||||
@ -66,6 +66,18 @@ class GridCollectionViewLayout: UICollectionViewFlowLayout
|
||||
self.sectionInset.right = self.interitemSpacing + self.contentInset.right
|
||||
}
|
||||
|
||||
override func invalidateLayout(with context: UICollectionViewLayoutInvalidationContext)
|
||||
{
|
||||
super.invalidateLayout(with: context)
|
||||
|
||||
if let context = context as? UICollectionViewFlowLayoutInvalidationContext,
|
||||
context.invalidateFlowLayoutAttributes || context.invalidateFlowLayoutDelegateMetrics || context.invalidateEverything
|
||||
{
|
||||
// Clear layout cache to prevent crashing due to returning outdated layout attributes.
|
||||
self.cachedCellLayoutAttributes = [:]
|
||||
}
|
||||
}
|
||||
|
||||
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
|
||||
{
|
||||
let layoutAttributes = super.layoutAttributesForElements(in: rect)?.map({ $0.copy() }) as! [UICollectionViewLayoutAttributes]
|
||||
@ -139,10 +151,10 @@ class GridCollectionViewLayout: UICollectionViewFlowLayout
|
||||
}
|
||||
}
|
||||
|
||||
for attributes in layoutAttributes
|
||||
for attributes in layoutAttributes where attributes.representedElementCategory == .cell
|
||||
{
|
||||
// Update cached attributes for layoutAttributesForItem(at:)
|
||||
self.cachedLayoutAttributes[attributes.indexPath] = attributes
|
||||
self.cachedCellLayoutAttributes[attributes.indexPath] = attributes
|
||||
}
|
||||
|
||||
return layoutAttributes
|
||||
@ -150,7 +162,7 @@ class GridCollectionViewLayout: UICollectionViewFlowLayout
|
||||
|
||||
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
|
||||
{
|
||||
if let cachedAttributes = self.cachedLayoutAttributes[indexPath]
|
||||
if let cachedAttributes = self.cachedCellLayoutAttributes[indexPath]
|
||||
{
|
||||
return cachedAttributes
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ extension UINavigationBar
|
||||
}
|
||||
|
||||
// Make "copy" of self.
|
||||
let navigationBar = UINavigationBar(frame: .zero)
|
||||
let navigationBar = UINavigationBar(frame: self.bounds) // Use self.bounds to avoid "Unable to simultaneously satisfy constraints" runtime error.
|
||||
navigationBar.barStyle = self.barStyle
|
||||
|
||||
// Set item with title so we can retrieve default text attributes.
|
||||
@ -43,10 +43,20 @@ extension UINavigationBar
|
||||
|
||||
let containerView: UIView
|
||||
|
||||
//TODO: Recursively search all subviews for title UILabel instead of hardcoded OS version-specific hierarchy traversals...
|
||||
if #available(iOS 16, *)
|
||||
{
|
||||
guard let titleControl = contentView.subviews.first(where: { NSStringFromClass(type(of: $0)).contains("Title") }) else { return nil }
|
||||
containerView = titleControl
|
||||
|
||||
if #available(iOS 17, *)
|
||||
{
|
||||
guard let view = titleControl.subviews.first else { return nil }
|
||||
containerView = view
|
||||
}
|
||||
else
|
||||
{
|
||||
containerView = titleControl
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@ -545,7 +545,7 @@ extension DatabaseManager
|
||||
try FileManager.default.removeItem(at: outputURL)
|
||||
}
|
||||
|
||||
_ = try archive.extract(entry, to: outputURL)
|
||||
_ = try archive.extract(entry, to: outputURL, skipCRC32: true)
|
||||
|
||||
outputURLs.insert(outputURL)
|
||||
}
|
||||
|
||||
@ -3,6 +3,6 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>_XCCurrentVersionName</key>
|
||||
<string>Delta 6.xcdatamodel</string>
|
||||
<string>Delta 7.xcdatamodel</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -0,0 +1,152 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22158.8" systemVersion="22F66" minimumToolsVersion="Xcode 7.0" sourceLanguage="Objective-C" userDefinedModelVersionIdentifier="1.0">
|
||||
<entity name="Cheat" representedClassName="Cheat" syncable="YES">
|
||||
<attribute name="code" attributeType="String" syncable="YES"/>
|
||||
<attribute name="creationDate" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
|
||||
<attribute name="identifier" attributeType="String" syncable="YES"/>
|
||||
<attribute name="isEnabled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="NO" syncable="YES"/>
|
||||
<attribute name="modifiedDate" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
|
||||
<attribute name="name" attributeType="String" syncable="YES"/>
|
||||
<attribute name="type" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" syncable="YES">
|
||||
<userInfo>
|
||||
<entry key="attributeValueClassName" value="CheatType"/>
|
||||
</userInfo>
|
||||
</attribute>
|
||||
<relationship name="game" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Game" inverseName="cheats" inverseEntity="Game" syncable="YES"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="identifier"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="ControllerSkin" representedClassName="ControllerSkin" syncable="YES">
|
||||
<attribute name="filename" attributeType="String" syncable="YES"/>
|
||||
<attribute name="gameType" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" syncable="YES">
|
||||
<userInfo>
|
||||
<entry key="attributeValueClassName" value="GameType"/>
|
||||
</userInfo>
|
||||
</attribute>
|
||||
<attribute name="identifier" attributeType="String" syncable="YES"/>
|
||||
<attribute name="isStandard" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES" syncable="YES"/>
|
||||
<attribute name="name" attributeType="String" syncable="YES"/>
|
||||
<attribute name="supportedConfigurations" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES" syncable="YES">
|
||||
<userInfo>
|
||||
<entry key="attributeValueScalarType" value="ControllerSkinConfigurations"/>
|
||||
</userInfo>
|
||||
</attribute>
|
||||
<relationship name="preferredLandscapeSkinByGames" toMany="YES" deletionRule="Nullify" destinationEntity="Game" inverseName="preferredLandscapeSkin" inverseEntity="Game" syncable="YES"/>
|
||||
<relationship name="preferredPortraitSkinByGames" toMany="YES" deletionRule="Nullify" destinationEntity="Game" inverseName="preferredPortraitSkin" inverseEntity="Game" syncable="YES"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="identifier"/>
|
||||
<constraint value="gameType"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="Game" representedClassName="Game" syncable="YES">
|
||||
<attribute name="artworkURL" optional="YES" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" syncable="YES">
|
||||
<userInfo>
|
||||
<entry key="attributeValueClassName" value="URL"/>
|
||||
</userInfo>
|
||||
</attribute>
|
||||
<attribute name="filename" attributeType="String" syncable="YES">
|
||||
<userInfo>
|
||||
<entry key="attributeValueClassName" value="NSURL"/>
|
||||
</userInfo>
|
||||
</attribute>
|
||||
<attribute name="identifier" attributeType="String" syncable="YES"/>
|
||||
<attribute name="name" attributeType="String" syncable="YES"/>
|
||||
<attribute name="playedDate" optional="YES" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
|
||||
<attribute name="type" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" syncable="YES">
|
||||
<userInfo>
|
||||
<entry key="attributeValueClassName" value="GameType"/>
|
||||
</userInfo>
|
||||
</attribute>
|
||||
<relationship name="cheats" toMany="YES" deletionRule="Cascade" destinationEntity="Cheat" inverseName="game" inverseEntity="Cheat" syncable="YES"/>
|
||||
<relationship name="gameCollection" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="GameCollection" inverseName="games" inverseEntity="GameCollection" syncable="YES"/>
|
||||
<relationship name="gameSave" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="GameSave" inverseName="game" inverseEntity="GameSave" syncable="YES"/>
|
||||
<relationship name="preferredLandscapeSkin" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ControllerSkin" inverseName="preferredLandscapeSkinByGames" inverseEntity="ControllerSkin" syncable="YES"/>
|
||||
<relationship name="preferredPortraitSkin" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="ControllerSkin" inverseName="preferredPortraitSkinByGames" inverseEntity="ControllerSkin" syncable="YES"/>
|
||||
<relationship name="previewSaveState" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="SaveState" inverseName="previewGame" inverseEntity="SaveState" syncable="YES"/>
|
||||
<relationship name="saveStates" toMany="YES" deletionRule="Cascade" destinationEntity="SaveState" inverseName="game" inverseEntity="SaveState" syncable="YES"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="identifier"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="GameCollection" representedClassName="GameCollection" syncable="YES">
|
||||
<attribute name="identifier" attributeType="String" syncable="YES"/>
|
||||
<attribute name="index" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
|
||||
<relationship name="games" toMany="YES" deletionRule="Nullify" destinationEntity="Game" inverseName="gameCollection" inverseEntity="Game" syncable="YES"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="identifier"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="GameControllerInputMapping" representedClassName="GameControllerInputMapping" syncable="YES">
|
||||
<attribute name="deltaCoreInputMapping" attributeType="Transformable" valueTransformerName="GameControllerInputMappingTransformer" syncable="YES">
|
||||
<userInfo>
|
||||
<entry key="attributeValueClassName" value="Any"/>
|
||||
</userInfo>
|
||||
</attribute>
|
||||
<attribute name="gameControllerInputType" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" syncable="YES">
|
||||
<userInfo>
|
||||
<entry key="attributeValueClassName" value="GameControllerInputType"/>
|
||||
</userInfo>
|
||||
</attribute>
|
||||
<attribute name="gameType" attributeType="Transformable" valueTransformerName="NSSecureUnarchiveFromData" syncable="YES">
|
||||
<userInfo>
|
||||
<entry key="attributeValueClassName" value="GameType"/>
|
||||
</userInfo>
|
||||
</attribute>
|
||||
<attribute name="identifier" attributeType="String" syncable="YES"/>
|
||||
<attribute name="playerIndex" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="NO" syncable="YES"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="gameControllerInputType"/>
|
||||
<constraint value="gameType"/>
|
||||
<constraint value="playerIndex"/>
|
||||
</uniquenessConstraint>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="identifier"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="GameSave" representedClassName="GameSave" syncable="YES">
|
||||
<attribute name="identifier" attributeType="String" syncable="YES"/>
|
||||
<attribute name="modifiedDate" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
|
||||
<attribute name="sha1" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<relationship name="game" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Game" inverseName="gameSave" inverseEntity="Game" syncable="YES"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="identifier"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
<entity name="SaveState" representedClassName="SaveState" versionHashModifier="quick" syncable="YES">
|
||||
<attribute name="coreIdentifier" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="creationDate" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
|
||||
<attribute name="filename" attributeType="String" syncable="YES">
|
||||
<userInfo>
|
||||
<entry key="attributeValueClassName" value="NSURL"/>
|
||||
</userInfo>
|
||||
</attribute>
|
||||
<attribute name="identifier" attributeType="String" syncable="YES"/>
|
||||
<attribute name="modifiedDate" attributeType="Date" usesScalarValueType="NO" syncable="YES"/>
|
||||
<attribute name="name" optional="YES" attributeType="String" syncable="YES"/>
|
||||
<attribute name="type" attributeType="Integer 16" defaultValueString="2" usesScalarValueType="NO" syncable="YES">
|
||||
<userInfo>
|
||||
<entry key="attributeValueScalarType" value="SaveStateType"/>
|
||||
</userInfo>
|
||||
</attribute>
|
||||
<relationship name="game" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Game" inverseName="saveStates" inverseEntity="Game" syncable="YES"/>
|
||||
<relationship name="previewGame" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Game" inverseName="previewSaveState" inverseEntity="Game" syncable="YES"/>
|
||||
<uniquenessConstraints>
|
||||
<uniquenessConstraint>
|
||||
<constraint value="identifier"/>
|
||||
</uniquenessConstraint>
|
||||
</uniquenessConstraints>
|
||||
</entity>
|
||||
</model>
|
||||
@ -53,4 +53,9 @@ extension Cheat: Syncable
|
||||
public var syncableLocalizedName: String? {
|
||||
return self.name
|
||||
}
|
||||
|
||||
public func resolveConflict(_ record: AnyRecord) -> ConflictResolution
|
||||
{
|
||||
return .newest
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,16 +13,27 @@ import Harmony
|
||||
|
||||
extension ControllerSkinConfigurations
|
||||
{
|
||||
init(traits: DeltaCore.ControllerSkin.Traits)
|
||||
init?(traits: DeltaCore.ControllerSkin.Traits)
|
||||
{
|
||||
switch (traits.displayType, traits.orientation)
|
||||
switch (traits.device, traits.displayType, traits.orientation)
|
||||
{
|
||||
case (.standard, .portrait): self = .standardPortrait
|
||||
case (.standard, .landscape): self = .standardLandscape
|
||||
case (.edgeToEdge, .portrait): self = .edgeToEdgePortrait
|
||||
case (.edgeToEdge, .landscape): self = .edgeToEdgeLandscape
|
||||
case (.splitView, .portrait): self = .splitViewPortrait
|
||||
case (.splitView, .landscape): self = .splitViewLandscape
|
||||
case (.iphone, .standard, .portrait): self = .iphoneStandardPortrait
|
||||
case (.iphone, .standard, .landscape): self = .iphoneStandardLandscape
|
||||
case (.iphone, .edgeToEdge, .portrait): self = .iphoneEdgeToEdgePortrait
|
||||
case (.iphone, .edgeToEdge, .landscape): self = .iphoneEdgeToEdgeLandscape
|
||||
case (.iphone, .splitView, _): return nil
|
||||
|
||||
case (.ipad, .standard, .portrait): self = .ipadStandardPortrait
|
||||
case (.ipad, .standard, .landscape): self = .ipadStandardLandscape
|
||||
case (.ipad, .edgeToEdge, .portrait): self = .ipadEdgeToEdgePortrait
|
||||
case (.ipad, .edgeToEdge, .landscape): self = .ipadEdgeToEdgeLandscape
|
||||
case (.ipad, .splitView, .portrait): self = .ipadSplitViewPortrait
|
||||
case (.ipad, .splitView, .landscape): self = .ipadSplitViewLandscape
|
||||
|
||||
case (.tv, .standard, .portrait): self = .tvStandardPortrait
|
||||
case (.tv, .standard, .landscape): self = .tvStandardLandscape
|
||||
case (.tv, .edgeToEdge, _): return nil
|
||||
case (.tv, .splitView, _): return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -90,6 +101,11 @@ extension ControllerSkin: ControllerSkinProtocol
|
||||
{
|
||||
return self.controllerSkin?.aspectRatio(for: traits)
|
||||
}
|
||||
|
||||
public func contentSize(for traits: DeltaCore.ControllerSkin.Traits) -> CGSize?
|
||||
{
|
||||
return self.controllerSkin?.contentSize(for: traits)
|
||||
}
|
||||
}
|
||||
|
||||
extension ControllerSkin: Syncable
|
||||
@ -113,4 +129,9 @@ extension ControllerSkin: Syncable
|
||||
public var syncableLocalizedName: String? {
|
||||
return self.name
|
||||
}
|
||||
|
||||
public func resolveConflict(_ record: AnyRecord) -> ConflictResolution
|
||||
{
|
||||
return .newest
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,12 +101,18 @@ extension Game
|
||||
super.prepareForDeletion()
|
||||
|
||||
guard let managedObjectContext = self.managedObjectContext else { return }
|
||||
|
||||
// If filename == empty string (e.g. during merge), ignore this deletion.
|
||||
// Otherwise, we may accidentally delete the entire Games directory!
|
||||
guard !self.filename.isEmpty else { return }
|
||||
|
||||
// If a game with the same identifier is also currently being inserted, Core Data is more than likely resolving a conflict by deleting the previous instance
|
||||
// In this case, we make sure we DON'T delete the game file + misc other Core Data relationships, or else we'll just lose all that data
|
||||
guard !managedObjectContext.insertedObjects.contains(where: { ($0 as? Game)?.identifier == self.identifier }) else { return }
|
||||
|
||||
guard FileManager.default.fileExists(atPath: self.fileURL.path) else { return }
|
||||
// Double-check fileURL is NOT actually a directory, which we should never delete.
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: self.fileURL.path, isDirectory: &isDirectory), !isDirectory.boolValue else { return }
|
||||
|
||||
do
|
||||
{
|
||||
@ -193,4 +199,14 @@ extension Game: Syncable
|
||||
public var syncableLocalizedName: String? {
|
||||
return self.name
|
||||
}
|
||||
|
||||
public func awakeFromSync(_ record: AnyRecord) throws
|
||||
{
|
||||
guard let gameCollection = self.gameCollection else { throw SyncValidationError.incorrectGameCollection(nil) }
|
||||
|
||||
if gameCollection.identifier != self.type.rawValue
|
||||
{
|
||||
throw SyncValidationError.incorrectGameCollection(gameCollection.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,7 +30,7 @@ extension GameSave: Syncable
|
||||
}
|
||||
|
||||
public var syncableKeys: Set<AnyKeyPath> {
|
||||
return [\GameSave.modifiedDate]
|
||||
return [\GameSave.modifiedDate, \GameSave.sha1]
|
||||
}
|
||||
|
||||
public var syncableRelationships: Set<AnyKeyPath> {
|
||||
@ -53,7 +53,9 @@ extension GameSave: Syncable
|
||||
|
||||
public var syncableMetadata: [HarmonyMetadataKey : String] {
|
||||
guard let game = self.game else { return [:] }
|
||||
return [.gameID: game.identifier, .gameName: game.name]
|
||||
|
||||
// Use self.identifier to always link with exact matching game.
|
||||
return [.gameID: self.identifier, .gameName: game.name]
|
||||
}
|
||||
|
||||
public var syncableLocalizedName: String? {
|
||||
@ -66,4 +68,49 @@ extension GameSave: Syncable
|
||||
|
||||
return self.game?.identifier != Game.melonDSBIOSIdentifier && self.game?.identifier != Game.melonDSDSiBIOSIdentifier
|
||||
}
|
||||
|
||||
public func awakeFromSync(_ record: AnyRecord) throws
|
||||
{
|
||||
do
|
||||
{
|
||||
guard let game = self.game else { throw SyncValidationError.incorrectGame(nil) }
|
||||
|
||||
if game.identifier != self.identifier
|
||||
{
|
||||
let fetchRequest = GameSave.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(GameSave.identifier), game.identifier)
|
||||
|
||||
if let misplacedGameSave = try self.managedObjectContext?.fetch(fetchRequest).first, misplacedGameSave.game == nil
|
||||
{
|
||||
// Relink game with its correct gameSave, in case we accidentally misplaced it.
|
||||
// Otherwise, corrupted records might displace already-downloaded GameSaves
|
||||
// due to automatic Core Data relationship propagation, despite us throwing error.
|
||||
game.gameSave = misplacedGameSave
|
||||
}
|
||||
else
|
||||
{
|
||||
// Either there is no misplacedGameSave, or there is but it's linked to another game somehow.
|
||||
game.gameSave = nil
|
||||
}
|
||||
|
||||
throw SyncValidationError.incorrectGame(game.name)
|
||||
}
|
||||
}
|
||||
catch let error as SyncValidationError
|
||||
{
|
||||
guard SyncManager.shared.ignoredCorruptedRecordIDs.contains(record.recordID) else { throw error }
|
||||
|
||||
let fetchRequest = Game.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(Game.identifier), self.identifier)
|
||||
|
||||
if let correctGame = try self.managedObjectContext?.fetch(fetchRequest).first
|
||||
{
|
||||
self.game = correctGame
|
||||
}
|
||||
else
|
||||
{
|
||||
throw ValidationError.nilRelationshipObjects(keys: [#keyPath(GameSave.game)])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -134,30 +134,60 @@ extension SaveState: Syncable
|
||||
|
||||
public var syncableMetadata: [HarmonyMetadataKey : String] {
|
||||
guard let game = self.game else { return [:] }
|
||||
return [.gameID: game.identifier, .gameName: game.name, .coreID: self.coreIdentifier].compactMapValues { $0 }
|
||||
return [.gameID: game.identifier, .gameName: game.name, .coreID: self.coreIdentifier, .verifiedGameID: game.identifier].compactMapValues { $0 }
|
||||
}
|
||||
|
||||
public var syncableLocalizedName: String? {
|
||||
return self.localizedName
|
||||
}
|
||||
|
||||
public func awakeFromSync(_ record: AnyRecord)
|
||||
public func awakeFromSync(_ record: AnyRecord) throws
|
||||
{
|
||||
guard self.coreIdentifier == nil else { return }
|
||||
guard let game = self.game, let system = System(gameType: game.type) else { return }
|
||||
|
||||
if let coreIdentifier = record.remoteMetadata?[.coreID]
|
||||
let verifiedGameID = record.remoteMetadata?[.verifiedGameID]
|
||||
|
||||
do
|
||||
{
|
||||
// SaveState was synced to older version of Delta and lost its coreIdentifier,
|
||||
// but it remains in the remote metadata so we can reassign it.
|
||||
self.coreIdentifier = coreIdentifier
|
||||
}
|
||||
else
|
||||
{
|
||||
switch system
|
||||
guard let game = self.game else { return }
|
||||
|
||||
if let system = System(gameType: game.type), self.coreIdentifier == nil
|
||||
{
|
||||
case .ds: self.coreIdentifier = DS.core.identifier // Assume DS save state with nil coreIdentifier is from DeSmuME core.
|
||||
default: self.coreIdentifier = system.deltaCore.identifier
|
||||
if let coreIdentifier = record.remoteMetadata?[.coreID]
|
||||
{
|
||||
// SaveState was synced to older version of Delta and lost its coreIdentifier,
|
||||
// but it remains in the remote metadata so we can reassign it.
|
||||
self.coreIdentifier = coreIdentifier
|
||||
}
|
||||
else
|
||||
{
|
||||
switch system
|
||||
{
|
||||
case .ds: self.coreIdentifier = DS.core.identifier // Assume DS save state with nil coreIdentifier is from DeSmuME core.
|
||||
default: self.coreIdentifier = system.deltaCore.identifier
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let verifiedGameID, verifiedGameID != game.identifier
|
||||
{
|
||||
// Game does not match verified game ID, which most likely means
|
||||
// this SaveState was reviewed + fixed on another device, but not uploaded.
|
||||
throw SyncValidationError.incorrectGame(game.name)
|
||||
}
|
||||
}
|
||||
catch let error as SyncValidationError
|
||||
{
|
||||
guard let verifiedGameID, SyncManager.shared.ignoredCorruptedRecordIDs.contains(record.recordID) else { throw error }
|
||||
|
||||
let fetchRequest = Game.fetchRequest()
|
||||
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(Game.identifier), verifiedGameID)
|
||||
|
||||
if let correctGame = try self.managedObjectContext?.fetch(fetchRequest).first
|
||||
{
|
||||
self.game = correctGame
|
||||
}
|
||||
else
|
||||
{
|
||||
throw ValidationError.nilRelationshipObjects(keys: [#keyPath(GameSave.game)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,6 +18,8 @@ public class _GameSave: NSManagedObject
|
||||
|
||||
@NSManaged public var modifiedDate: Date
|
||||
|
||||
@NSManaged public var sha1: String?
|
||||
|
||||
// MARK: - Relationships
|
||||
|
||||
@NSManaged public var game: Game?
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -9,16 +9,35 @@
|
||||
#ifndef ControllerSkinConfigurations_h
|
||||
#define ControllerSkinConfigurations_h
|
||||
|
||||
// Every possible (supported) combination of traits.
|
||||
typedef NS_OPTIONS(int16_t, ControllerSkinConfigurations)
|
||||
{
|
||||
ControllerSkinConfigurationStandardPortrait = 1 << 0,
|
||||
ControllerSkinConfigurationStandardLandscape = 1 << 1,
|
||||
/* iPhone */
|
||||
ControllerSkinConfigurationiPhoneStandardPortrait NS_SWIFT_NAME(iphoneStandardPortrait) = 1 << 0,
|
||||
ControllerSkinConfigurationiPhoneStandardLandscape NS_SWIFT_NAME(iphoneStandardLandscape) = 1 << 1,
|
||||
|
||||
ControllerSkinConfigurationSplitViewPortrait = 1 << 2,
|
||||
ControllerSkinConfigurationSplitViewLandscape = 1 << 3,
|
||||
// iPhone doesn't support Split View
|
||||
// ControllerSkinConfigurationiPhoneSplitViewPortrait = 1 << 2,
|
||||
// ControllerSkinConfigurationiPhoneSplitViewLandscape = 1 << 3,
|
||||
|
||||
ControllerSkinConfigurationEdgeToEdgePortrait = 1 << 4,
|
||||
ControllerSkinConfigurationEdgeToEdgeLandscape = 1 << 5,
|
||||
ControllerSkinConfigurationiPhoneEdgeToEdgePortrait NS_SWIFT_NAME(iphoneEdgeToEdgePortrait) = 1 << 4,
|
||||
ControllerSkinConfigurationiPhoneEdgeToEdgeLandscape NS_SWIFT_NAME(iphoneEdgeToEdgeLandscape) = 1 << 5,
|
||||
|
||||
|
||||
/* iPad */
|
||||
ControllerSkinConfigurationiPadStandardPortrait NS_SWIFT_NAME(ipadStandardPortrait) = 1 << 6,
|
||||
ControllerSkinConfigurationiPadStandardLandscape NS_SWIFT_NAME(ipadStandardLandscape) = 1 << 7,
|
||||
|
||||
ControllerSkinConfigurationiPadSplitViewPortrait NS_SWIFT_NAME(ipadSplitViewPortrait) = 1 << 2, // Backwards compatible with legacy ControllerSkinConfigurationSplitViewPortrait
|
||||
ControllerSkinConfigurationiPadSplitViewLandscape NS_SWIFT_NAME(ipadSplitViewLandscape) = 1 << 3, // Backwards compatible with legacy ControllerSkinConfigurationSplitViewLandscape
|
||||
|
||||
ControllerSkinConfigurationiPadEdgeToEdgePortrait NS_SWIFT_NAME(ipadEdgeToEdgePortrait) = 1 << 8,
|
||||
ControllerSkinConfigurationiPadEdgeToEdgeLandscape NS_SWIFT_NAME(ipadEdgeToEdgeLandscape) = 1 << 9,
|
||||
|
||||
|
||||
/* TV */
|
||||
ControllerSkinConfigurationTVStandardPortrait = 1 << 10,
|
||||
ControllerSkinConfigurationTVStandardLandscape = 1 << 11,
|
||||
};
|
||||
|
||||
#endif /* ControllerSkinConfigurations_h */
|
||||
|
||||
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(named: "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)
|
||||
}
|
||||
}
|
||||
479
Delta/Database/Repair/RepairDatabaseViewController.swift
Normal file
479
Delta/Database/Repair/RepairDatabaseViewController.swift
Normal file
@ -0,0 +1,479 @@
|
||||
//
|
||||
// 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.notice("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)
|
||||
|
||||
var conflictedGames = Set<Game>()
|
||||
|
||||
for gameSave in gameSaves
|
||||
{
|
||||
let expectedGame = self.repair(gameSave, backupsDirectory: savesBackupsDirectory)
|
||||
|
||||
// At this point, gameSave is only updated in gameSavesContext,
|
||||
// so gameSave here still points to previous game,
|
||||
|
||||
if let game = gameSave.game
|
||||
{
|
||||
Logger.database.notice("The save file for “\(game.name, privacy: .public)” is potentially corrupted, writing to conflicts.txt")
|
||||
conflictedGames.insert(game)
|
||||
}
|
||||
|
||||
if let expectedGame
|
||||
{
|
||||
Logger.database.notice("The save file for “\(expectedGame.name, privacy: .public)” is potentially corrupted, writing to conflicts.txt")
|
||||
conflictedGames.insert(expectedGame)
|
||||
}
|
||||
}
|
||||
|
||||
try self.gameSavesContext.performAndWait {
|
||||
try self.gameSavesContext.save()
|
||||
}
|
||||
|
||||
try self.managedObjectContext.save()
|
||||
|
||||
let outputURL = self.backupsDirectory.appendingPathComponent("conflicts.txt")
|
||||
|
||||
let conflictsLog = conflictedGames.map { $0.name + " (" + $0.identifier + ")" }.sorted().joined(separator: "\n")
|
||||
try conflictsLog.write(to: outputURL, atomically: true, encoding: .utf8)
|
||||
|
||||
completion(.success)
|
||||
}
|
||||
catch
|
||||
{
|
||||
completion(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Returns expectedGame, but in managedObjectContext (not gameSavesContext)
|
||||
func repair(_ gameSave: GameSave, backupsDirectory: URL) -> Game?
|
||||
{
|
||||
Logger.database.notice("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 nil
|
||||
}
|
||||
|
||||
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, ignoring misplaced GameSave \(misplacedGameSave.identifier, privacy: .public).")
|
||||
|
||||
// Not worth potential conflicts.
|
||||
// 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, ignoring.")
|
||||
|
||||
// Not worth potential conflicts.
|
||||
// let modifiedDate = try? FileManager.default.attributesOfItem(atPath: expectedGame.gameSaveURL.path)[.modificationDate] as? Date
|
||||
// gameSave.sha1 = hash
|
||||
// gameSave.modifiedDate = modifiedDate ?? Date()
|
||||
}
|
||||
|
||||
gameSave.game = expectedGame
|
||||
}
|
||||
|
||||
return expectedGame
|
||||
}
|
||||
|
||||
func backup(_ gameSave: GameSave, for expectedGame: Game?, to backupsDirectory: URL) throws
|
||||
{
|
||||
Logger.database.notice("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.notice("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.notice("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.notice("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.notice("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.notice("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.filter = .sinceLastBeta
|
||||
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("Database Repaired", comment: ""),
|
||||
message: NSLocalizedString("Some save files may still be corrupted and require you to restore an older version from the Delta Sync settings.\n\nA text file listing all affected games has been saved to “On My Device/Delta/Backups/conflicts.txt” in the Files app, alongside backups of any conflicted save files.", comment: ""),
|
||||
preferredStyle: .alert)
|
||||
alertController.addAction(UIAlertAction(title: UIAlertAction.ok.title, style: UIAlertAction.ok.style) { _ in
|
||||
self.completionHandler?()
|
||||
})
|
||||
self.present(alertController, animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
377
Delta/Database/Repair/ReviewSaveStatesViewController.swift
Normal file
377
Delta/Database/Repair/ReviewSaveStatesViewController.swift
Normal file
@ -0,0 +1,377 @@
|
||||
//
|
||||
// ReviewSaveStatesViewController.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 8/4/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import OSLog
|
||||
|
||||
import Harmony
|
||||
import Roxas
|
||||
|
||||
extension ReviewSaveStatesViewController
|
||||
{
|
||||
enum Filter
|
||||
{
|
||||
case recent
|
||||
case all
|
||||
case sinceLastBeta
|
||||
}
|
||||
}
|
||||
|
||||
extension RecordFlags
|
||||
{
|
||||
static let isGameRelationshipVerified = RecordFlags(rawValue: 1 << 0)
|
||||
}
|
||||
|
||||
class ReviewSaveStatesViewController: UITableViewController
|
||||
{
|
||||
var filter: Filter = .recent {
|
||||
didSet {
|
||||
self.updateDataSource()
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
private weak var _parentNavigationController: UINavigationController?
|
||||
|
||||
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)
|
||||
|
||||
if let parent = self.parent, parent.navigationItem.title == nil
|
||||
{
|
||||
// Must change parent's navigationItem when we're contained in SwiftUI View.
|
||||
parent.navigationItem.title = NSLocalizedString("Review Save States", comment: "")
|
||||
parent.navigationItem.rightBarButtonItem = self.makeFilterButton()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool)
|
||||
{
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
_parentNavigationController = self.parent?.navigationController
|
||||
}
|
||||
|
||||
override func viewDidDisappear(_ animated: Bool)
|
||||
{
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
switch self.filter
|
||||
{
|
||||
case .all, .recent:
|
||||
if self.parent == nil || self.parent?.parent == nil
|
||||
{
|
||||
// Only finish if we're popped off navigation controller.
|
||||
self.finish()
|
||||
}
|
||||
|
||||
case .sinceLastBeta: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 fetchedResultsController = self.makeSaveStatesFetchedResultsController()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func makeSaveStatesFetchedResultsController() -> NSFetchedResultsController<SaveState>
|
||||
{
|
||||
let fetchRequest = SaveState.fetchRequest()
|
||||
fetchRequest.returnsObjectsAsFaults = false
|
||||
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \SaveState.game?.name, ascending: true), NSSortDescriptor(keyPath: \SaveState.modifiedDate, ascending: false)]
|
||||
|
||||
let predicate = NSPredicate(format: "%K != %@", #keyPath(SaveState.type), SaveStateType.auto.rawValue as NSNumber)
|
||||
|
||||
switch self.filter
|
||||
{
|
||||
case .recent:
|
||||
let oneMonthAgo = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date().addingTimeInterval(-1 * 60 * 60 * 24 * 30)
|
||||
let recentPredicate = NSPredicate(format: "%K > %@", #keyPath(SaveState.modifiedDate), oneMonthAgo as NSDate)
|
||||
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate, recentPredicate])
|
||||
|
||||
case .all:
|
||||
fetchRequest.predicate = predicate
|
||||
|
||||
case .sinceLastBeta:
|
||||
let dateComponents = DateComponents(year: 2023, month: 7, day: 18, hour: 0, minute: 0, second: 0)
|
||||
let lastBetaDate = Calendar.current.date(from: dateComponents) ?? Date().addingTimeInterval(-1 * 60 * 60 * 24 * 45)
|
||||
|
||||
let sinceLastBetaPredicate = NSPredicate(format: "%K > %@", #keyPath(SaveState.modifiedDate), lastBetaDate as NSDate)
|
||||
fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate, sinceLastBetaPredicate])
|
||||
}
|
||||
|
||||
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: self.managedObjectContext, sectionNameKeyPath: #keyPath(SaveState.game.name), cacheName: nil)
|
||||
return fetchedResultsController
|
||||
}
|
||||
|
||||
func updateDataSource()
|
||||
{
|
||||
let fetchedResultsController = self.makeSaveStatesFetchedResultsController()
|
||||
self.saveStatesDataSource.fetchedResultsController = fetchedResultsController
|
||||
}
|
||||
|
||||
func makeFilterButton() -> UIBarButtonItem
|
||||
{
|
||||
let recentAction = UIAction(title: NSLocalizedString("Past Month", comment: ""), image: UIImage(systemName: "calendar")) { [weak self] _ in
|
||||
self?.filter = .recent
|
||||
}
|
||||
let allAction = UIAction(title: NSLocalizedString("All Time", comment: ""), image: UIImage(systemName: "clock")) { [weak self] _ in
|
||||
self?.filter = .all
|
||||
}
|
||||
|
||||
var options: UIMenu.Options = []
|
||||
if #available(iOS 15, *)
|
||||
{
|
||||
options = .singleSelection
|
||||
|
||||
recentAction.state = self.filter == .recent ? .on : .off
|
||||
allAction.state = self.filter == .all ? .on : .off
|
||||
}
|
||||
|
||||
let filterMenu = UIMenu(options: options, children: [recentAction, allAction])
|
||||
|
||||
let filterButton = UIBarButtonItem(title: NSLocalizedString("Filter", comment: ""), image: UIImage(systemName: "calendar.badge.clock"), menu: filterMenu)
|
||||
return filterButton
|
||||
}
|
||||
}
|
||||
|
||||
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.notice("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
|
||||
{
|
||||
let saveStates: [SaveState]?
|
||||
|
||||
switch self.filter
|
||||
{
|
||||
case .recent, .all:
|
||||
// Only upload metadata for changed SaveStates.
|
||||
saveStates = self.managedObjectContext.updatedObjects.compactMap { $0 as? SaveState }
|
||||
|
||||
case .sinceLastBeta:
|
||||
// Upload metadata for _all_ SaveStates.
|
||||
saveStates = self.saveStatesDataSource.fetchedResultsController.fetchedObjects
|
||||
}
|
||||
|
||||
try self.managedObjectContext.save()
|
||||
|
||||
if let saveStates = saveStates, let coordinator = SyncManager.shared.coordinator
|
||||
{
|
||||
let records = try coordinator.recordController.fetchRecords(for: saveStates)
|
||||
if let context = records.first?.recordedObject?.managedObjectContext
|
||||
{
|
||||
try context.performAndWait {
|
||||
for record in records
|
||||
{
|
||||
record.perform { managedRecord in
|
||||
managedRecord.flags.insert(.isGameRelationshipVerified)
|
||||
managedRecord.setNeedsMetadataUpdate()
|
||||
|
||||
let saveState = record.recordedObject
|
||||
Logger.database.notice("Flagged SaveState “\(saveState?.localizedName ?? record.recordID.identifier, privacy: .public)” for metadata update.")
|
||||
}
|
||||
}
|
||||
|
||||
try context.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._parentNavigationController ?? 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: "")
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
import DeltaCore
|
||||
import GBADeltaCore
|
||||
@ -180,6 +181,14 @@ class GameViewController: DeltaCore.GameViewController
|
||||
return .all
|
||||
}
|
||||
|
||||
override var prefersStatusBarHidden: Bool {
|
||||
return !ExperimentalFeatures.shared.showStatusBar.isEnabled
|
||||
}
|
||||
|
||||
override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
return .lightContent
|
||||
}
|
||||
|
||||
required init()
|
||||
{
|
||||
super.init()
|
||||
@ -203,7 +212,7 @@ class GameViewController: DeltaCore.GameViewController
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(GameViewController.didEnterBackground(with:)), name: UIApplication.didEnterBackgroundNotification, object: UIApplication.shared)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(GameViewController.settingsDidChange(with:)), name: .settingsDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(GameViewController.settingsDidChange(with:)), name: Settings.didChangeNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(GameViewController.deepLinkControllerLaunchGame(with:)), name: .deepLinkControllerLaunchGame, object: nil)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(GameViewController.didActivateGyro(with:)), name: GBA.didActivateGyroNotification, object: nil)
|
||||
@ -212,6 +221,9 @@ class GameViewController: DeltaCore.GameViewController
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(GameViewController.emulationDidQuit(with:)), name: EmulatorCore.emulationDidQuitNotification, object: nil)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(GameViewController.didEnableJIT(with:)), name: ServerManager.didEnableJITNotification, object: nil)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(GameViewController.sceneWillConnect(with:)), name: UIScene.willConnectNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(GameViewController.sceneDidDisconnect(with:)), name: UIScene.didDisconnectNotification, object: nil)
|
||||
}
|
||||
|
||||
deinit
|
||||
@ -404,6 +416,9 @@ extension GameViewController
|
||||
pauseViewController.fastForwardItem?.action = { [unowned self] item in
|
||||
self.performFastForwardAction(activate: item.isSelected)
|
||||
}
|
||||
pauseViewController.screenshotItem?.action = { [unowned self] item in
|
||||
self.performScreenshotAction()
|
||||
}
|
||||
|
||||
pauseViewController.sustainButtonsItem?.isSelected = gameController.sustainedInputs.count > 0
|
||||
pauseViewController.sustainButtonsItem?.action = { [unowned self, unowned pauseViewController] item in
|
||||
@ -667,6 +682,12 @@ private extension GameViewController
|
||||
{
|
||||
var touchControllerSkin = TouchControllerSkin(controllerSkin: controllerSkin)
|
||||
|
||||
if UIApplication.shared.isExternalDisplayConnected
|
||||
{
|
||||
// Only show touch screen if external display is connected.
|
||||
touchControllerSkin.screenPredicate = { $0.isTouchScreen }
|
||||
}
|
||||
|
||||
if self.view.bounds.width > self.view.bounds.height
|
||||
{
|
||||
touchControllerSkin.screenLayoutAxis = .horizontal
|
||||
@ -679,8 +700,45 @@ private extension GameViewController
|
||||
self.controllerView.controllerSkin = touchControllerSkin
|
||||
}
|
||||
|
||||
self.updateExternalDisplay()
|
||||
|
||||
self.view.setNeedsLayout()
|
||||
}
|
||||
|
||||
func updateGameViews()
|
||||
{
|
||||
if UIApplication.shared.isExternalDisplayConnected
|
||||
{
|
||||
// AirPlaying, hide all (non-touch) screens.
|
||||
|
||||
if let traits = self.controllerView.controllerSkinTraits, let screens = self.controllerView.controllerSkin?.screens(for: traits)
|
||||
{
|
||||
for (screen, gameView) in zip(screens, self.gameViews)
|
||||
{
|
||||
gameView.isEnabled = screen.isTouchScreen
|
||||
gameView.isHidden = !screen.isTouchScreen
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Either self.controllerView.controllerSkin is `nil`, or it doesn't support these traits.
|
||||
// Most likely this system only has 1 screen, so just hide self.gameView.
|
||||
|
||||
self.gameView.isEnabled = false
|
||||
self.gameView.isHidden = true
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Not AirPlaying, show all screens.
|
||||
|
||||
for gameView in self.gameViews
|
||||
{
|
||||
gameView.isEnabled = true
|
||||
gameView.isHidden = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - Game Saves -
|
||||
@ -697,23 +755,30 @@ private extension GameViewController
|
||||
let game = context.object(with: game.objectID) as! Game
|
||||
|
||||
let hash = try RSTHasher.sha1HashOfFile(at: game.gameSaveURL)
|
||||
let previousHash = game.gameSaveURL.extendedAttribute(name: "com.rileytestut.delta.sha1Hash")
|
||||
let previousHash = game.gameSave?.sha1
|
||||
|
||||
guard hash != previousHash else { return }
|
||||
|
||||
if let gameSave = game.gameSave
|
||||
{
|
||||
gameSave.modifiedDate = Date()
|
||||
gameSave.sha1 = hash
|
||||
}
|
||||
else
|
||||
{
|
||||
let gameSave = GameSave(context: context)
|
||||
gameSave.identifier = game.identifier
|
||||
gameSave.sha1 = hash
|
||||
|
||||
game.gameSave = gameSave
|
||||
}
|
||||
|
||||
try context.save()
|
||||
try game.gameSaveURL.setExtendedAttribute(name: "com.rileytestut.delta.sha1Hash", value: hash)
|
||||
|
||||
if ExperimentalFeatures.shared.toastNotifications.gameSaveEnabled
|
||||
{
|
||||
self.presentExperimentalToastView(NSLocalizedString("Game Data Saved", comment: ""))
|
||||
}
|
||||
}
|
||||
catch CocoaError.fileNoSuchFile
|
||||
{
|
||||
@ -832,6 +897,12 @@ extension GameViewController: SaveStatesViewControllerDelegate
|
||||
saveState.modifiedDate = Date()
|
||||
saveState.coreIdentifier = self.emulatorCore?.deltaCore.identifier
|
||||
|
||||
if ExperimentalFeatures.shared.toastNotifications.stateSaveEnabled,
|
||||
saveState.type != .auto
|
||||
{
|
||||
self.presentExperimentalToastView(NSLocalizedString("Saved Save State", comment: ""))
|
||||
}
|
||||
|
||||
if isRunning
|
||||
{
|
||||
self.resumeEmulation()
|
||||
@ -879,6 +950,11 @@ extension GameViewController: SaveStatesViewControllerDelegate
|
||||
{
|
||||
try self.emulatorCore?.load(saveState)
|
||||
}
|
||||
|
||||
if ExperimentalFeatures.shared.toastNotifications.stateLoadEnabled
|
||||
{
|
||||
self.presentExperimentalToastView(NSLocalizedString("Loaded Save State", comment: ""))
|
||||
}
|
||||
}
|
||||
catch EmulatorCore.SaveStateError.doesNotExist
|
||||
{
|
||||
@ -1063,13 +1139,190 @@ extension GameViewController
|
||||
|
||||
if activate
|
||||
{
|
||||
emulatorCore.rate = emulatorCore.deltaCore.supportedRates.upperBound
|
||||
if ExperimentalFeatures.shared.variableFastForward.isEnabled,
|
||||
let preferredSpeed = ExperimentalFeatures.shared.variableFastForward[emulatorCore.game.type],
|
||||
(preferredSpeed.rawValue <= emulatorCore.deltaCore.supportedRates.upperBound || ExperimentalFeatures.shared.variableFastForward.allowUnrestrictedSpeeds)
|
||||
{
|
||||
emulatorCore.rate = preferredSpeed.rawValue
|
||||
}
|
||||
else
|
||||
{
|
||||
emulatorCore.rate = emulatorCore.deltaCore.supportedRates.upperBound
|
||||
}
|
||||
|
||||
if ExperimentalFeatures.shared.toastNotifications.fastForwardEnabled
|
||||
{
|
||||
self.presentExperimentalToastView(NSLocalizedString("Fast Forward Enabled", comment: ""))
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
emulatorCore.rate = emulatorCore.deltaCore.supportedRates.lowerBound
|
||||
|
||||
if ExperimentalFeatures.shared.toastNotifications.fastForwardEnabled
|
||||
{
|
||||
self.presentExperimentalToastView(NSLocalizedString("Fast Forward Disabled", comment: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func performScreenshotAction()
|
||||
{
|
||||
guard let snapshot = self.emulatorCore?.videoManager.snapshot() else { return }
|
||||
|
||||
let imageScale = ExperimentalFeatures.shared.gameScreenshots.size?.rawValue ?? 1.0
|
||||
let imageSize = CGSize(width: snapshot.size.width * imageScale, height: snapshot.size.height * imageScale)
|
||||
|
||||
let screenshotData: Data
|
||||
if imageScale == 1, let data = snapshot.pngData()
|
||||
{
|
||||
// No need to redraw image because it's already the correct size.
|
||||
screenshotData = data
|
||||
}
|
||||
else
|
||||
{
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1
|
||||
|
||||
let renderer = UIGraphicsImageRenderer(size: imageSize, format: format)
|
||||
screenshotData = renderer.pngData { (context) in
|
||||
context.cgContext.interpolationQuality = .none
|
||||
snapshot.draw(in: CGRect(origin: .zero, size: imageSize))
|
||||
}
|
||||
}
|
||||
|
||||
if ExperimentalFeatures.shared.gameScreenshots.saveToPhotos
|
||||
{
|
||||
PHPhotoLibrary.runIfAuthorized
|
||||
{
|
||||
PHPhotoLibrary.saveImageData(screenshotData)
|
||||
}
|
||||
}
|
||||
|
||||
if ExperimentalFeatures.shared.gameScreenshots.saveToFiles
|
||||
{
|
||||
let screenshotsDirectory = FileManager.default.documentsDirectory.appendingPathComponent("Screenshots")
|
||||
|
||||
do
|
||||
{
|
||||
try FileManager.default.createDirectory(at: screenshotsDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
}
|
||||
catch
|
||||
{
|
||||
print(error)
|
||||
}
|
||||
|
||||
let date = Date()
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss"
|
||||
|
||||
let fileName: URL
|
||||
if let game = self.game as? Game
|
||||
{
|
||||
let filename = game.name + "_" + dateFormatter.string(from: date) + ".png"
|
||||
fileName = screenshotsDirectory.appendingPathComponent(filename)
|
||||
}
|
||||
else
|
||||
{
|
||||
fileName = screenshotsDirectory.appendingPathComponent(dateFormatter.string(from: date) + ".png")
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
try screenshotData.write(to: fileName)
|
||||
}
|
||||
catch
|
||||
{
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
|
||||
self.pauseViewController?.screenshotItem?.isSelected = false
|
||||
}
|
||||
}
|
||||
|
||||
private extension GameViewController
|
||||
{
|
||||
func connectExternalDisplay(for scene: ExternalDisplayScene)
|
||||
{
|
||||
// We need to receive gameViewController(_:didUpdateGameViews) callback.
|
||||
scene.gameViewController.delegate = self
|
||||
|
||||
self.updateControllerSkin()
|
||||
|
||||
// Implicitly called from updateControllerSkin()
|
||||
// self.updateExternalDisplay()
|
||||
}
|
||||
|
||||
func updateExternalDisplay()
|
||||
{
|
||||
guard let scene = UIApplication.shared.externalDisplayScene else { return }
|
||||
|
||||
if scene.game?.fileURL != self.game?.fileURL
|
||||
{
|
||||
scene.game = self.game
|
||||
}
|
||||
|
||||
var controllerSkin: ControllerSkinProtocol?
|
||||
|
||||
if let game = self.game, let traits = scene.gameViewController.controllerView.controllerSkinTraits
|
||||
{
|
||||
if ExperimentalFeatures.shared.airPlaySkins.isEnabled,
|
||||
let preferredControllerSkin = ExperimentalFeatures.shared.airPlaySkins.preferredAirPlayControllerSkin(for: game.type), preferredControllerSkin.supports(traits)
|
||||
{
|
||||
// Use preferredControllerSkin directly.
|
||||
controllerSkin = preferredControllerSkin
|
||||
}
|
||||
else if let standardSkin = DeltaCore.ControllerSkin.standardControllerSkin(for: game.type), standardSkin.supports(traits)
|
||||
{
|
||||
if standardSkin.hasTouchScreen(for: traits)
|
||||
{
|
||||
// Only use TouchControllerSkin for standard controller skins with touch screens.
|
||||
|
||||
var touchControllerSkin = DeltaCore.TouchControllerSkin(controllerSkin: standardSkin)
|
||||
touchControllerSkin.screenLayoutAxis = Settings.features.dsAirPlay.layoutAxis
|
||||
|
||||
if Settings.features.dsAirPlay.topScreenOnly
|
||||
{
|
||||
touchControllerSkin.screenPredicate = { !$0.isTouchScreen }
|
||||
}
|
||||
|
||||
controllerSkin = touchControllerSkin
|
||||
}
|
||||
else
|
||||
{
|
||||
controllerSkin = standardSkin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene.gameViewController.controllerView.controllerSkin = controllerSkin
|
||||
|
||||
// Implicitly called when assigning controllerSkin.
|
||||
// self.updateExternalDisplayGameViews()
|
||||
}
|
||||
|
||||
func updateExternalDisplayGameViews()
|
||||
{
|
||||
guard let scene = UIApplication.shared.externalDisplayScene, let emulatorCore = self.emulatorCore else { return }
|
||||
|
||||
for gameView in scene.gameViewController.gameViews
|
||||
{
|
||||
emulatorCore.add(gameView)
|
||||
}
|
||||
}
|
||||
|
||||
func disconnectExternalDisplay(for scene: ExternalDisplayScene)
|
||||
{
|
||||
scene.gameViewController.delegate = nil
|
||||
|
||||
for gameView in scene.gameViewController.gameViews
|
||||
{
|
||||
self.emulatorCore?.remove(gameView)
|
||||
}
|
||||
|
||||
self.updateControllerSkin() // Reset TouchControllerSkin + GameViews
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - GameViewControllerDelegate -
|
||||
@ -1078,6 +1331,8 @@ extension GameViewController: GameViewControllerDelegate
|
||||
{
|
||||
func gameViewController(_ gameViewController: DeltaCore.GameViewController, handleMenuInputFrom gameController: GameController)
|
||||
{
|
||||
guard gameViewController == self else { return }
|
||||
|
||||
if let pausingGameController = self.pausingGameController
|
||||
{
|
||||
guard pausingGameController == gameController else { return }
|
||||
@ -1103,6 +1358,8 @@ extension GameViewController: GameViewControllerDelegate
|
||||
|
||||
func gameViewControllerShouldResumeEmulation(_ gameViewController: DeltaCore.GameViewController) -> Bool
|
||||
{
|
||||
guard gameViewController == self else { return false }
|
||||
|
||||
var result = false
|
||||
|
||||
rst_dispatch_sync_on_main_thread {
|
||||
@ -1111,6 +1368,20 @@ extension GameViewController: GameViewControllerDelegate
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func gameViewController(_ gameViewController: DeltaCore.GameViewController, didUpdateGameViews gameViews: [GameView])
|
||||
{
|
||||
// gameViewController could be `self` or ExternalDisplayScene.gameViewController.
|
||||
|
||||
if gameViewController == self
|
||||
{
|
||||
self.updateGameViews()
|
||||
}
|
||||
else
|
||||
{
|
||||
self.updateExternalDisplayGameViews()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension GameViewController
|
||||
@ -1212,6 +1483,17 @@ private extension GameViewController
|
||||
self.updateAudio()
|
||||
|
||||
case .syncingService, .isAltJITEnabled: break
|
||||
|
||||
case Settings.features.dsAirPlay.$topScreenOnly.settingsKey: fallthrough
|
||||
case Settings.features.dsAirPlay.$layoutAxis.settingsKey:
|
||||
self.updateExternalDisplay()
|
||||
|
||||
case ExperimentalFeatures.shared.airPlaySkins.settingsKey: fallthrough
|
||||
case _ where settingsName.rawValue.hasPrefix(ExperimentalFeatures.shared.airPlaySkins.settingsKey.rawValue):
|
||||
// Update whenever any of the AirPlay skins have changed.
|
||||
self.updateExternalDisplay()
|
||||
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
@ -1362,6 +1644,18 @@ private extension GameViewController
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func sceneWillConnect(with notification: Notification)
|
||||
{
|
||||
guard let scene = notification.object as? ExternalDisplayScene else { return }
|
||||
self.connectExternalDisplay(for: scene)
|
||||
}
|
||||
|
||||
@objc func sceneDidDisconnect(with notification: Notification)
|
||||
{
|
||||
guard let scene = notification.object as? ExternalDisplayScene else { return }
|
||||
self.disconnectExternalDisplay(for: scene)
|
||||
}
|
||||
}
|
||||
|
||||
private extension UserDefaults
|
||||
|
||||
53
Delta/Experimental Features/ExperimentalFeatures.swift
Normal file
53
Delta/Experimental Features/ExperimentalFeatures.swift
Normal file
@ -0,0 +1,53 @@
|
||||
//
|
||||
// ExperimentalFeatures.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/6/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import DeltaFeatures
|
||||
|
||||
struct ExperimentalFeatures: FeatureContainer
|
||||
{
|
||||
static let shared = ExperimentalFeatures()
|
||||
|
||||
@Feature(name: "AirPlay Skins",
|
||||
description: "Customize the appearance of games when AirPlaying to your TV.",
|
||||
options: AirPlaySkinsOptions())
|
||||
var airPlaySkins
|
||||
|
||||
@Feature(name: "Variable Fast Forward",
|
||||
description: "Change the preferred Fast Foward speed per-system. You can also change it by long-pressing the Fast Forward button from the Pause Menu.",
|
||||
options: VariableFastForwardOptions())
|
||||
var variableFastForward
|
||||
|
||||
@Feature(name: "Show Status Bar",
|
||||
description: "Enable to show the Status Bar during gameplay.")
|
||||
var showStatusBar
|
||||
|
||||
@Feature(name: "Game Screenshots",
|
||||
description: "When enabled, a Screenshot button will appear in the Pause Menu, allowing you to save a screenshot of your game. You can choose to save the screenshot to Photos or Files.",
|
||||
options: GameScreenshotsOptions())
|
||||
var gameScreenshots
|
||||
|
||||
@Feature(name: "Toast Notifications",
|
||||
description: "Show toast notifications as a confirmation for various actions, such as saving your game or loading a save state.",
|
||||
options: ToastNotificationOptions())
|
||||
var toastNotifications
|
||||
|
||||
@Feature(name: "Review Save States",
|
||||
description: "Review recent Save States to make sure they are associated with the correct game.",
|
||||
options: ReviewSaveStatesOptions())
|
||||
var reviewSaveStates
|
||||
|
||||
@Feature(name: "Alternate App Icon",
|
||||
description: "Change the app icon.",
|
||||
options: AlternateAppIconOptions())
|
||||
var alternateAppIcons
|
||||
|
||||
private init()
|
||||
{
|
||||
self.prepareFeatures()
|
||||
}
|
||||
}
|
||||
167
Delta/Experimental Features/Features/AirPlaySkins.swift
Normal file
167
Delta/Experimental Features/Features/AirPlaySkins.swift
Normal file
@ -0,0 +1,167 @@
|
||||
//
|
||||
// AirPlaySkins.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/20/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
import DeltaFeatures
|
||||
import DeltaCore
|
||||
|
||||
extension Feature where Options == AirPlaySkinsOptions
|
||||
{
|
||||
func preferredAirPlayControllerSkin(for gameType: GameType) -> ControllerSkin?
|
||||
{
|
||||
guard let identifier = self[gameType] else { return nil }
|
||||
|
||||
let predicate = NSPredicate(format: "%K == %@", #keyPath(ControllerSkin.identifier), identifier)
|
||||
let controllerSkin = ControllerSkin.instancesWithPredicate(predicate, inManagedObjectContext: DatabaseManager.shared.viewContext, type: ControllerSkin.self).first
|
||||
return controllerSkin
|
||||
}
|
||||
}
|
||||
|
||||
struct AirPlaySkinsOptions
|
||||
{
|
||||
@Option(name: "Manage Skins", detailView: { _ in SkinManager() })
|
||||
private var skinManager: String = "" // Hack until I figure out how to support Void properties...
|
||||
|
||||
@Option(name: LocalizedStringKey(System.nes.localizedName), description: "The controller skin used when AirPlaying NES games.", detailView: { SkinPicker(gameType: .nes, controllerSkinID: $0) })
|
||||
var nes: String?
|
||||
|
||||
@Option(name: LocalizedStringKey(System.snes.localizedName), description: "The controller skin used when AirPlaying SNES games.", detailView: { SkinPicker(gameType: .snes, controllerSkinID: $0) })
|
||||
var snes: String?
|
||||
|
||||
@Option(name: LocalizedStringKey(System.genesis.localizedName), description: "The controller skin used when AirPlaying Genesis games.", detailView: { SkinPicker(gameType: .genesis, controllerSkinID: $0) })
|
||||
var genesis: String?
|
||||
|
||||
@Option(name: LocalizedStringKey(System.n64.localizedName), description: "The controller skin used when AirPlaying N64 games.", detailView: { SkinPicker(gameType: .n64, controllerSkinID: $0) })
|
||||
var n64: String?
|
||||
|
||||
@Option(name: LocalizedStringKey(System.gbc.localizedName), description: "The controller skin used when AirPlaying GBC games.", detailView: { SkinPicker(gameType: .gbc, controllerSkinID: $0) })
|
||||
var gbc: String?
|
||||
|
||||
@Option(name: LocalizedStringKey(System.gba.localizedName), description: "The controller skin used when AirPlaying GBA games.", detailView: { SkinPicker(gameType: .gba, controllerSkinID: $0) })
|
||||
var gba: String?
|
||||
|
||||
@Option(name: LocalizedStringKey(System.ds.localizedName), description: "The controller skin used when AirPlaying DS games.", detailView: { SkinPicker(gameType: .ds, controllerSkinID: $0) })
|
||||
var ds: String?
|
||||
|
||||
subscript(gameType: GameType) -> String? {
|
||||
guard let system = System(gameType: gameType) else { return nil }
|
||||
switch system
|
||||
{
|
||||
case .nes: return self.nes
|
||||
case .snes: return self.snes
|
||||
case .genesis: return self.genesis
|
||||
case .n64: return self.n64
|
||||
case .gbc: return self.gbc
|
||||
case .gba: return self.gba
|
||||
case .ds: return self.ds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension AirPlaySkinsOptions
|
||||
{
|
||||
struct SkinPicker: View
|
||||
{
|
||||
let gameType: GameType
|
||||
|
||||
@Binding
|
||||
var controllerSkinID: String?
|
||||
|
||||
@FetchRequest
|
||||
private var controllerSkins: FetchedResults<ControllerSkin>
|
||||
|
||||
@Environment(\.featureOption)
|
||||
private var option
|
||||
|
||||
var body: some View {
|
||||
Picker(option.name ?? "", selection: $controllerSkinID) {
|
||||
ForEach(controllerSkins, id: \.identifier) { controllerSkin in
|
||||
Text(controllerSkin.name)
|
||||
.tag(Optional<String>(controllerSkin.identifier)) // Must be Optional<String> in order for selection to work.
|
||||
// .tag(controllerSkin.identifier)
|
||||
}
|
||||
|
||||
Text("None")
|
||||
.tag(String?.none)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.displayInline()
|
||||
}
|
||||
|
||||
init(gameType: GameType, controllerSkinID: Binding<String?>)
|
||||
{
|
||||
self.gameType = gameType
|
||||
self._controllerSkinID = controllerSkinID
|
||||
|
||||
let configuration = ControllerSkinConfigurations.tvStandardLandscape
|
||||
|
||||
let predicate = NSPredicate(format: "%K == %@ AND (%K & %d) != 0 AND %K == NO",
|
||||
#keyPath(ControllerSkin.gameType), self.gameType.rawValue,
|
||||
#keyPath(ControllerSkin.supportedConfigurations), configuration.rawValue,
|
||||
#keyPath(ControllerSkin.isStandard))
|
||||
|
||||
self._controllerSkins = FetchRequest(entity: ControllerSkin.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \ControllerSkin.name, ascending: true)], predicate: predicate)
|
||||
}
|
||||
}
|
||||
|
||||
struct SkinManager: View
|
||||
{
|
||||
@FetchRequest(entity: ControllerSkin.entity(),
|
||||
sortDescriptors: [NSSortDescriptor(keyPath: \ControllerSkin.name, ascending: true)],
|
||||
predicate: {
|
||||
let configuration = ControllerSkinConfigurations.tvStandardLandscape
|
||||
return NSPredicate(format: "(%K & %d) != 0 AND %K == NO",
|
||||
#keyPath(ControllerSkin.supportedConfigurations), configuration.rawValue,
|
||||
#keyPath(ControllerSkin.isStandard))
|
||||
}())
|
||||
private var controllerSkins: FetchedResults<ControllerSkin>
|
||||
|
||||
var body: some View {
|
||||
if controllerSkins.isEmpty
|
||||
{
|
||||
Text("No AirPlay Skins")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
else
|
||||
{
|
||||
List {
|
||||
ForEach(controllerSkins, id: \.identifier) { controllerSkin in
|
||||
HStack {
|
||||
Text(controllerSkin.name)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let system = System(gameType: controllerSkin.gameType)
|
||||
{
|
||||
Text(system.localizedShortName)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteAirPlaySkins)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteAirPlaySkins(at indexes: IndexSet)
|
||||
{
|
||||
let objectIDs = indexes.map { controllerSkins[$0].objectID }
|
||||
|
||||
DatabaseManager.shared.performBackgroundTask { context in
|
||||
let controllerSkins = objectIDs.compactMap { context.object(with: $0) as? ControllerSkin }
|
||||
for controllerSkin in controllerSkins
|
||||
{
|
||||
context.delete(controllerSkin)
|
||||
}
|
||||
|
||||
context.saveWithErrorLogging()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
124
Delta/Experimental Features/Features/AlternateAppIcons.swift
Normal file
124
Delta/Experimental Features/Features/AlternateAppIcons.swift
Normal file
@ -0,0 +1,124 @@
|
||||
//
|
||||
// AlternateAppIcons.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Chris Rittenhouse on 5/2/23.
|
||||
// Copyright © 2023 LitRitt. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
import DeltaFeatures
|
||||
|
||||
enum AppIcon: String, CaseIterable, CustomStringConvertible, Identifiable
|
||||
{
|
||||
case normal = "Default"
|
||||
case gba4ios = "GBA4iOS"
|
||||
case inverted = "Inverted"
|
||||
case pixelated = "Pixelated"
|
||||
case skin = "Controller Skin"
|
||||
|
||||
var description: String {
|
||||
return self.rawValue
|
||||
}
|
||||
|
||||
var id: String {
|
||||
return self.rawValue
|
||||
}
|
||||
|
||||
var author: String {
|
||||
switch self
|
||||
{
|
||||
case .normal: return "Caroline Moore"
|
||||
case .gba4ios: return "Paul Thorsen"
|
||||
case .inverted, .skin, .pixelated: return "LitRitt"
|
||||
}
|
||||
}
|
||||
|
||||
var assetName: String {
|
||||
switch self
|
||||
{
|
||||
case .normal: return "AppIcon"
|
||||
case .gba4ios: return "IconGBA4iOS"
|
||||
case .inverted: return "IconInverted"
|
||||
case .pixelated: return "IconPixelated"
|
||||
case .skin: return "IconSkin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AppIcon: Equatable
|
||||
{
|
||||
static func == (lhs: AppIcon, rhs: AppIcon) -> Bool
|
||||
{
|
||||
return lhs.description == rhs.description
|
||||
}
|
||||
}
|
||||
|
||||
extension AppIcon: LocalizedOptionValue
|
||||
{
|
||||
var localizedDescription: Text {
|
||||
Text(self.description)
|
||||
}
|
||||
}
|
||||
|
||||
struct AlternateAppIconOptions
|
||||
{
|
||||
@Option(name: "Alternate App Icon",
|
||||
description: "Choose from alternate app icons created by the community.",
|
||||
detailView: { value in
|
||||
List {
|
||||
ForEach(AppIcon.allCases) { icon in
|
||||
HStack {
|
||||
if icon == value.wrappedValue
|
||||
{
|
||||
Text("✓")
|
||||
}
|
||||
icon.localizedDescription
|
||||
Text("- by \(icon.author)")
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(.gray)
|
||||
Spacer()
|
||||
Image(uiImage: Bundle.appIcon(for: icon) ?? UIImage())
|
||||
.cornerRadius(13)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
value.wrappedValue = icon
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: value.wrappedValue) { _ in
|
||||
updateAppIcon()
|
||||
}
|
||||
.displayInline()
|
||||
})
|
||||
var icon: AppIcon = .normal
|
||||
}
|
||||
|
||||
extension AlternateAppIconOptions
|
||||
{
|
||||
static func updateAppIcon()
|
||||
{
|
||||
// Get current icon
|
||||
let currentIcon = UIApplication.shared.alternateIconName
|
||||
|
||||
// Apply chosen icon if feature is enabled
|
||||
if ExperimentalFeatures.shared.alternateAppIcons.isEnabled
|
||||
{
|
||||
let icon = ExperimentalFeatures.shared.alternateAppIcons.icon
|
||||
|
||||
// Only apply new icon if it's not already the current icon
|
||||
switch icon
|
||||
{
|
||||
case .normal: if currentIcon != nil { UIApplication.shared.setAlternateIconName(nil) } // Default app icon
|
||||
default: if currentIcon != icon.assetName { UIApplication.shared.setAlternateIconName(icon.assetName) } // Alternate app icon
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Remove alternate icons if feature is disabled
|
||||
if currentIcon != nil { UIApplication.shared.setAlternateIconName(nil) }
|
||||
}
|
||||
}
|
||||
}
|
||||
54
Delta/Experimental Features/Features/GameScreenshots.swift
Normal file
54
Delta/Experimental Features/Features/GameScreenshots.swift
Normal file
@ -0,0 +1,54 @@
|
||||
//
|
||||
// GameScreenshots.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Chris Rittenhouse on 4/24/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
import DeltaFeatures
|
||||
|
||||
enum ScreenshotSize: Double, CaseIterable, CustomStringConvertible
|
||||
{
|
||||
case x5 = 5
|
||||
case x4 = 4
|
||||
case x3 = 3
|
||||
case x2 = 2
|
||||
|
||||
var description: String {
|
||||
if #available(iOS 15, *)
|
||||
{
|
||||
let formattedText = self.rawValue.formatted(.number.decimalSeparator(strategy: .automatic))
|
||||
return "\(formattedText)x Size"
|
||||
}
|
||||
else
|
||||
{
|
||||
return "\(self.rawValue)x Size"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ScreenshotSize: LocalizedOptionValue
|
||||
{
|
||||
var localizedDescription: Text {
|
||||
Text(self.description)
|
||||
}
|
||||
|
||||
static var localizedNilDescription: Text {
|
||||
Text("Original Size")
|
||||
}
|
||||
}
|
||||
|
||||
struct GameScreenshotsOptions
|
||||
{
|
||||
@Option(name: "Save to Files", description: "Save the screenshot to the app's directory in Files.")
|
||||
var saveToFiles: Bool = true
|
||||
|
||||
@Option(name: "Save to Photos", description: "Save the screenshot to the Photo Library.")
|
||||
var saveToPhotos: Bool = false
|
||||
|
||||
@Option(name: "Image Size", description: "Choose the size of screenshots. This only increases the export size, it does not increase the quality.", values: ScreenshotSize.allCases)
|
||||
var size: ScreenshotSize?
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
//
|
||||
// LinkSaveStatesOptions.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 8/7/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
import DeltaFeatures
|
||||
|
||||
struct ReviewSaveStatesView: UIViewControllerRepresentable
|
||||
{
|
||||
func makeUIViewController(context: Context) -> ReviewSaveStatesViewController
|
||||
{
|
||||
let viewController = ReviewSaveStatesViewController()
|
||||
return viewController
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: ReviewSaveStatesViewController, context: Context)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
struct ReviewSaveStatesOptions
|
||||
{
|
||||
@Option(name: "View Save States", detailView: { _ in ReviewSaveStatesView() })
|
||||
private var reviewSaveStates: String = "" // Hack until I figure out how to support Void properties...
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
//
|
||||
// ToastNotificationOptions.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Chris Rittenhouse on 4/25/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
import DeltaFeatures
|
||||
|
||||
struct ToastNotificationOptions
|
||||
{
|
||||
@Option(name: "Duration", description: "Change how long toasts should be shown.", detailView: { duration in
|
||||
HStack {
|
||||
Text("Duration: \(duration.wrappedValue, specifier: "%.1f")s")
|
||||
Slider(value: duration, in: 1...5, step: 0.5).displayInline()
|
||||
}
|
||||
})
|
||||
var duration: Double = 1.5
|
||||
|
||||
@Option(name: "Game Data Saved",
|
||||
description: "Show toasts when performing an in game save.")
|
||||
var gameSaveEnabled: Bool = true
|
||||
|
||||
@Option(name: "Saved Save State",
|
||||
description: "Show toasts when saving a save state.")
|
||||
var stateSaveEnabled: Bool = true
|
||||
|
||||
@Option(name: "Loaded Save State",
|
||||
description: "Show toasts when loading a save state.")
|
||||
var stateLoadEnabled: Bool = true
|
||||
|
||||
@Option(name: "Fast Forward Toggled",
|
||||
description: "Show toasts when toggling fast forward.")
|
||||
var fastForwardEnabled: Bool = true
|
||||
}
|
||||
129
Delta/Experimental Features/Features/VariableFastForward.swift
Normal file
129
Delta/Experimental Features/Features/VariableFastForward.swift
Normal file
@ -0,0 +1,129 @@
|
||||
//
|
||||
// VariableFastForward.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/5/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
import DeltaCore
|
||||
import DeltaFeatures
|
||||
|
||||
struct FastForwardSpeed: RawRepresentable
|
||||
{
|
||||
let rawValue: Double
|
||||
|
||||
init(rawValue: Double)
|
||||
{
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
static func speeds(in range: ClosedRange<Double>) -> [FastForwardSpeed]
|
||||
{
|
||||
var range = range
|
||||
|
||||
if ExperimentalFeatures.shared.variableFastForward.allowUnrestrictedSpeeds
|
||||
{
|
||||
range = 1.0...8.0
|
||||
}
|
||||
|
||||
// .dropFirst() to remove 1x speed.
|
||||
var speeds = stride(from: range.lowerBound, to: range.upperBound, by: 1.0).dropFirst().map { FastForwardSpeed(rawValue: $0) }
|
||||
|
||||
// Handles both integer and non-integer maximum speeds, because range.upperBound is not included in `speeds`.
|
||||
speeds.append(.init(rawValue: range.upperBound))
|
||||
|
||||
return speeds
|
||||
}
|
||||
}
|
||||
|
||||
extension FastForwardSpeed: CustomStringConvertible, LocalizedOptionValue
|
||||
{
|
||||
var description: String {
|
||||
if #available(iOS 15, *)
|
||||
{
|
||||
let formattedText = self.rawValue.formatted(.number.decimalSeparator(strategy: .automatic))
|
||||
return "\(formattedText)x"
|
||||
}
|
||||
else
|
||||
{
|
||||
return "\(self.rawValue)x"
|
||||
}
|
||||
}
|
||||
|
||||
var localizedDescription: Text {
|
||||
Text(self.description)
|
||||
}
|
||||
|
||||
static var localizedNilDescription: Text {
|
||||
Text("Maximum")
|
||||
}
|
||||
}
|
||||
|
||||
struct VariableFastForwardOptions
|
||||
{
|
||||
// Alternatively, this feature could be implemented with single hidden dictionary @Option mapping preferred speeds to systems,
|
||||
// because we support changing these values by long-pressing the Fast Forward button in the pause menu.
|
||||
// However, we want to also show these options in Delta's settings, which requires us to explicitly define them one-by-one.
|
||||
//
|
||||
// @Option // No name = hidden
|
||||
// var preferredSpeedsBySystem: [String: Double] = [:]
|
||||
|
||||
@Option(name: "Nintendo", description: "Preferred NES fast forward speed.", values: FastForwardSpeed.speeds(in: System.nes.deltaCore.supportedRates))
|
||||
var nes: FastForwardSpeed?
|
||||
|
||||
@Option(name: "Super Nintendo", description: "Preferred SNES fast forward speed.", values: FastForwardSpeed.speeds(in: System.snes.deltaCore.supportedRates))
|
||||
var snes: FastForwardSpeed?
|
||||
|
||||
@Option(name: "Sega Genesis", description: "Preferred Genesis fast forward speed.", values: FastForwardSpeed.speeds(in: System.genesis.deltaCore.supportedRates))
|
||||
var genesis: FastForwardSpeed?
|
||||
|
||||
@Option(name: "Nintendo 64", description: "Preferred N64 fast forward speed.", values: FastForwardSpeed.speeds(in: System.n64.deltaCore.supportedRates))
|
||||
var n64: FastForwardSpeed?
|
||||
|
||||
@Option(name: "Game Boy Color", description: "Preferred GBC fast forward speed.", values: FastForwardSpeed.speeds(in: System.gbc.deltaCore.supportedRates))
|
||||
var gbc: FastForwardSpeed?
|
||||
|
||||
@Option(name: "Game Boy Advance", description: "Preferred GBA fast forward speed.", values: FastForwardSpeed.speeds(in: System.gba.deltaCore.supportedRates))
|
||||
var gba: FastForwardSpeed?
|
||||
|
||||
@Option(name: "Nintendo DS", description: "Preferred DS fast forward speed.", values: FastForwardSpeed.speeds(in: System.ds.deltaCore.supportedRates))
|
||||
var ds: FastForwardSpeed?
|
||||
|
||||
@Option(name: "Allow Unrestricted Speeds", description: "Allow choosing speeds that exceed the maximum supported speed of a system.\n\nThis can be used to test the performance of new iOS devices.")
|
||||
var allowUnrestrictedSpeeds: Bool = false
|
||||
}
|
||||
|
||||
extension Feature where Options == VariableFastForwardOptions
|
||||
{
|
||||
subscript(gameType: GameType) -> FastForwardSpeed? {
|
||||
get {
|
||||
guard let system = System(gameType: gameType) else { return nil }
|
||||
switch system
|
||||
{
|
||||
case .nes: return self.nes
|
||||
case .snes: return self.snes
|
||||
case .genesis: return self.genesis
|
||||
case .n64: return self.n64
|
||||
case .gbc: return self.gbc
|
||||
case .gba: return self.gba
|
||||
case .ds: return self.ds
|
||||
}
|
||||
}
|
||||
set {
|
||||
guard let system = System(gameType: gameType) else { return }
|
||||
switch system
|
||||
{
|
||||
case .nes: self.nes = newValue
|
||||
case .snes: self.snes = newValue
|
||||
case .genesis: self.genesis = newValue
|
||||
case .n64: self.n64 = newValue
|
||||
case .gbc: self.gbc = newValue
|
||||
case .gba: self.gba = newValue
|
||||
case .ds: self.ds = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
Delta/Extensions/Bundle+AppIconImage.swift
Normal file
34
Delta/Extensions/Bundle+AppIconImage.swift
Normal file
@ -0,0 +1,34 @@
|
||||
//
|
||||
// Bundle+AppIconImage.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Chris Rittenhouse on 7/20/23.
|
||||
// Copyright © 2023 LitRitt. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
extension Bundle
|
||||
{
|
||||
static func appIcon(for icon: AppIcon = .normal) -> UIImage? {
|
||||
guard let appIcons = Bundle.main.infoDictionary?["CFBundleIcons"] as? [String: Any] else { return nil }
|
||||
|
||||
switch icon
|
||||
{
|
||||
case .normal:
|
||||
guard let primaryAppIcon = appIcons["CFBundlePrimaryIcon"] as? [String: Any],
|
||||
let appIconFiles = primaryAppIcon["CFBundleIconFiles"] as? [String],
|
||||
let appIcon = appIconFiles.first else { return nil }
|
||||
|
||||
return UIImage(named:appIcon)
|
||||
|
||||
default:
|
||||
guard let alternateAppIcons = appIcons["CFBundleAlternateIcons"] as? [String: Any],
|
||||
let alternateAppIcon = alternateAppIcons[icon.assetName] as? [String: Any],
|
||||
let appIconFiles = alternateAppIcon["CFBundleIconFiles"] as? [String],
|
||||
let appIcon = appIconFiles.first else { return nil }
|
||||
|
||||
return UIImage(named:appIcon)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -34,21 +34,20 @@ extension ControllerSkin
|
||||
|
||||
var configurations = ControllerSkinConfigurations()
|
||||
|
||||
let device: DeltaCore.ControllerSkin.Device = (UIDevice.current.userInterfaceIdiom == .pad) ? .ipad : .iphone
|
||||
|
||||
let traitCollections: [(displayType: DeltaCore.ControllerSkin.DisplayType, orientation: DeltaCore.ControllerSkin.Orientation)] =
|
||||
[(.standard, .portrait), (.standard, .landscape), (.edgeToEdge, .portrait), (.edgeToEdge, .landscape), (.splitView, .portrait), (.splitView, .landscape)]
|
||||
|
||||
for collection in traitCollections
|
||||
{
|
||||
let traits = DeltaCore.ControllerSkin.Traits(device: device, displayType: collection.displayType, orientation: collection.orientation)
|
||||
if skin.supports(traits)
|
||||
{
|
||||
let configuration = ControllerSkinConfigurations(traits: traits)
|
||||
configurations.formUnion(configuration)
|
||||
let allTraitCombinations = DeltaCore.ControllerSkin.Device.allCases.flatMap { device in
|
||||
DeltaCore.ControllerSkin.DisplayType.allCases.flatMap { displayType in
|
||||
DeltaCore.ControllerSkin.Orientation.allCases.map { orientation in
|
||||
DeltaCore.ControllerSkin.Traits(device: device, displayType: displayType, orientation: orientation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for traits in allTraitCombinations
|
||||
{
|
||||
guard let configuration = ControllerSkinConfigurations(traits: traits), skin.supports(traits) else { continue }
|
||||
configurations.formUnion(configuration)
|
||||
}
|
||||
|
||||
self.supportedConfigurations = configurations
|
||||
}
|
||||
}
|
||||
|
||||
25
Delta/Extensions/GameViewController+ExperimentalToasts.swift
Normal file
25
Delta/Extensions/GameViewController+ExperimentalToasts.swift
Normal file
@ -0,0 +1,25 @@
|
||||
//
|
||||
// GameViewController+ExperimentalToasts.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Chris Rittenhouse on 4/26/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Roxas
|
||||
|
||||
extension GameViewController
|
||||
{
|
||||
func presentExperimentalToastView(_ text: String)
|
||||
{
|
||||
guard ExperimentalFeatures.shared.toastNotifications.isEnabled else { return }
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let toastView = RSTToastView(text: text, detailText: nil)
|
||||
toastView.edgeOffset.vertical = 8
|
||||
toastView.textLabel.textAlignment = .center
|
||||
toastView.presentationEdge = .top
|
||||
toastView.show(in: self.view, duration: ExperimentalFeatures.shared.toastNotifications.duration)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ extension HarmonyMetadataKey
|
||||
{
|
||||
static let gameID = HarmonyMetadataKey("gameID")
|
||||
static let gameName = HarmonyMetadataKey("gameName")
|
||||
static let verifiedGameID = HarmonyMetadataKey("verifiedGameID")
|
||||
|
||||
// Backwards compatibility
|
||||
static let coreID = HarmonyMetadataKey("coreID")
|
||||
|
||||
@ -124,6 +124,8 @@ extension Input
|
||||
case .leftTrigger: return NSLocalizedString("L2", comment: "")
|
||||
case .rightShoulder: return NSLocalizedString("R1", comment: "")
|
||||
case .rightTrigger: return NSLocalizedString("R2", comment: "")
|
||||
case .start: return NSLocalizedString("Start", comment: "")
|
||||
case .select: return NSLocalizedString("Select", comment: "")
|
||||
}
|
||||
|
||||
case .controller(.keyboard):
|
||||
|
||||
@ -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: return NSLocalizedString("Undefined", comment: "")
|
||||
case .debug: return NSLocalizedString("Debug", comment: "")
|
||||
case .info: return NSLocalizedString("Info", comment: "")
|
||||
case .notice: return NSLocalizedString("Notice", comment: "")
|
||||
case .error: return NSLocalizedString("Error", comment: "")
|
||||
case .fault: return NSLocalizedString("Fault", comment: "")
|
||||
@unknown default: return NSLocalizedString("Unknown", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
46
Delta/Extensions/PHPhotoLibrary+Authorization.swift
Normal file
46
Delta/Extensions/PHPhotoLibrary+Authorization.swift
Normal file
@ -0,0 +1,46 @@
|
||||
//
|
||||
// PHPhotoLibrary+Authorization.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Chris Rittenhouse on 4/24/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
extension PHPhotoLibrary
|
||||
{
|
||||
static func runIfAuthorized(code: @escaping () -> Void)
|
||||
{
|
||||
PHPhotoLibrary.requestAuthorization(for: .addOnly, handler: { success in
|
||||
switch success
|
||||
{
|
||||
case .authorized, .limited:
|
||||
code()
|
||||
|
||||
case .denied, .restricted, .notDetermined: break
|
||||
@unknown default: break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static func saveImageData(_ data: Data)
|
||||
{
|
||||
// Save the image to the Photos app
|
||||
PHPhotoLibrary.shared().performChanges({
|
||||
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: data, options: nil)
|
||||
}, completionHandler: { success, error in
|
||||
if success
|
||||
{
|
||||
// Image saved successfully
|
||||
print("Image saved to Photos app.")
|
||||
}
|
||||
else
|
||||
{
|
||||
// Error saving image
|
||||
print("Error saving image: \(error?.localizedDescription ?? "Unknown error")")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,7 @@ extension ServerManager
|
||||
{
|
||||
func prepare()
|
||||
{
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(ServerManager.didChangeJITMode(_:)), name: .settingsDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(ServerManager.didChangeJITMode(_:)), name: Settings.didChangeNotification, object: nil)
|
||||
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.isDebugging
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
//
|
||||
// URL+ExtendedAttributes.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 3/26/19.
|
||||
// Copyright © 2019 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension URL
|
||||
{
|
||||
func setExtendedAttribute(name: String, value: String) throws
|
||||
{
|
||||
try self.withUnsafeFileSystemRepresentation { (path) in
|
||||
let data = value.data(using: .utf8)
|
||||
let result = data?.withUnsafeBytes { (buffer) in
|
||||
setxattr(path, name, buffer.baseAddress, buffer.count, 0, 0)
|
||||
}
|
||||
|
||||
if let result = result, result < 0
|
||||
{
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .ENOENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extendedAttribute(name: String) -> String?
|
||||
{
|
||||
let value = self.withUnsafeFileSystemRepresentation { (path) -> String? in
|
||||
let size = getxattr(path, name, nil, 0, 0, 0)
|
||||
guard size >= 0 else { return nil }
|
||||
|
||||
var data = Data(count: size)
|
||||
let result = data.withUnsafeMutableBytes { getxattr(path, name, $0.baseAddress, $0.count, 0, 0) }
|
||||
|
||||
guard result >= 0 else { return nil }
|
||||
|
||||
let value = String(data: data, encoding: .utf8)!
|
||||
return value
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
@ -78,7 +78,7 @@ class GamesViewController: UIViewController
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(GamesViewController.syncingDidStart(_:)), name: SyncCoordinator.didStartSyncingNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(GamesViewController.syncingDidFinish(_:)), name: SyncCoordinator.didFinishSyncingNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(GamesViewController.settingsDidChange(_:)), name: .settingsDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(GamesViewController.settingsDidChange(_:)), name: Settings.didChangeNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(GamesViewController.emulationDidQuit(_:)), name: EmulatorCore.emulationDidQuitNotification, object: nil)
|
||||
}
|
||||
}
|
||||
@ -646,7 +646,7 @@ extension GamesViewController: NSFetchedResultsControllerDelegate
|
||||
|
||||
extension GamesViewController: UIAdaptivePresentationControllerDelegate
|
||||
{
|
||||
func presentationControllerWillDismiss(_ presentationController: UIPresentationController)
|
||||
func presentationControllerDidDismiss(_ presentationController: UIPresentationController)
|
||||
{
|
||||
self.sync()
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
|
||||
import UIKit
|
||||
import MobileCoreServices
|
||||
import UniformTypeIdentifiers
|
||||
import ObjectiveC
|
||||
|
||||
import DeltaCore
|
||||
@ -147,18 +148,38 @@ class ImportController: NSObject
|
||||
|
||||
private func presentDocumentBrowser()
|
||||
{
|
||||
let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(ImportController.cancel))
|
||||
let supportedTypes = self.documentTypes.compactMap { UTType($0) }
|
||||
|
||||
let documentBrowserViewController = UIDocumentBrowserViewController(forOpeningFilesWithContentTypes: Array(self.documentTypes))
|
||||
documentBrowserViewController.delegate = self
|
||||
documentBrowserViewController.modalPresentationStyle = .fullScreen
|
||||
documentBrowserViewController.browserUserInterfaceStyle = .dark
|
||||
documentBrowserViewController.allowsPickingMultipleItems = true
|
||||
documentBrowserViewController.allowsDocumentCreation = false
|
||||
documentBrowserViewController.additionalTrailingNavigationBarButtonItems = [cancelButton]
|
||||
let presentedViewController: UIViewController
|
||||
|
||||
self.presentedViewController = documentBrowserViewController
|
||||
self.presentingViewController?.present(documentBrowserViewController, animated: true, completion: nil)
|
||||
if #available(iOS 17, *)
|
||||
{
|
||||
// Prior to iOS 17, UIDocumentPickerViewController was too buggy to reliably use with iCloud Drive.
|
||||
|
||||
let documentPickerViewController = UIDocumentPickerViewController(forOpeningContentTypes: supportedTypes, asCopy: true)
|
||||
documentPickerViewController.delegate = self
|
||||
documentPickerViewController.overrideUserInterfaceStyle = .dark
|
||||
documentPickerViewController.allowsMultipleSelection = true
|
||||
|
||||
presentedViewController = documentPickerViewController
|
||||
}
|
||||
else
|
||||
{
|
||||
let documentBrowserViewController = UIDocumentBrowserViewController(forOpening: supportedTypes)
|
||||
documentBrowserViewController.delegate = self
|
||||
documentBrowserViewController.modalPresentationStyle = .fullScreen
|
||||
documentBrowserViewController.browserUserInterfaceStyle = .dark
|
||||
documentBrowserViewController.allowsPickingMultipleItems = true
|
||||
documentBrowserViewController.allowsDocumentCreation = false
|
||||
|
||||
let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(ImportController.cancel))
|
||||
documentBrowserViewController.additionalTrailingNavigationBarButtonItems = [cancelButton]
|
||||
|
||||
presentedViewController = documentBrowserViewController
|
||||
}
|
||||
|
||||
self.presentedViewController = presentedViewController
|
||||
self.presentingViewController?.present(presentedViewController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,9 +216,22 @@ extension ImportController
|
||||
}
|
||||
}
|
||||
|
||||
extension ImportController: UIDocumentPickerDelegate
|
||||
{
|
||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt documentURLs: [URL])
|
||||
{
|
||||
self.finish(with: Set(documentURLs), errors: [])
|
||||
}
|
||||
|
||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController)
|
||||
{
|
||||
self.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
extension ImportController: UIDocumentBrowserViewControllerDelegate
|
||||
{
|
||||
func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentURLs documentURLs: [URL])
|
||||
func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentsAt documentURLs: [URL])
|
||||
{
|
||||
var coordinatedURLs = Set<URL>()
|
||||
var errors = [Error]()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -174,3 +174,31 @@ extension GridMenuViewController
|
||||
}
|
||||
}
|
||||
|
||||
extension GridMenuViewController
|
||||
{
|
||||
override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration?
|
||||
{
|
||||
let item = self.dataSource.item(at: indexPath)
|
||||
guard let menu = item.menu else { return nil }
|
||||
|
||||
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: nil) { _ in menu }
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview?
|
||||
{
|
||||
guard let indexPath = configuration.identifier as? IndexPath else { return nil }
|
||||
guard let cell = collectionView.cellForItem(at: indexPath) as? GridCollectionViewCell else { return nil }
|
||||
|
||||
let parameters = UIPreviewParameters()
|
||||
parameters.backgroundColor = .clear
|
||||
parameters.visiblePath = UIBezierPath(rect: cell.contentView.bounds)
|
||||
|
||||
let preview = UITargetedPreview(view: cell.contentView, parameters: parameters)
|
||||
return preview
|
||||
}
|
||||
|
||||
override func collectionView(_ collectionView: UICollectionView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview?
|
||||
{
|
||||
return self.collectionView(collectionView, previewForHighlightingContextMenuWithConfiguration: configuration)
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,8 @@ class MenuItem: NSObject
|
||||
var image: UIImage?
|
||||
var action: ((MenuItem) -> Void)
|
||||
|
||||
var menu: UIMenu?
|
||||
|
||||
@objc dynamic var isSelected = false
|
||||
|
||||
init(text: String, image: UIImage?, action: @escaping ((MenuItem) -> Void))
|
||||
|
||||
@ -19,7 +19,7 @@ class PauseViewController: UIViewController, PauseInfoProviding
|
||||
}
|
||||
|
||||
var pauseItems: [MenuItem] {
|
||||
return [self.saveStateItem, self.loadStateItem, self.cheatCodesItem, self.fastForwardItem, self.sustainButtonsItem].compactMap { $0 }
|
||||
return [self.saveStateItem, self.loadStateItem, self.cheatCodesItem, self.fastForwardItem, self.sustainButtonsItem, self.screenshotItem].compactMap { $0 }
|
||||
}
|
||||
|
||||
/// Pause Items
|
||||
@ -28,6 +28,7 @@ class PauseViewController: UIViewController, PauseInfoProviding
|
||||
var cheatCodesItem: MenuItem?
|
||||
var fastForwardItem: MenuItem?
|
||||
var sustainButtonsItem: MenuItem?
|
||||
var screenshotItem: MenuItem?
|
||||
|
||||
/// PauseInfoProviding
|
||||
var pauseText: String?
|
||||
@ -160,8 +161,9 @@ private extension PauseViewController
|
||||
self.cheatCodesItem = nil
|
||||
self.sustainButtonsItem = nil
|
||||
self.fastForwardItem = nil
|
||||
self.screenshotItem = nil
|
||||
|
||||
guard self.emulatorCore != nil else { return }
|
||||
guard let emulatorCore = self.emulatorCore else { return }
|
||||
|
||||
self.saveStateItem = MenuItem(text: NSLocalizedString("Save State", comment: ""), image: #imageLiteral(resourceName: "SaveSaveState"), action: { [unowned self] _ in
|
||||
self.saveStatesViewControllerMode = .saving
|
||||
@ -179,6 +181,17 @@ private extension PauseViewController
|
||||
|
||||
self.fastForwardItem = MenuItem(text: NSLocalizedString("Fast Forward", comment: ""), image: #imageLiteral(resourceName: "FastForward"), action: { _ in })
|
||||
self.sustainButtonsItem = MenuItem(text: NSLocalizedString("Hold Buttons", comment: ""), image: #imageLiteral(resourceName: "SustainButtons"), action: { _ in })
|
||||
|
||||
if ExperimentalFeatures.shared.gameScreenshots.isEnabled
|
||||
{
|
||||
self.screenshotItem = MenuItem(text: NSLocalizedString("Screenshot", comment: ""), image: #imageLiteral(resourceName: "Screenshot"), action: { _ in })
|
||||
}
|
||||
|
||||
if ExperimentalFeatures.shared.variableFastForward.isEnabled
|
||||
{
|
||||
let menu = self.makeFastForwardMenu(for: emulatorCore.game)
|
||||
self.fastForwardItem?.menu = menu
|
||||
}
|
||||
}
|
||||
|
||||
func updateSafeAreaInsets()
|
||||
@ -194,4 +207,56 @@ private extension PauseViewController
|
||||
self.additionalSafeAreaInsets.right = 0
|
||||
}
|
||||
}
|
||||
|
||||
func makeFastForwardMenu(for game: GameProtocol) -> UIMenu?
|
||||
{
|
||||
guard let deltaCore = Delta.core(for: game.type), #available(iOS 15, *) else { return nil }
|
||||
|
||||
let menu = UIMenu(title: NSLocalizedString("Change the Fast Forward speed for this system.", comment: ""), options: [.singleSelection], children: [
|
||||
UIDeferredMenuElement.uncached { [weak self] completion in
|
||||
let preferredSpeed = ExperimentalFeatures.shared.variableFastForward[game.type]
|
||||
|
||||
let supportedSpeeds = FastForwardSpeed.speeds(in: deltaCore.supportedRates)
|
||||
var actions = zip(0..., supportedSpeeds).map { (index, speed) in
|
||||
|
||||
let state: UIAction.State = (speed == preferredSpeed) ? .on : .off
|
||||
let action = UIAction(title: speed.description, state: state) { action in
|
||||
ExperimentalFeatures.shared.variableFastForward[game.type] = speed
|
||||
|
||||
if let fastForwardItem = self?.fastForwardItem
|
||||
{
|
||||
fastForwardItem.isSelected = true // Always enable FF after selecting speed.
|
||||
fastForwardItem.action(fastForwardItem)
|
||||
}
|
||||
}
|
||||
|
||||
if #available(iOS 16, *)
|
||||
{
|
||||
let configuration = UIImage.SymbolConfiguration(hierarchicalColor: .deltaPurple)
|
||||
|
||||
let percentage = Double(index + 1) / Double(supportedSpeeds.count)
|
||||
action.image = UIImage(systemName: "timelapse", variableValue: percentage, configuration: configuration)
|
||||
}
|
||||
|
||||
return action
|
||||
}
|
||||
|
||||
let state: UIAction.State = (preferredSpeed == nil) ? .on : .off
|
||||
let action = UIAction(title: NSLocalizedString("Maximum", comment: ""), state: state) { action in
|
||||
ExperimentalFeatures.shared.variableFastForward[game.type] = nil
|
||||
|
||||
if let fastForwardItem = self?.fastForwardItem
|
||||
{
|
||||
fastForwardItem.isSelected = true // Always enable FF after selecting speed.
|
||||
fastForwardItem.action(fastForwardItem)
|
||||
}
|
||||
}
|
||||
actions.append(action)
|
||||
|
||||
completion(actions)
|
||||
}
|
||||
])
|
||||
|
||||
return menu
|
||||
}
|
||||
}
|
||||
|
||||
85
Delta/Scenes/ExternalDisplaySceneDelegate.swift
Normal file
85
Delta/Scenes/ExternalDisplaySceneDelegate.swift
Normal file
@ -0,0 +1,85 @@
|
||||
//
|
||||
// ExternalDisplaySceneDelegate.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/17/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import DeltaCore
|
||||
|
||||
extension UIApplication
|
||||
{
|
||||
var isExternalDisplayConnected: Bool {
|
||||
let scene = UIApplication.shared.connectedScenes.first { $0.session.role == .windowExternalDisplay }
|
||||
return scene != nil
|
||||
}
|
||||
|
||||
var externalDisplayScene: ExternalDisplayScene? {
|
||||
let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? ExternalDisplayScene }).first(where: { $0.session.role == .windowExternalDisplay })
|
||||
return scene
|
||||
}
|
||||
}
|
||||
|
||||
class ExternalDisplayScene: UIWindowScene
|
||||
{
|
||||
let gameViewController = DeltaCore.GameViewController()
|
||||
|
||||
var game: GameProtocol? {
|
||||
get { self.gameViewController.game }
|
||||
set { self.gameViewController.game = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
class ExternalDisplaySceneDelegate: UIResponder, UIWindowSceneDelegate
|
||||
{
|
||||
var window: UIWindow?
|
||||
|
||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions)
|
||||
{
|
||||
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
|
||||
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
|
||||
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
|
||||
guard let windowScene = scene as? UIWindowScene, let externalDisplayScene = scene as? ExternalDisplayScene else { return }
|
||||
|
||||
self.window = GameWindow(windowScene: windowScene)
|
||||
self.window?.tintColor = .deltaPurple
|
||||
self.window?.rootViewController = externalDisplayScene.gameViewController
|
||||
self.window?.makeKeyAndVisible()
|
||||
}
|
||||
|
||||
func sceneDidDisconnect(_ scene: UIScene)
|
||||
{
|
||||
// Called as the scene is being released by the system.
|
||||
// This occurs shortly after the scene enters the background, or when its session is discarded.
|
||||
// Release any resources associated with this scene that can be re-created the next time the scene connects.
|
||||
// The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead).
|
||||
}
|
||||
|
||||
func sceneDidBecomeActive(_ scene: UIScene)
|
||||
{
|
||||
// Called when the scene has moved from an inactive state to an active state.
|
||||
// Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
|
||||
}
|
||||
|
||||
func sceneWillResignActive(_ scene: UIScene)
|
||||
{
|
||||
// Called when the scene will move from an active state to an inactive state.
|
||||
// This may occur due to temporary interruptions (ex. an incoming phone call).
|
||||
}
|
||||
|
||||
func sceneWillEnterForeground(_ scene: UIScene)
|
||||
{
|
||||
// Called as the scene transitions from the background to the foreground.
|
||||
// Use this method to undo the changes made on entering the background.
|
||||
}
|
||||
|
||||
func sceneDidEnterBackground(_ scene: UIScene)
|
||||
{
|
||||
// Called as the scene transitions from the foreground to the background.
|
||||
// Use this method to save data, release shared resources, and store enough scene-specific state information
|
||||
// to restore the scene back to its current state.
|
||||
}
|
||||
}
|
||||
@ -59,7 +59,8 @@ extension ContributorsView
|
||||
let contributorsView = ContributorsView(viewModel: viewModel)
|
||||
|
||||
let hostingController = UIHostingController(rootView: contributorsView)
|
||||
hostingController.title = NSLocalizedString("Contributors", comment: "")
|
||||
hostingController.navigationItem.largeTitleDisplayMode = .never
|
||||
hostingController.navigationItem.title = contributorsView.localizedTitle
|
||||
|
||||
viewModel.hostingController = hostingController
|
||||
|
||||
@ -76,6 +77,8 @@ struct ContributorsView: View
|
||||
@State
|
||||
private var showErrorAlert: Bool = false
|
||||
|
||||
private var localizedTitle: String { NSLocalizedString("Contributors", comment: "") }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section(content: {}, footer: {
|
||||
@ -99,7 +102,9 @@ struct ContributorsView: View
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.grouped) // TODO: Change to .insetGrouped once we drop iOS 13 support.
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle(localizedTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.environmentObject(viewModel)
|
||||
.alert(isPresented: $showErrorAlert) {
|
||||
Alert(title: Text("Unable to Load Contributors"), message: Text(viewModel.error?.localizedDescription ?? ""), dismissButton: .default(Text("OK")) {
|
||||
|
||||
@ -108,13 +108,13 @@ private extension ControllerSkinsViewController
|
||||
{
|
||||
guard let system = self.system, let traits = self.traits else { return }
|
||||
|
||||
let configuration = ControllerSkinConfigurations(traits: traits)
|
||||
guard let configuration = ControllerSkinConfigurations(traits: traits) else { return }
|
||||
|
||||
let fetchRequest: NSFetchRequest<ControllerSkin> = ControllerSkin.fetchRequest()
|
||||
|
||||
if traits.device == .iphone && traits.displayType == .edgeToEdge
|
||||
{
|
||||
let fallbackConfiguration: ControllerSkinConfigurations = (traits.orientation == .landscape) ? .standardLandscape : .standardPortrait
|
||||
let fallbackConfiguration: ControllerSkinConfigurations = (traits.orientation == .landscape) ? .iphoneStandardLandscape : .iphoneStandardPortrait
|
||||
|
||||
// Allow selecting skins that only support standard display types as well.
|
||||
fetchRequest.predicate = NSPredicate(format: "%K == %@ AND ((%K & %d) != 0 OR (%K & %d) != 0)",
|
||||
|
||||
@ -15,6 +15,7 @@ extension ControllersSettingsViewController
|
||||
{
|
||||
private enum Section: Int
|
||||
{
|
||||
case none
|
||||
case localDevice
|
||||
case externalControllers
|
||||
case customizeControls
|
||||
@ -149,6 +150,18 @@ private extension ControllersSettingsViewController
|
||||
|
||||
switch Section(rawValue: indexPath.section)!
|
||||
{
|
||||
case .none:
|
||||
cell.textLabel?.text = NSLocalizedString("None", comment: "")
|
||||
|
||||
if self.gameController == nil
|
||||
{
|
||||
cell.accessoryType = .checkmark
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.accessoryType = .none
|
||||
}
|
||||
|
||||
case .localDevice, .externalControllers:
|
||||
let controller: GameController
|
||||
|
||||
@ -172,7 +185,7 @@ private extension ControllersSettingsViewController
|
||||
cell.accessoryType = .checkmark
|
||||
}
|
||||
else
|
||||
{
|
||||
{
|
||||
cell.accessoryType = .none
|
||||
}
|
||||
|
||||
@ -257,18 +270,20 @@ extension ControllersSettingsViewController
|
||||
{
|
||||
override func numberOfSections(in tableView: UITableView) -> Int
|
||||
{
|
||||
if self.gameController == self.localDeviceController
|
||||
if self.gameController == self.localDeviceController || self.gameController == nil
|
||||
{
|
||||
return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int
|
||||
{
|
||||
switch Section(rawValue: section)!
|
||||
{
|
||||
case .none where self.playerIndex == 0: return 0
|
||||
case .none: return 1
|
||||
case .localDevice: return 1
|
||||
case .externalControllers: return self.connectedControllers.isEmpty ? 1 : self.connectedControllers.count
|
||||
case .customizeControls: return 1
|
||||
@ -294,11 +309,30 @@ extension ControllersSettingsViewController
|
||||
{
|
||||
switch Section(rawValue: section)!
|
||||
{
|
||||
case .none: return nil
|
||||
case .localDevice: return NSLocalizedString("This Device", comment: "")
|
||||
case .externalControllers: return NSLocalizedString("Game Controllers", comment: "")
|
||||
case .customizeControls: return nil
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat
|
||||
{
|
||||
switch Section(rawValue: section)!
|
||||
{
|
||||
case .none where self.playerIndex == 0: return 1
|
||||
default: return UITableView.automaticDimension
|
||||
}
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat
|
||||
{
|
||||
switch Section(rawValue: section)!
|
||||
{
|
||||
case .none where self.playerIndex == 0: return 1
|
||||
default: return UITableView.automaticDimension
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ControllersSettingsViewController
|
||||
@ -309,6 +343,7 @@ extension ControllersSettingsViewController
|
||||
|
||||
switch Section(rawValue: indexPath.section)!
|
||||
{
|
||||
case .none: self.gameController = nil
|
||||
case .localDevice: self.gameController = self.localDeviceController
|
||||
case .externalControllers where self.connectedControllers.isEmpty: return
|
||||
case .externalControllers: self.gameController = self.connectedControllers[indexPath.row]
|
||||
@ -338,7 +373,7 @@ extension ControllersSettingsViewController
|
||||
}
|
||||
else
|
||||
{
|
||||
previousIndexPath = nil
|
||||
previousIndexPath = IndexPath(row: 0, section: Section.none.rawValue)
|
||||
}
|
||||
|
||||
self.tableView.beginUpdates()
|
||||
|
||||
@ -23,12 +23,19 @@ private extension MelonDSCoreSettingsViewController
|
||||
enum Section: Int
|
||||
{
|
||||
case general
|
||||
case airPlay
|
||||
case performance
|
||||
case dsBIOS
|
||||
case dsiBIOS
|
||||
case changeCore
|
||||
}
|
||||
|
||||
enum AirPlayRow: Int, CaseIterable
|
||||
{
|
||||
case topScreenOnly
|
||||
case layoutHorizontally
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
enum BIOSError: LocalizedError
|
||||
{
|
||||
@ -265,6 +272,32 @@ private extension MelonDSCoreSettingsViewController
|
||||
Settings.isAltJITEnabled = sender.isOn
|
||||
}
|
||||
|
||||
@IBAction func toggleTopScreenOnly(_ sender: UISwitch)
|
||||
{
|
||||
Settings.features.dsAirPlay.topScreenOnly = sender.isOn
|
||||
|
||||
self.tableView.performBatchUpdates({
|
||||
let layoutHorizontallyIndexPath = IndexPath(row: AirPlayRow.layoutHorizontally.rawValue, section: Section.airPlay.rawValue)
|
||||
if sender.isOn
|
||||
{
|
||||
self.tableView.deleteRows(at: [layoutHorizontallyIndexPath], with: .automatic)
|
||||
}
|
||||
else
|
||||
{
|
||||
self.tableView.insertRows(at: [layoutHorizontallyIndexPath], with: .automatic)
|
||||
}
|
||||
}) { _ in
|
||||
self.tableView.reloadSections([Section.airPlay.rawValue], with: .none)
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func toggleLayoutHorizontally(_ sender: UISwitch)
|
||||
{
|
||||
Settings.features.dsAirPlay.layoutAxis = sender.isOn ? .horizontal : .vertical
|
||||
|
||||
self.tableView.reloadSections([Section.airPlay.rawValue], with: .none)
|
||||
}
|
||||
|
||||
@objc func willEnterForeground(_ notification: Notification)
|
||||
{
|
||||
self.tableView.reloadData()
|
||||
@ -277,13 +310,11 @@ extension MelonDSCoreSettingsViewController
|
||||
{
|
||||
let section = Section(rawValue: sectionIndex)!
|
||||
|
||||
if isSectionHidden(section)
|
||||
switch section
|
||||
{
|
||||
return 0
|
||||
}
|
||||
else
|
||||
{
|
||||
return super.tableView(tableView, numberOfRowsInSection: sectionIndex)
|
||||
case _ where isSectionHidden(section): return 0
|
||||
case .airPlay where Settings.features.dsAirPlay.topScreenOnly: return 1 // Layout axis is irrelevant if only AirPlaying top screen.
|
||||
default: return super.tableView(tableView, numberOfRowsInSection: sectionIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@ -313,6 +344,16 @@ extension MelonDSCoreSettingsViewController
|
||||
|
||||
cell.contentView.isHidden = (item == nil)
|
||||
|
||||
case .airPlay:
|
||||
let cell = cell as! SwitchTableViewCell
|
||||
|
||||
let row = AirPlayRow.allCases[indexPath.row]
|
||||
switch row
|
||||
{
|
||||
case .topScreenOnly: cell.switchView.isOn = Settings.features.dsAirPlay.topScreenOnly
|
||||
case .layoutHorizontally: cell.switchView.isOn = (Settings.features.dsAirPlay.layoutAxis == .horizontal)
|
||||
}
|
||||
|
||||
case .performance:
|
||||
let cell = cell as! SwitchTableViewCell
|
||||
cell.switchView.isOn = Settings.isAltJITEnabled
|
||||
@ -396,7 +437,7 @@ extension MelonDSCoreSettingsViewController
|
||||
case .changeCore:
|
||||
self.changeCore()
|
||||
|
||||
case .performance: break
|
||||
case .airPlay, .performance: break
|
||||
}
|
||||
}
|
||||
|
||||
@ -417,14 +458,18 @@ extension MelonDSCoreSettingsViewController
|
||||
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String?
|
||||
{
|
||||
let section = Section(rawValue: section)!
|
||||
|
||||
if isSectionHidden(section)
|
||||
switch section
|
||||
{
|
||||
return nil
|
||||
}
|
||||
else
|
||||
{
|
||||
return super.tableView(tableView, titleForFooterInSection: section.rawValue)
|
||||
case _ where isSectionHidden(section): return nil
|
||||
case .airPlay:
|
||||
switch (Settings.features.dsAirPlay.topScreenOnly, Settings.features.dsAirPlay.layoutAxis)
|
||||
{
|
||||
case (true, _): return NSLocalizedString("When AirPlaying DS games, only the top screen will appear on the external display.", comment: "")
|
||||
case (false, .vertical): return NSLocalizedString("When AirPlaying DS games, both screens will be stacked vertically on the external display.", comment: "")
|
||||
case (false, .horizontal): return NSLocalizedString("When AirPlaying DS games, both screens will be placed side-by-side on the external display.", comment: "")
|
||||
}
|
||||
|
||||
default: return super.tableView(tableView, titleForFooterInSection: section.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,102 @@
|
||||
//
|
||||
// ExperimentalFeaturesView.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/5/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
import DeltaFeatures
|
||||
|
||||
extension ExperimentalFeaturesView
|
||||
{
|
||||
private class ViewModel: ObservableObject
|
||||
{
|
||||
@Published
|
||||
var sortedFeatures: [any AnyFeature]
|
||||
|
||||
init()
|
||||
{
|
||||
// Sort features alphabetically by name.
|
||||
self.sortedFeatures = ExperimentalFeatures.shared.allFeatures.sorted { (featureA, featureB) in
|
||||
return String(describing: featureA.name) < String(describing: featureB.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ExperimentalFeaturesView: View
|
||||
{
|
||||
@StateObject
|
||||
private var viewModel: ViewModel = ViewModel()
|
||||
|
||||
private var localizedTitle: String { NSLocalizedString("Experimental Features", comment: "") }
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(content: {}, footer: {
|
||||
Text("These features have been added by contributors to the open-source Delta project on GitHub and are currently being tested.\n\nYou may encounter bugs when using these features.")
|
||||
.font(.subheadline)
|
||||
})
|
||||
|
||||
ForEach(viewModel.sortedFeatures, id: \.key) { feature in
|
||||
section(for: feature)
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.navigationTitle(localizedTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
// Cannot open existential if return type uses concrete type T in non-covariant position (e.g. Box<T>).
|
||||
// So instead we erase return type to AnyView.
|
||||
private func section<T: AnyFeature>(for feature: T) -> AnyView
|
||||
{
|
||||
let section = FeatureSection(feature: feature)
|
||||
return AnyView(section)
|
||||
}
|
||||
}
|
||||
|
||||
extension ExperimentalFeaturesView
|
||||
{
|
||||
static func makeViewController() -> UIHostingController<some View>
|
||||
{
|
||||
let experimentalFeaturesView = ExperimentalFeaturesView()
|
||||
|
||||
let hostingController = UIHostingController(rootView: experimentalFeaturesView)
|
||||
hostingController.navigationItem.largeTitleDisplayMode = .never
|
||||
hostingController.navigationItem.title = experimentalFeaturesView.localizedTitle
|
||||
return hostingController
|
||||
}
|
||||
}
|
||||
|
||||
private struct FeatureSection<T: AnyFeature>: View
|
||||
{
|
||||
@ObservedObject
|
||||
var feature: T
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
NavigationLink(destination: FeatureDetailView(feature: feature)) {
|
||||
HStack {
|
||||
Text(feature.name)
|
||||
Spacer()
|
||||
|
||||
if feature.isEnabled
|
||||
{
|
||||
Text("On")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
if let description = feature.description
|
||||
{
|
||||
Text(description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
141
Delta/Settings/Experimental Features/FeatureDetailView.swift
Normal file
141
Delta/Settings/Experimental Features/FeatureDetailView.swift
Normal file
@ -0,0 +1,141 @@
|
||||
//
|
||||
// FeatureDetailView.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/10/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
import DeltaFeatures
|
||||
|
||||
struct FeatureDetailView<Feature: AnyFeature>: View
|
||||
{
|
||||
@ObservedObject
|
||||
var feature: Feature
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Toggle(isOn: $feature.isEnabled.animation()) {
|
||||
Text(feature.name)
|
||||
.bold()
|
||||
}
|
||||
} footer: {
|
||||
if let description = feature.description
|
||||
{
|
||||
Text(description)
|
||||
}
|
||||
}
|
||||
|
||||
if feature.isEnabled
|
||||
{
|
||||
ForEach(feature.allOptions, id: \.key) { option in
|
||||
if let optionView = optionView(option)
|
||||
{
|
||||
Section {
|
||||
optionView
|
||||
} footer: {
|
||||
if let description = option.description
|
||||
{
|
||||
Text(description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cannot open existential if return type uses concrete type T in non-covariant position (e.g. Box<T>).
|
||||
// So instead we erase return type to AnyView.
|
||||
private func optionView<T: AnyOption>(_ option: T) -> AnyView?
|
||||
{
|
||||
guard let view = OptionRow(option: option) else { return nil }
|
||||
return AnyView(view)
|
||||
}
|
||||
}
|
||||
|
||||
private struct OptionRow<Option: AnyOption, DetailView: View>: View where DetailView == Option.DetailView
|
||||
{
|
||||
let name: LocalizedStringKey
|
||||
let value: any LocalizedOptionValue
|
||||
let detailView: DetailView
|
||||
|
||||
let option: Option
|
||||
|
||||
@State
|
||||
private var displayInline: Bool = false
|
||||
|
||||
init?(option: Option)
|
||||
{
|
||||
// Only show if option has a name, localizable value, and detailView.
|
||||
guard
|
||||
let name = option.name,
|
||||
let value = option.value as? any LocalizedOptionValue,
|
||||
let detailView = option.detailView()
|
||||
else { return nil }
|
||||
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.detailView = detailView
|
||||
|
||||
self.option = option
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
let detailView = detailView
|
||||
.environment(\.managedObjectContext, DatabaseManager.shared.viewContext)
|
||||
.environment(\.featureOption, option)
|
||||
|
||||
if displayInline
|
||||
{
|
||||
// Display entire view inline.
|
||||
detailView
|
||||
}
|
||||
else
|
||||
{
|
||||
NavigationLink(destination: wrap(detailView)) {
|
||||
HStack {
|
||||
Text(name)
|
||||
Spacer()
|
||||
|
||||
value.localizedDescription
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.overlay(
|
||||
// Hack to ensure displayInline preference is in View hierarchy.
|
||||
detailView
|
||||
.hidden()
|
||||
.frame(width: 0, height: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(DisplayInlineKey.self) { displayInline in
|
||||
self.displayInline = displayInline
|
||||
}
|
||||
}
|
||||
|
||||
func wrap(_ detailView: some View) -> AnyView
|
||||
{
|
||||
let wrappedDetailView: AnyView
|
||||
|
||||
if self.detailView is any UIViewControllerRepresentable
|
||||
{
|
||||
wrappedDetailView = AnyView(detailView.ignoresSafeArea())
|
||||
}
|
||||
else
|
||||
{
|
||||
let form = Form {
|
||||
detailView
|
||||
}
|
||||
|
||||
wrappedDetailView = AnyView(form)
|
||||
}
|
||||
|
||||
return wrappedDetailView
|
||||
}
|
||||
}
|
||||
23
Delta/Settings/Features/DSAirPlay.swift
Normal file
23
Delta/Settings/Features/DSAirPlay.swift
Normal file
@ -0,0 +1,23 @@
|
||||
//
|
||||
// DSAirPlay.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/26/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
import DeltaFeatures
|
||||
import DeltaCore
|
||||
|
||||
extension TouchControllerSkin.LayoutAxis: OptionValue {}
|
||||
|
||||
struct DSAirPlayOptions
|
||||
{
|
||||
@Option
|
||||
var topScreenOnly: Bool = true
|
||||
|
||||
@Option
|
||||
var layoutAxis: TouchControllerSkin.LayoutAxis = .vertical
|
||||
}
|
||||
25
Delta/Settings/Features/Features.swift
Normal file
25
Delta/Settings/Features/Features.swift
Normal file
@ -0,0 +1,25 @@
|
||||
//
|
||||
// Features.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/21/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import DeltaFeatures
|
||||
|
||||
extension Settings
|
||||
{
|
||||
struct Features: FeatureContainer
|
||||
{
|
||||
static let shared = Features()
|
||||
|
||||
@Feature(name: "DS AirPlay", options: DSAirPlayOptions())
|
||||
var dsAirPlay
|
||||
|
||||
private init()
|
||||
{
|
||||
self.prepareFeatures()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,38 +9,28 @@
|
||||
import Foundation
|
||||
|
||||
import DeltaCore
|
||||
import DeltaFeatures
|
||||
import MelonDSDeltaCore
|
||||
|
||||
import Roxas
|
||||
|
||||
extension Notification.Name
|
||||
extension Settings.NotificationUserInfoKey
|
||||
{
|
||||
static let settingsDidChange = Notification.Name("SettingsDidChangeNotification")
|
||||
static let system: Settings.NotificationUserInfoKey = "system"
|
||||
static let traits: Settings.NotificationUserInfoKey = "traits"
|
||||
static let core: Settings.NotificationUserInfoKey = "core"
|
||||
}
|
||||
|
||||
extension Settings
|
||||
extension Settings.Name
|
||||
{
|
||||
enum NotificationUserInfoKey: String
|
||||
{
|
||||
case name
|
||||
|
||||
case system
|
||||
case traits
|
||||
|
||||
case core
|
||||
}
|
||||
|
||||
enum Name: String
|
||||
{
|
||||
case localControllerPlayerIndex
|
||||
case translucentControllerSkinOpacity
|
||||
case preferredControllerSkin
|
||||
case syncingService
|
||||
case isButtonHapticFeedbackEnabled
|
||||
case isThumbstickHapticFeedbackEnabled
|
||||
case isAltJITEnabled
|
||||
case respectSilentMode
|
||||
}
|
||||
static let localControllerPlayerIndex: Settings.Name = "localControllerPlayerIndex"
|
||||
static let translucentControllerSkinOpacity: Settings.Name = "translucentControllerSkinOpacity"
|
||||
static let preferredControllerSkin: Settings.Name = "preferredControllerSkin"
|
||||
static let syncingService: Settings.Name = "syncingService"
|
||||
static let isButtonHapticFeedbackEnabled: Settings.Name = "isButtonHapticFeedbackEnabled"
|
||||
static let isThumbstickHapticFeedbackEnabled: Settings.Name = "isThumbstickHapticFeedbackEnabled"
|
||||
static let isAltJITEnabled: Settings.Name = "isAltJITEnabled"
|
||||
static let respectSilentMode: Settings.Name = "respectSilentMode"
|
||||
}
|
||||
|
||||
extension Settings
|
||||
@ -50,13 +40,20 @@ extension Settings
|
||||
case recent
|
||||
case manual
|
||||
}
|
||||
|
||||
typealias Name = SettingsName
|
||||
typealias NotificationUserInfoKey = SettingsUserInfoKey
|
||||
|
||||
static let didChangeNotification = Notification.Name.settingsDidChange
|
||||
}
|
||||
|
||||
struct Settings
|
||||
{
|
||||
static let features = Features.shared
|
||||
|
||||
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,
|
||||
@ -65,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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,7 +86,7 @@ extension Settings
|
||||
static var localControllerPlayerIndex: Int? = 0 {
|
||||
didSet {
|
||||
guard self.localControllerPlayerIndex != oldValue else { return }
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: nil, userInfo: [NotificationUserInfoKey.name: Name.localControllerPlayerIndex])
|
||||
NotificationCenter.default.post(name: Settings.didChangeNotification, object: nil, userInfo: [NotificationUserInfoKey.name: Name.localControllerPlayerIndex])
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,7 +94,7 @@ extension Settings
|
||||
set {
|
||||
guard newValue != self.translucentControllerSkinOpacity else { return }
|
||||
UserDefaults.standard.translucentControllerSkinOpacity = newValue
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: nil, userInfo: [NotificationUserInfoKey.name: Name.translucentControllerSkinOpacity])
|
||||
NotificationCenter.default.post(name: Settings.didChangeNotification, object: nil, userInfo: [NotificationUserInfoKey.name: Name.translucentControllerSkinOpacity])
|
||||
}
|
||||
get { return UserDefaults.standard.translucentControllerSkinOpacity }
|
||||
}
|
||||
@ -160,7 +163,7 @@ extension Settings
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.syncingService = newValue?.rawValue
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: nil, userInfo: [NotificationUserInfoKey.name: Name.syncingService])
|
||||
NotificationCenter.default.post(name: Settings.didChangeNotification, object: nil, userInfo: [NotificationUserInfoKey.name: Name.syncingService])
|
||||
}
|
||||
}
|
||||
|
||||
@ -171,7 +174,7 @@ extension Settings
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.isButtonHapticFeedbackEnabled = newValue
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: nil, userInfo: [NotificationUserInfoKey.name: Name.isButtonHapticFeedbackEnabled])
|
||||
NotificationCenter.default.post(name: Settings.didChangeNotification, object: nil, userInfo: [NotificationUserInfoKey.name: Name.isButtonHapticFeedbackEnabled])
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,7 +185,7 @@ extension Settings
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.isThumbstickHapticFeedbackEnabled = newValue
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: nil, userInfo: [NotificationUserInfoKey.name: Name.isThumbstickHapticFeedbackEnabled])
|
||||
NotificationCenter.default.post(name: Settings.didChangeNotification, object: nil, userInfo: [NotificationUserInfoKey.name: Name.isThumbstickHapticFeedbackEnabled])
|
||||
}
|
||||
}
|
||||
|
||||
@ -209,7 +212,7 @@ extension Settings
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.isAltJITEnabled = newValue
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: nil, userInfo: [NotificationUserInfoKey.name: Name.isAltJITEnabled])
|
||||
NotificationCenter.default.post(name: Settings.didChangeNotification, object: nil, userInfo: [NotificationUserInfoKey.name: Name.isAltJITEnabled])
|
||||
}
|
||||
}
|
||||
|
||||
@ -220,7 +223,7 @@ extension Settings
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.respectSilentMode = newValue
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: nil, userInfo: [NotificationUserInfoKey.name: Name.respectSilentMode])
|
||||
NotificationCenter.default.post(name: Settings.didChangeNotification, object: nil, userInfo: [NotificationUserInfoKey.name: Name.respectSilentMode])
|
||||
}
|
||||
}
|
||||
|
||||
@ -241,7 +244,7 @@ extension Settings
|
||||
let key = self.preferredCoreSettingsKey(for: gameType)
|
||||
|
||||
UserDefaults.standard.set(core.identifier, forKey: key)
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: nil, userInfo: [NotificationUserInfoKey.name: key, NotificationUserInfoKey.core: core])
|
||||
NotificationCenter.default.post(name: Settings.didChangeNotification, object: nil, userInfo: [NotificationUserInfoKey.name: key, NotificationUserInfoKey.core: core])
|
||||
}
|
||||
|
||||
static func preferredControllerSkin(for system: System, traits: DeltaCore.ControllerSkin.Traits) -> ControllerSkin?
|
||||
@ -292,7 +295,7 @@ extension Settings
|
||||
|
||||
UserDefaults.standard.set(controllerSkin?.identifier, forKey: userDefaultKey)
|
||||
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: controllerSkin, userInfo: [NotificationUserInfoKey.name: Name.preferredControllerSkin, NotificationUserInfoKey.system: system, NotificationUserInfoKey.traits: traits])
|
||||
NotificationCenter.default.post(name: Settings.didChangeNotification, object: controllerSkin, userInfo: [NotificationUserInfoKey.name: Name.preferredControllerSkin, NotificationUserInfoKey.system: system, NotificationUserInfoKey.traits: traits])
|
||||
}
|
||||
|
||||
static func preferredControllerSkin(for game: Game, traits: DeltaCore.ControllerSkin.Traits) -> ControllerSkin?
|
||||
@ -350,7 +353,7 @@ extension Settings
|
||||
|
||||
if let system = System(gameType: game.type)
|
||||
{
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: controllerSkin, userInfo: [NotificationUserInfoKey.name: Name.preferredControllerSkin, NotificationUserInfoKey.system: system, NotificationUserInfoKey.traits: traits])
|
||||
NotificationCenter.default.post(name: Settings.didChangeNotification, object: controllerSkin, userInfo: [NotificationUserInfoKey.name: Name.preferredControllerSkin, NotificationUserInfoKey.system: system, NotificationUserInfoKey.traits: traits])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ private extension SettingsViewController
|
||||
case syncing
|
||||
case hapticTouch
|
||||
case cores
|
||||
case advanced
|
||||
case patreon
|
||||
case credits
|
||||
}
|
||||
@ -78,7 +79,7 @@ class SettingsViewController: UITableViewController
|
||||
{
|
||||
super.init(coder: aDecoder)
|
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(SettingsViewController.settingsDidChange(with:)), name: .settingsDidChange, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(SettingsViewController.settingsDidChange(with:)), name: Settings.didChangeNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(SettingsViewController.externalGameControllerDidConnect(_:)), name: .externalGameControllerDidConnect, object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(SettingsViewController.externalGameControllerDidDisconnect(_:)), name: .externalGameControllerDidDisconnect, object: nil)
|
||||
}
|
||||
@ -287,6 +288,12 @@ private extension SettingsViewController
|
||||
let hostingController = ContributorsView.makeViewController()
|
||||
self.navigationController?.pushViewController(hostingController, animated: true)
|
||||
}
|
||||
|
||||
func showExperimentalFeatures()
|
||||
{
|
||||
let hostingController = ExperimentalFeaturesView.makeViewController()
|
||||
self.navigationController?.pushViewController(hostingController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
private extension SettingsViewController
|
||||
@ -309,6 +316,7 @@ private extension SettingsViewController
|
||||
}
|
||||
|
||||
case .localControllerPlayerIndex, .preferredControllerSkin, .translucentControllerSkinOpacity, .respectSilentMode, .isButtonHapticFeedbackEnabled, .isThumbstickHapticFeedbackEnabled, .isAltJITEnabled: break
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
@ -330,7 +338,7 @@ extension SettingsViewController
|
||||
let section = Section(rawValue: sectionIndex)!
|
||||
switch section
|
||||
{
|
||||
case .controllers: return 1 // Temporarily hide other controller indexes until controller logic is finalized
|
||||
case .controllers: return 4
|
||||
case .controllerSkins: return System.registeredSystems.count
|
||||
case .syncing: return SyncManager.shared.coordinator?.account == nil ? 1 : super.tableView(tableView, numberOfRowsInSection: sectionIndex)
|
||||
default:
|
||||
@ -385,7 +393,7 @@ extension SettingsViewController
|
||||
let preferredCore = Settings.preferredCore(for: .ds)
|
||||
cell.detailTextLabel?.text = preferredCore?.metadata?.name.value ?? preferredCore?.name ?? NSLocalizedString("Unknown", comment: "")
|
||||
|
||||
case .controllerOpacity, .gameAudio, .hapticFeedback, .hapticTouch, .patreon, .credits: break
|
||||
case .controllerOpacity, .gameAudio, .hapticFeedback, .hapticTouch, .advanced, .patreon, .credits: break
|
||||
}
|
||||
|
||||
return cell
|
||||
@ -402,6 +410,7 @@ extension SettingsViewController
|
||||
case .controllerSkins: self.performSegue(withIdentifier: Segue.controllerSkins.rawValue, sender: cell)
|
||||
case .cores: self.performSegue(withIdentifier: Segue.dsSettings.rawValue, sender: cell)
|
||||
case .controllerOpacity, .gameAudio, .hapticFeedback, .hapticTouch, .syncing: break
|
||||
case .advanced: self.showExperimentalFeatures()
|
||||
case .patreon:
|
||||
let patreonURL = URL(string: "altstore://patreon")!
|
||||
|
||||
|
||||
@ -136,7 +136,7 @@ private extension RecordVersionsViewController
|
||||
|
||||
let localVersionsDataSource = RSTDynamicTableViewDataSource<Version>()
|
||||
localVersionsDataSource.numberOfSectionsHandler = { 1 }
|
||||
localVersionsDataSource.numberOfItemsHandler = { [weak self] _ in self?.record.localModificationDate != nil ? 1 : 0 }
|
||||
localVersionsDataSource.numberOfItemsHandler = { [weak self] _ in self?.record.localModificationDate != nil ? 1 : 0 } // fetchVersions() assumes this logic, so update there too.
|
||||
localVersionsDataSource.cellConfigurationHandler = { [weak self] (cell, _, indexPath) in
|
||||
guard let `self` = self else { return }
|
||||
|
||||
@ -200,6 +200,19 @@ private extension RecordVersionsViewController
|
||||
self.versions = versions
|
||||
|
||||
DispatchQueue.main.async {
|
||||
let previousLocalVersionExists = self.tableView.numberOfRows(inSection: Section.local.rawValue) > 0
|
||||
let localVersionExists = self.record.localModificationDate != nil
|
||||
|
||||
let localVersionIndexPath = IndexPath(row: 0, section: Section.local.rawValue)
|
||||
if !previousLocalVersionExists && localVersionExists
|
||||
{
|
||||
self.tableView.insertRows(at: [localVersionIndexPath], with: .fade)
|
||||
}
|
||||
else if previousLocalVersionExists && !localVersionExists
|
||||
{
|
||||
self.tableView.deleteRows(at: [localVersionIndexPath], with: .fade)
|
||||
}
|
||||
|
||||
let count = self.tableView.numberOfRows(inSection: Section.remote.rawValue)
|
||||
|
||||
let deletions = (0 ..< count).map { (row) -> RSTCellContentChange in
|
||||
@ -245,6 +258,8 @@ private extension RecordVersionsViewController
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
|
||||
SyncManager.shared.ignoredCorruptedRecordIDs.remove(self.record.recordID)
|
||||
|
||||
CATransaction.begin()
|
||||
|
||||
CATransaction.setCompletionBlock {
|
||||
@ -274,17 +289,34 @@ private extension RecordVersionsViewController
|
||||
}
|
||||
catch
|
||||
{
|
||||
let title: String
|
||||
|
||||
switch self.mode
|
||||
switch error
|
||||
{
|
||||
case .restoreVersion: title = NSLocalizedString("Failed to Restore Version", comment: "")
|
||||
case .resolveConflict: title = NSLocalizedString("Failed to Resolve Conflict", comment: "")
|
||||
case RecordError.other(let record, let error as SyncValidationError):
|
||||
// Only allow restoring corrupted records with incorrect games.
|
||||
guard case .incorrectGame = error else { fallthrough }
|
||||
|
||||
let message = NSLocalizedString("Would you like to download this version anyway? Delta will try to fix the corruption if possible.", comment: "")
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Version Corrupted", comment: ""), message: message, preferredStyle: .alert)
|
||||
alertController.addAction(.cancel)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Download Anyway", comment: ""), style: .destructive) { _ in
|
||||
SyncManager.shared.ignoredCorruptedRecordIDs.insert(record.recordID)
|
||||
self.restoreVersion()
|
||||
})
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
|
||||
default:
|
||||
let title: String
|
||||
|
||||
switch self.mode
|
||||
{
|
||||
case .restoreVersion: title = NSLocalizedString("Failed to Restore Version", comment: "")
|
||||
case .resolveConflict: title = NSLocalizedString("Failed to Resolve Conflict", comment: "")
|
||||
}
|
||||
|
||||
let alertController = UIAlertController(title: title, message: error.localizedDescription, preferredStyle: .alert)
|
||||
alertController.addAction(.ok)
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
let alertController = UIAlertController(title: title, message: error.localizedDescription, preferredStyle: .alert)
|
||||
alertController.addAction(.ok)
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
CATransaction.commit()
|
||||
@ -358,6 +390,7 @@ private extension RecordVersionsViewController
|
||||
}
|
||||
|
||||
let alertController = UIAlertController(title: nil, message: message, preferredStyle: .actionSheet)
|
||||
alertController.popoverPresentationController?.barButtonItem = sender
|
||||
alertController.addAction(.cancel)
|
||||
alertController.addAction(UIAlertAction(title: actionTitle, style: .destructive) { (action) in
|
||||
self.restoreVersion()
|
||||
|
||||
@ -32,6 +32,7 @@ extension SyncingServicesViewController
|
||||
class SyncingServicesViewController: UITableViewController
|
||||
{
|
||||
@IBOutlet private var syncingEnabledSwitch: UISwitch!
|
||||
private var activityIndicatorView: UIActivityIndicatorView!
|
||||
|
||||
private var selectedSyncingService = Settings.syncingService
|
||||
|
||||
@ -39,6 +40,12 @@ class SyncingServicesViewController: UITableViewController
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
self.activityIndicatorView = UIActivityIndicatorView(style: .medium)
|
||||
self.activityIndicatorView.hidesWhenStopped = true
|
||||
|
||||
let barButtonItem = UIBarButtonItem(customView: self.activityIndicatorView)
|
||||
self.navigationItem.rightBarButtonItem = barButtonItem
|
||||
|
||||
self.syncingEnabledSwitch.onTintColor = .deltaPurple
|
||||
self.syncingEnabledSwitch.isOn = (self.selectedSyncingService != nil)
|
||||
}
|
||||
@ -57,7 +64,9 @@ private extension SyncingServicesViewController
|
||||
if SyncManager.shared.coordinator?.account != nil
|
||||
{
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Disable Syncing?", comment: ""), message: NSLocalizedString("Enabling syncing again later may result in conflicts that must be resolved manually.", comment: ""), preferredStyle: .alert)
|
||||
alertController.addAction(.cancel)
|
||||
alertController.addAction(UIAlertAction(title: UIAlertAction.cancel.title, style: UIAlertAction.cancel.style) { (action) in
|
||||
sender.setOn(true, animated: true)
|
||||
})
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Disable", comment: ""), style: .default) { (action) in
|
||||
self.changeService(to: nil)
|
||||
})
|
||||
@ -170,7 +179,7 @@ extension SyncingServicesViewController
|
||||
|
||||
if SyncManager.shared.coordinator?.account != nil
|
||||
{
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Are you sure you want to change sync services?", comment: ""), message: NSLocalizedString("Switching back later may result in conflicts that must be resolved manually.", comment: ""), preferredStyle: .actionSheet)
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Are you sure you want to change sync services?", comment: ""), message: NSLocalizedString("Switching back later may result in conflicts that must be resolved manually.", comment: ""), preferredStyle: .alert)
|
||||
alertController.addAction(.cancel)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Change Sync Service", comment: ""), style: .destructive, handler: { (action) in
|
||||
self.changeService(to: syncingService)
|
||||
@ -188,7 +197,7 @@ extension SyncingServicesViewController
|
||||
case .authenticate:
|
||||
if SyncManager.shared.coordinator?.account != nil
|
||||
{
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Are you sure you want to sign out?", comment: ""), message: NSLocalizedString("Signing in again later may result in conflicts that must be resolved manually.", comment: ""), preferredStyle: .actionSheet)
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Are you sure you want to sign out?", comment: ""), message: NSLocalizedString("Signing in again later may result in conflicts that must be resolved manually.", comment: ""), preferredStyle: .alert)
|
||||
alertController.addAction(.cancel)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Sign Out", comment: ""), style: .destructive) { (action) in
|
||||
SyncManager.shared.deauthenticate { (result) in
|
||||
@ -213,6 +222,8 @@ extension SyncingServicesViewController
|
||||
}
|
||||
else
|
||||
{
|
||||
self.activityIndicatorView.startAnimating()
|
||||
|
||||
SyncManager.shared.authenticate(presentingViewController: self) { (result) in
|
||||
DispatchQueue.main.async {
|
||||
do
|
||||
@ -231,6 +242,8 @@ extension SyncingServicesViewController
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Failed to Sign In", comment: ""), error: error)
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
self.activityIndicatorView.stopAnimating()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ALTDeviceID</key>
|
||||
<string>00008110-000A68390A82801E</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
@ -176,20 +178,6 @@
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>Fabric</key>
|
||||
<dict>
|
||||
<key>APIKey</key>
|
||||
<string>d542629b4f6625cfd5564d27318550321272076d</string>
|
||||
<key>Kits</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>KitInfo</key>
|
||||
<dict/>
|
||||
<key>KitName</key>
|
||||
<string>Crashlytics</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>dbapi-8-emm</string>
|
||||
@ -199,6 +187,12 @@
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<false/>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_altserver._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Delta uses the local network to communicate with AltServer and enable JIT.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Delta uses your microphone to emulate the Nintendo DS microphone.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
@ -222,6 +216,17 @@
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UIWindowSceneSessionRoleExternalDisplay</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ExternalDisplayScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>External Display</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ExternalDisplaySceneDelegate</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
@ -430,13 +435,5 @@
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_altserver._tcp</string>
|
||||
</array>
|
||||
<key>ALTDeviceID</key>
|
||||
<string>00008110-000A68390A82801E</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Delta uses the local network to communicate with AltServer and enable JIT.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -95,6 +95,9 @@ final class SyncManager
|
||||
return self.coordinator?.recordController
|
||||
}
|
||||
|
||||
// Hacky, but YOLO I'm under time crunch.
|
||||
public var ignoredCorruptedRecordIDs = Set<RecordID>()
|
||||
|
||||
private(set) var syncProgress: Progress?
|
||||
|
||||
private(set) var previousSyncResult: SyncResult?
|
||||
@ -209,31 +212,7 @@ extension SyncManager
|
||||
{
|
||||
guard let coordinator = self.coordinator else { return completionHandler(.failure(AuthenticationError(Error.nilService))) }
|
||||
|
||||
coordinator.authenticate(presentingViewController: presentingViewController) { (result) in
|
||||
do
|
||||
{
|
||||
let account = try result.get()
|
||||
|
||||
if !coordinator.recordController.isSeeded
|
||||
{
|
||||
coordinator.recordController.seedFromPersistentContainer { (result) in
|
||||
switch result
|
||||
{
|
||||
case .success: completionHandler(.success(account))
|
||||
case .failure(let error): completionHandler(.failure(AuthenticationError(error)))
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
completionHandler(.success(account))
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
completionHandler(.failure(AuthenticationError(error)))
|
||||
}
|
||||
}
|
||||
coordinator.authenticate(presentingViewController: presentingViewController, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
func deauthenticate(completionHandler: @escaping (Result<Void, DeauthenticationError>) -> Void)
|
||||
@ -245,6 +224,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
|
||||
}
|
||||
|
||||
@ -173,6 +173,15 @@ private extension SyncResultViewController
|
||||
errorMessage = NSLocalizedString("The game for this item is missing. Please re-import the game to resume syncing its data.", comment: "")
|
||||
}
|
||||
|
||||
case .other(_, let error as SyncValidationError):
|
||||
var message = error.failureReason ?? error.localizedDescription
|
||||
if let recoverySuggestion = error.recoverySuggestion
|
||||
{
|
||||
message += "\n\n" + recoverySuggestion
|
||||
}
|
||||
|
||||
errorMessage = message
|
||||
|
||||
case .other(_, let error as NSError): errorMessage = error.localizedFailureReason ?? error.localizedDescription
|
||||
default: errorMessage = error.failureReason
|
||||
}
|
||||
@ -249,9 +258,7 @@ private extension SyncResultViewController
|
||||
case .gameControllerInputMapping: group = .gameControllerInputMapping
|
||||
|
||||
case .gameSave:
|
||||
guard let gameID = metadata?[.gameID] else { continue }
|
||||
|
||||
let recordID = RecordID(type: SyncManager.RecordType.game.rawValue, identifier: gameID)
|
||||
let recordID = RecordID(type: SyncManager.RecordType.game.rawValue, identifier: error.record.recordID.identifier)
|
||||
group = .game(recordID)
|
||||
|
||||
case .saveState:
|
||||
@ -387,7 +394,11 @@ extension SyncResultViewController
|
||||
|
||||
case .game:
|
||||
guard let error = section.errors.first as? RecordError else { return nil }
|
||||
return error.record.localizedName
|
||||
|
||||
let recordID = RecordID(type: SyncManager.RecordType.game.rawValue, identifier: error.record.recordID.identifier)
|
||||
|
||||
let gameName = self.gameNamesByRecordID[recordID] // In case the remote record is corrupted, rely on local names.
|
||||
return gameName ?? error.record.localizedName
|
||||
|
||||
case .saveState(let gameID):
|
||||
guard let error = section.errors.first as? RecordError else { return nil }
|
||||
|
||||
40
Delta/Syncing/SyncValidationError.swift
Normal file
40
Delta/Syncing/SyncValidationError.swift
Normal file
@ -0,0 +1,40 @@
|
||||
//
|
||||
// SyncValidationError.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 8/10/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum SyncValidationError: LocalizedError
|
||||
{
|
||||
case incorrectGame(String?)
|
||||
case incorrectGameCollection(String?)
|
||||
|
||||
var failureReason: String? {
|
||||
switch self
|
||||
{
|
||||
case .incorrectGame(let name?):
|
||||
return String(format: NSLocalizedString("The downloaded record is associated with the wrong game (%@).", comment: ""), name)
|
||||
|
||||
case .incorrectGame(nil):
|
||||
return NSLocalizedString("The downloaded record is not associated with a game.", comment: "")
|
||||
|
||||
case .incorrectGameCollection(let name?):
|
||||
return String(format: NSLocalizedString("The downloaded record is associated with the wrong game system (%@).", comment: ""), name)
|
||||
|
||||
case .incorrectGameCollection(nil):
|
||||
return NSLocalizedString("The downloaded record is not associated with a game system.", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
var recoverySuggestion: String? {
|
||||
switch self
|
||||
{
|
||||
case .incorrectGame: return NSLocalizedString("Try restoring an older version to resolve this issue.", comment: "")
|
||||
case .incorrectGameCollection: return NSLocalizedString("Try restoring an older version, or manually re-import the game to resolve this issue.", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
19
DeltaFeatures/Extensions/Collection+Optionals.swift
Normal file
19
DeltaFeatures/Extensions/Collection+Optionals.swift
Normal file
@ -0,0 +1,19 @@
|
||||
//
|
||||
// Collection+Optionals.swift
|
||||
// DeltaFeatures
|
||||
//
|
||||
// Created by Riley Testut on 4/12/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension Collection
|
||||
{
|
||||
func appendingNil() -> [Element] where Element: OptionalProtocol, Element.Wrapped: LocalizedOptionValue
|
||||
{
|
||||
var values = Array(self)
|
||||
values.append(Element.none)
|
||||
return values
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
//
|
||||
// EnvironmentValues+FeatureOption.swift
|
||||
// DeltaFeatures
|
||||
//
|
||||
// Created by Riley Testut on 4/26/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
private struct FeatureOptionKey: EnvironmentKey
|
||||
{
|
||||
static let defaultValue: any AnyOption = Option(wrappedValue: true)
|
||||
}
|
||||
|
||||
public extension EnvironmentValues
|
||||
{
|
||||
var featureOption: any AnyOption {
|
||||
get { self[FeatureOptionKey.self] }
|
||||
set { self[FeatureOptionKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
14
DeltaFeatures/Extensions/NotificationName+Settings.swift
Normal file
14
DeltaFeatures/Extensions/NotificationName+Settings.swift
Normal file
@ -0,0 +1,14 @@
|
||||
//
|
||||
// Notification+Settings.swift
|
||||
// DeltaFeatures
|
||||
//
|
||||
// Created by Riley Testut on 4/13/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension Notification.Name
|
||||
{
|
||||
static let settingsDidChange = Notification.Name("SettingsDidChangeNotification")
|
||||
}
|
||||
115
DeltaFeatures/Extensions/UserDefaults+OptionValues.swift
Normal file
115
DeltaFeatures/Extensions/UserDefaults+OptionValues.swift
Normal file
@ -0,0 +1,115 @@
|
||||
//
|
||||
// UserDefaults+OptionValues.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/7/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
private func wrap<RawType, WrapperType: RawRepresentable>(rawValue: RawType, in type: WrapperType.Type) -> WrapperType?
|
||||
{
|
||||
// Ensure rawValue is correct type.
|
||||
guard let rawValue = rawValue as? WrapperType.RawValue else { return nil }
|
||||
|
||||
let representingValue = WrapperType.init(rawValue: rawValue)
|
||||
return representingValue
|
||||
}
|
||||
|
||||
extension UserDefaults
|
||||
{
|
||||
func setOptionValue<Value: OptionValue>(_ newValue: Value, forKey key: String) throws
|
||||
{
|
||||
switch newValue
|
||||
{
|
||||
// case .none/nil does _not_ catch nil values passed in,
|
||||
// but casting to NSSecureCoding then checking if NSNull does.
|
||||
// case .none: break
|
||||
|
||||
case let secureCoding as any NSSecureCoding:
|
||||
if secureCoding is NSNull
|
||||
{
|
||||
// Removing value will make us return default value later,
|
||||
// which isn't what we want if we explicitly set nil.
|
||||
// Instead, we persist a dictionary with "isNil" key to let
|
||||
// us know we should return nil later, not the default value.
|
||||
let nilDictionary = ["isNil": true] as NSDictionary
|
||||
self.set(nilDictionary, forKey: key)
|
||||
}
|
||||
else
|
||||
{
|
||||
self.set(secureCoding, forKey: key)
|
||||
}
|
||||
|
||||
case let rawRepresentable as any RawRepresentable:
|
||||
self.set(rawRepresentable.rawValue, forKey: key)
|
||||
|
||||
case let codable as any Codable:
|
||||
let data = try PropertyListEncoder().encode(codable)
|
||||
self.set(data, forKey: key)
|
||||
|
||||
default:
|
||||
// Try anyway ¯\_(ツ)_/¯
|
||||
self.set(newValue, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns Optional<Value>. If Value is already an Optional type, this will return a *nested* Optional<Optional<Value>>.
|
||||
// If return value == nil, value does _not_ yet exist, so we should use default value.
|
||||
// If return value == .some(nil) (aka nested nil), the value _does_ exist, and it is explicitly nil.
|
||||
func optionValue<Value: OptionValue>(forKey key: String, type: Value.Type) throws -> Value?
|
||||
{
|
||||
guard let rawValue = UserDefaults.standard.object(forKey: key) else { return nil }
|
||||
|
||||
if let nilDictionary = rawValue as? [String: Bool], let isNil = nilDictionary["isNil"], let optionalType = Value.self as? any OptionalProtocol.Type, isNil
|
||||
{
|
||||
// Return nil nested inside Optional (aka .some(nil)).
|
||||
// Caller will treat it as non-nil and thus won't return default value.
|
||||
let nestedNil = optionalType.none as? Value
|
||||
return nestedNil
|
||||
}
|
||||
|
||||
if let value = rawValue as? Value
|
||||
{
|
||||
return value
|
||||
}
|
||||
else if let optionalType = Value.self as? any OptionalProtocol.Type, let rawRepresentableType = optionalType.wrappedType as? any RawRepresentable.Type
|
||||
{
|
||||
// Open `rawRepresentableType` existential as concrete type so we can initialize RawRepresentable.
|
||||
// Don't cast via as? Value yet because that may result in `.some(nil)` if Value is optional.
|
||||
guard let rawRepresentable = wrap(rawValue: rawValue, in: rawRepresentableType) else {
|
||||
// Incorrect raw type, so return nil directly without nesting to use default value.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return (potentially) nested optional.
|
||||
return rawRepresentable as? Value
|
||||
}
|
||||
else if let rawRepresentableType = Value.self as? any RawRepresentable.Type
|
||||
{
|
||||
// Open `rawRepresentableType` existential as concrete type so we can initialize RawRepresentable.
|
||||
// Don't cast via as? Value yet because that may result in `.some(nil)` if Value is optional.
|
||||
guard let rawRepresentable = wrap(rawValue: rawValue, in: rawRepresentableType) else {
|
||||
// Incorrect raw type, so return nil directly without nesting to use default value.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return (potentially) nested optional.
|
||||
return rawRepresentable as? Value
|
||||
}
|
||||
else if let codableType = Value.self as? any Codable.Type, let data = rawValue as? Data
|
||||
{
|
||||
let decodedValue = try PropertyListDecoder().decode(codableType, from: data) as? Value
|
||||
return decodedValue
|
||||
}
|
||||
else
|
||||
{
|
||||
print("[RSTLog] Unsupported option type:", Value.self)
|
||||
|
||||
// Return nil directly, no nesting.
|
||||
// Caller will treat this as nil and will return the default value instead.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
95
DeltaFeatures/Feature.swift
Normal file
95
DeltaFeatures/Feature.swift
Normal file
@ -0,0 +1,95 @@
|
||||
//
|
||||
// Feature.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/5/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
public struct EmptyOptions
|
||||
{
|
||||
public init() {}
|
||||
}
|
||||
|
||||
@propertyWrapper @dynamicMemberLookup
|
||||
public final class Feature<Options>: _AnyFeature
|
||||
{
|
||||
public let name: LocalizedStringKey
|
||||
public let description: LocalizedStringKey?
|
||||
|
||||
// Assigned to property name.
|
||||
public internal(set) var key: String = ""
|
||||
|
||||
// Used for `SettingsUserInfoKey.name` value in .settingsDidChange notification.
|
||||
public var settingsKey: SettingsName {
|
||||
return SettingsName(rawValue: self.key)
|
||||
}
|
||||
|
||||
public var isEnabled: Bool {
|
||||
get {
|
||||
let isEnabled = UserDefaults.standard.bool(forKey: self.key)
|
||||
return isEnabled
|
||||
}
|
||||
set {
|
||||
self.objectWillChange.send()
|
||||
UserDefaults.standard.set(newValue, forKey: self.key)
|
||||
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: nil, userInfo: [SettingsUserInfoKey.name: self.settingsKey, SettingsUserInfoKey.value: newValue])
|
||||
}
|
||||
}
|
||||
|
||||
public var wrappedValue: some Feature {
|
||||
return self
|
||||
}
|
||||
|
||||
private var options: Options
|
||||
|
||||
public init(name: LocalizedStringKey, description: LocalizedStringKey? = nil, options: Options = EmptyOptions())
|
||||
{
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.options = options
|
||||
|
||||
self.prepareOptions()
|
||||
}
|
||||
|
||||
// Use `KeyPath` instead of `WritableKeyPath` as parameter to allow accessing projected property wrappers.
|
||||
public subscript<T>(dynamicMember keyPath: KeyPath<Options, T>) -> T {
|
||||
get {
|
||||
options[keyPath: keyPath]
|
||||
}
|
||||
set {
|
||||
guard let writableKeyPath = keyPath as? WritableKeyPath<Options, T> else { return }
|
||||
options[keyPath: writableKeyPath] = newValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public extension Feature
|
||||
{
|
||||
var allOptions: [any AnyOption] {
|
||||
let features = Mirror(reflecting: self.options).children.compactMap { (child) -> (any AnyOption)? in
|
||||
let feature = child.value as? (any AnyOption)
|
||||
return feature
|
||||
}
|
||||
return features
|
||||
}
|
||||
}
|
||||
|
||||
private extension Feature
|
||||
{
|
||||
func prepareOptions()
|
||||
{
|
||||
// Update option keys + feature
|
||||
for case (let key?, let option as any _AnyOption) in Mirror(reflecting: self.options).children
|
||||
{
|
||||
// Remove leading underscore.
|
||||
let sanitizedKey = key.dropFirst()
|
||||
option.key = String(sanitizedKey)
|
||||
option.feature = self
|
||||
}
|
||||
}
|
||||
}
|
||||
219
DeltaFeatures/Option.swift
Normal file
219
DeltaFeatures/Option.swift
Normal file
@ -0,0 +1,219 @@
|
||||
//
|
||||
// Option.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/7/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
@propertyWrapper
|
||||
public class Option<Value: OptionValue, DetailView: View>: _AnyOption
|
||||
{
|
||||
// Nil name == hidden option.
|
||||
public let name: LocalizedStringKey?
|
||||
public let description: LocalizedStringKey?
|
||||
|
||||
public let values: (() -> [Value])?
|
||||
public private(set) var detailView: () -> DetailView? = { nil }
|
||||
|
||||
// Assigned to property name.
|
||||
public internal(set) var key: String = ""
|
||||
|
||||
// Used for `SettingsUserInfoKey.name` value in .settingsDidChange notification.
|
||||
public var settingsKey: SettingsName {
|
||||
guard let feature = self.feature else { return SettingsName(rawValue: self.key) }
|
||||
|
||||
let defaultsKey = feature.key + "_" + self.key
|
||||
return SettingsName(rawValue: defaultsKey)
|
||||
}
|
||||
|
||||
internal weak var feature: (any AnyFeature)?
|
||||
|
||||
private let defaultValue: Value
|
||||
|
||||
private var valueBinding: Binding<Value> {
|
||||
Binding(get: {
|
||||
self.wrappedValue
|
||||
}, set: { newValue in
|
||||
self.wrappedValue = newValue
|
||||
})
|
||||
}
|
||||
|
||||
/// @propertyWrapper
|
||||
public var projectedValue: some Option {
|
||||
return self
|
||||
}
|
||||
|
||||
public var wrappedValue: Value {
|
||||
get {
|
||||
do {
|
||||
let wrappedValue = try UserDefaults.standard.optionValue(forKey: self.settingsKey.rawValue, type: Value.self)
|
||||
return wrappedValue ?? self.defaultValue
|
||||
}
|
||||
catch {
|
||||
print("[ALTLog] Failed to read option value for key \(self.settingsKey.rawValue).", error)
|
||||
return self.defaultValue
|
||||
}
|
||||
}
|
||||
set {
|
||||
do {
|
||||
try UserDefaults.standard.setOptionValue(newValue, forKey: self.settingsKey.rawValue)
|
||||
|
||||
Task { @MainActor in
|
||||
// Delay to avoid "Publishing changes from within view updates is not allowed" runtime warning.
|
||||
(self.feature?.objectWillChange as? ObservableObjectPublisher)?.send()
|
||||
|
||||
// Delay to avoid potential simultaneous memory access runtime error.
|
||||
NotificationCenter.default.post(name: .settingsDidChange, object: nil, userInfo: [SettingsUserInfoKey.name: self.settingsKey, SettingsUserInfoKey.value: newValue])
|
||||
}
|
||||
}
|
||||
catch {
|
||||
print("[ALTLog] Failed to set option value for key \(self.settingsKey.rawValue).", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private init(defaultValue: Value, name: LocalizedStringKey?, description: LocalizedStringKey?, values: (() -> some Collection<Value>)?)
|
||||
{
|
||||
self.defaultValue = defaultValue
|
||||
|
||||
self.name = name
|
||||
self.description = description
|
||||
|
||||
if let values
|
||||
{
|
||||
self.values = { Array(values()) }
|
||||
}
|
||||
else
|
||||
{
|
||||
self.values = nil
|
||||
}
|
||||
|
||||
self.detailView = { nil }
|
||||
}
|
||||
|
||||
private convenience init(defaultValue: Value, name: LocalizedStringKey?, description: LocalizedStringKey?)
|
||||
{
|
||||
self.init(defaultValue: defaultValue, name: name, description: description, values: (() -> [Value])?.none)
|
||||
}
|
||||
}
|
||||
|
||||
// "Hidden" Option (no name, pre-set values, or custom SwiftUI view)
|
||||
public extension Option where DetailView == EmptyView
|
||||
{
|
||||
// Non-Optional
|
||||
convenience init(wrappedValue: Value)
|
||||
{
|
||||
self.init(defaultValue: wrappedValue, name: nil, description: nil)
|
||||
}
|
||||
|
||||
// Optional, default = nil
|
||||
convenience init() where Value: OptionalProtocol
|
||||
{
|
||||
self.init(defaultValue: Value.none, name: nil, description: nil)
|
||||
}
|
||||
|
||||
// Optional, default = non-nil
|
||||
convenience init(wrappedValue: Value) where Value: OptionalProtocol
|
||||
{
|
||||
self.init(defaultValue: wrappedValue, name: nil, description: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// "Toggle" Option (User-visible, Bool option with default toggle UI)
|
||||
public extension Option where Value == Bool, DetailView == OptionToggleView
|
||||
{
|
||||
// Non-Optional
|
||||
convenience init(wrappedValue: Value, name: LocalizedStringKey, description: LocalizedStringKey? = nil)
|
||||
{
|
||||
self.init(defaultValue: wrappedValue, name: name, description: description)
|
||||
|
||||
self.detailView = { [weak self] () -> DetailView? in
|
||||
guard let self else { return nil }
|
||||
return OptionToggleView(name: name, selectedValue: self.valueBinding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "Picker" Option (User-visible, pre-set options with default picker UI)
|
||||
public extension Option where Value: LocalizedOptionValue, DetailView == OptionPickerView<Value>
|
||||
{
|
||||
// Non-Optional
|
||||
convenience init(wrappedValue: Value, name: LocalizedStringKey, description: LocalizedStringKey? = nil, values: @autoclosure @escaping () -> some Collection<Value>)
|
||||
{
|
||||
self.init(defaultValue: wrappedValue, name: name, description: description, values: values)
|
||||
|
||||
self.detailView = { [weak self] () -> DetailView? in
|
||||
guard let self else { return nil }
|
||||
return OptionPickerView(name: name, options: Array(values()), selectedValue: self.valueBinding)
|
||||
}
|
||||
}
|
||||
|
||||
// Optional, default = nil
|
||||
convenience init(name: LocalizedStringKey, description: LocalizedStringKey? = nil, values: @autoclosure @escaping () -> some Collection<Value>) where Value: OptionalProtocol, Value.Wrapped: LocalizedOptionValue
|
||||
{
|
||||
self.init(defaultValue: Value.none, name: name, description: description, values: values)
|
||||
|
||||
self.detailView = { [weak self] () -> DetailView? in
|
||||
guard let self else { return nil }
|
||||
return OptionPickerView(name: name, options: values().appendingNil(), selectedValue: self.valueBinding)
|
||||
}
|
||||
}
|
||||
|
||||
// Optional, default = non-nil
|
||||
convenience init(wrappedValue: Value, name: LocalizedStringKey, description: LocalizedStringKey? = nil, values: @autoclosure @escaping () -> some Collection<Value>) where Value: OptionalProtocol, Value.Wrapped: LocalizedOptionValue
|
||||
{
|
||||
self.init(defaultValue: wrappedValue, name: name, description: description, values: values)
|
||||
|
||||
self.detailView = { [weak self] () -> DetailView? in
|
||||
guard let self else { return nil }
|
||||
return OptionPickerView(name: name, options: values().appendingNil(), selectedValue: self.valueBinding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "Custom" Option (User-visible, provides SwiftUI view to configure option)
|
||||
public extension Option where Value: LocalizedOptionValue
|
||||
{
|
||||
// Non-Optional
|
||||
convenience init(wrappedValue: Value, name: LocalizedStringKey, description: LocalizedStringKey? = nil, @ViewBuilder detailView: @escaping (Binding<Value>) -> DetailView)
|
||||
{
|
||||
self.init(defaultValue: wrappedValue, name: name, description: description)
|
||||
|
||||
self.detailView = { [weak self] in
|
||||
guard let self else { return nil }
|
||||
|
||||
let view = detailView(self.valueBinding)
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
// Optional, default = nil
|
||||
convenience init(name: LocalizedStringKey, description: LocalizedStringKey? = nil, @ViewBuilder detailView: @escaping (Binding<Value>) -> DetailView) where Value: OptionalProtocol, Value.Wrapped: LocalizedOptionValue
|
||||
{
|
||||
self.init(defaultValue: Value.none, name: name, description: description)
|
||||
|
||||
self.detailView = { [weak self] in
|
||||
guard let self else { return nil }
|
||||
|
||||
let view = detailView(self.valueBinding)
|
||||
return view
|
||||
}
|
||||
}
|
||||
|
||||
// Optional, default = non-nil
|
||||
convenience init(wrappedValue: Value, name: LocalizedStringKey, description: LocalizedStringKey? = nil, @ViewBuilder detailView: @escaping (Binding<Value>) -> DetailView) where Value: OptionalProtocol, Value.Wrapped: LocalizedOptionValue
|
||||
{
|
||||
self.init(defaultValue: wrappedValue, name: name, description: description)
|
||||
|
||||
self.detailView = { [weak self] in
|
||||
guard let self else { return nil }
|
||||
|
||||
let view = detailView(self.valueBinding)
|
||||
return view
|
||||
}
|
||||
}
|
||||
}
|
||||
38
DeltaFeatures/Protocols/AnyFeature.swift
Normal file
38
DeltaFeatures/Protocols/AnyFeature.swift
Normal file
@ -0,0 +1,38 @@
|
||||
//
|
||||
// AnyFeature.swift
|
||||
// DeltaFeatures
|
||||
//
|
||||
// Created by Riley Testut on 4/12/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@dynamicMemberLookup
|
||||
public protocol AnyFeature<Options>: ObservableObject, Identifiable
|
||||
{
|
||||
associatedtype Options = EmptyOptions
|
||||
|
||||
var name: LocalizedStringKey { get }
|
||||
var description: LocalizedStringKey? { get }
|
||||
|
||||
var key: String { get }
|
||||
var settingsKey: SettingsName { get }
|
||||
|
||||
var isEnabled: Bool { get set }
|
||||
|
||||
var allOptions: [any AnyOption] { get }
|
||||
|
||||
subscript<T>(dynamicMember keyPath: KeyPath<Options, T>) -> T { get set }
|
||||
}
|
||||
|
||||
extension AnyFeature
|
||||
{
|
||||
public var id: String { self.key }
|
||||
}
|
||||
|
||||
// Don't expose `key` setter via AnyFeature protocol.
|
||||
internal protocol _AnyFeature: AnyFeature
|
||||
{
|
||||
var key: String { get set }
|
||||
}
|
||||
48
DeltaFeatures/Protocols/AnyOption.swift
Normal file
48
DeltaFeatures/Protocols/AnyOption.swift
Normal file
@ -0,0 +1,48 @@
|
||||
//
|
||||
// AnyOption.swift
|
||||
// DeltaFeatures
|
||||
//
|
||||
// Created by Riley Testut on 4/12/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public protocol AnyOption<Value>: AnyObject, Identifiable
|
||||
{
|
||||
associatedtype Value: OptionValue
|
||||
associatedtype DetailView: View
|
||||
|
||||
var name: LocalizedStringKey? { get }
|
||||
var description: LocalizedStringKey? { get }
|
||||
|
||||
var key: String { get }
|
||||
var settingsKey: SettingsName { get }
|
||||
|
||||
var values: (() -> [Value])? { get }
|
||||
var detailView: () -> DetailView? { get }
|
||||
|
||||
var value: Value { get set }
|
||||
}
|
||||
|
||||
extension AnyOption
|
||||
{
|
||||
public var id: String { self.key }
|
||||
}
|
||||
|
||||
// Don't expose `feature` or `key` setters via AnyOption protocol.
|
||||
internal protocol _AnyOption: AnyOption
|
||||
{
|
||||
var key: String { get set }
|
||||
var feature: (any AnyFeature)? { get set }
|
||||
|
||||
var wrappedValue: Value { get set }
|
||||
}
|
||||
|
||||
extension _AnyOption
|
||||
{
|
||||
public var value: Value {
|
||||
get { self.wrappedValue }
|
||||
set { self.wrappedValue = newValue }
|
||||
}
|
||||
}
|
||||
36
DeltaFeatures/Protocols/FeatureContainer.swift
Normal file
36
DeltaFeatures/Protocols/FeatureContainer.swift
Normal file
@ -0,0 +1,36 @@
|
||||
//
|
||||
// FeatureContainer.swift
|
||||
// DeltaFeatures
|
||||
//
|
||||
// Created by Riley Testut on 4/11/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol FeatureContainer
|
||||
{
|
||||
static var shared: Self { get }
|
||||
}
|
||||
|
||||
public extension FeatureContainer
|
||||
{
|
||||
var allFeatures: [any AnyFeature] {
|
||||
let features = Mirror(reflecting: self).children.compactMap { (child) -> (any AnyFeature)? in
|
||||
let feature = child.value as? any AnyFeature
|
||||
return feature
|
||||
}
|
||||
return features
|
||||
}
|
||||
|
||||
func prepareFeatures()
|
||||
{
|
||||
// Assign keys to property names.
|
||||
for case (let key?, let feature as any _AnyFeature) in Mirror(reflecting: self).children
|
||||
{
|
||||
// Remove leading underscore.
|
||||
let sanitizedKey = key.dropFirst()
|
||||
feature.key = String(sanitizedKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
DeltaFeatures/Protocols/OptionalProtocol.swift
Normal file
24
DeltaFeatures/Protocols/OptionalProtocol.swift
Normal file
@ -0,0 +1,24 @@
|
||||
//
|
||||
// OptionalProtocol.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/11/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Public so we can use as generic constraint.
|
||||
public protocol OptionalProtocol
|
||||
{
|
||||
associatedtype Wrapped
|
||||
|
||||
static var none: Self { get }
|
||||
|
||||
static var wrappedType: Wrapped.Type { get }
|
||||
}
|
||||
|
||||
extension Optional: OptionalProtocol
|
||||
{
|
||||
public static var wrappedType: Wrapped.Type { return Wrapped.self }
|
||||
}
|
||||
27
DeltaFeatures/Types/DisplayInlineKey.swift
Normal file
27
DeltaFeatures/Types/DisplayInlineKey.swift
Normal file
@ -0,0 +1,27 @@
|
||||
//
|
||||
// DisplayInlineKey.swift
|
||||
// DeltaFeatures
|
||||
//
|
||||
// Created by Riley Testut on 4/11/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public struct DisplayInlineKey: PreferenceKey
|
||||
{
|
||||
public static var defaultValue: Bool = false
|
||||
|
||||
public static func reduce(value: inout Bool, nextValue: () -> Bool)
|
||||
{
|
||||
value = nextValue()
|
||||
}
|
||||
}
|
||||
|
||||
public extension View
|
||||
{
|
||||
func displayInline(_ value: Bool = true) -> some View
|
||||
{
|
||||
self.preference(key: DisplayInlineKey.self, value: value)
|
||||
}
|
||||
}
|
||||
60
DeltaFeatures/Types/LocalizedOptionValue.swift
Normal file
60
DeltaFeatures/Types/LocalizedOptionValue.swift
Normal file
@ -0,0 +1,60 @@
|
||||
//
|
||||
// LocalizedOptionValue.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/11/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
public protocol LocalizedOptionValue: OptionValue, Hashable /* Identifiable */ // We don't want to implicitly conform types we don't own to `Identifiable`.
|
||||
{
|
||||
static var localizedNilDescription: Text { get }
|
||||
|
||||
var localizedDescription: Text { get }
|
||||
}
|
||||
|
||||
public extension LocalizedOptionValue
|
||||
{
|
||||
static var localizedNilDescription: Text {
|
||||
Text("None")
|
||||
}
|
||||
}
|
||||
|
||||
// Provide `localizedDescription` implementation for standard library types.
|
||||
public extension LocalizedOptionValue where Self: CustomStringConvertible
|
||||
{
|
||||
var localizedDescription: Text {
|
||||
Text(String(describing: self))
|
||||
}
|
||||
}
|
||||
|
||||
extension Int: LocalizedOptionValue {}
|
||||
extension Int8: LocalizedOptionValue {}
|
||||
extension Int16: LocalizedOptionValue {}
|
||||
extension Int32: LocalizedOptionValue {}
|
||||
extension Int64: LocalizedOptionValue {}
|
||||
|
||||
extension UInt: LocalizedOptionValue {}
|
||||
extension UInt8: LocalizedOptionValue {}
|
||||
extension UInt16: LocalizedOptionValue {}
|
||||
extension UInt32: LocalizedOptionValue {}
|
||||
extension UInt64: LocalizedOptionValue {}
|
||||
|
||||
extension Float: LocalizedOptionValue {}
|
||||
extension Double: LocalizedOptionValue {}
|
||||
|
||||
extension String: LocalizedOptionValue {}
|
||||
extension Bool: LocalizedOptionValue {}
|
||||
|
||||
extension Optional: LocalizedOptionValue where Wrapped: LocalizedOptionValue
|
||||
{
|
||||
public var localizedDescription: Text {
|
||||
switch self
|
||||
{
|
||||
case .none: return Wrapped.localizedNilDescription
|
||||
case .some(let value): return value.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
21
DeltaFeatures/Types/OptionValue.swift
Normal file
21
DeltaFeatures/Types/OptionValue.swift
Normal file
@ -0,0 +1,21 @@
|
||||
//
|
||||
// OptionValue.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/11/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol OptionValue
|
||||
{
|
||||
}
|
||||
|
||||
extension Data: OptionValue {}
|
||||
|
||||
extension Optional: OptionValue where Wrapped: OptionValue {}
|
||||
|
||||
extension Array: OptionValue where Element: OptionValue {}
|
||||
extension Set: OptionValue where Element: OptionValue {}
|
||||
extension Dictionary: OptionValue where Key: OptionValue, Value: OptionValue {}
|
||||
24
DeltaFeatures/Types/SettingsName.swift
Normal file
24
DeltaFeatures/Types/SettingsName.swift
Normal file
@ -0,0 +1,24 @@
|
||||
//
|
||||
// SettingsTypes.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/5/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct SettingsName: RawRepresentable, Hashable, ExpressibleByStringLiteral
|
||||
{
|
||||
public let rawValue: String
|
||||
|
||||
public init(rawValue: String)
|
||||
{
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
public init(stringLiteral rawValue: String)
|
||||
{
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
}
|
||||
30
DeltaFeatures/Types/SettingsUserInfoKey.swift
Normal file
30
DeltaFeatures/Types/SettingsUserInfoKey.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// SettingsUserInfoKey.swift
|
||||
// DeltaFeatures
|
||||
//
|
||||
// Created by Riley Testut on 4/13/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public extension SettingsUserInfoKey
|
||||
{
|
||||
static let name: SettingsUserInfoKey = "name"
|
||||
static let value: SettingsUserInfoKey = "value"
|
||||
}
|
||||
|
||||
public struct SettingsUserInfoKey: RawRepresentable, Hashable, ExpressibleByStringLiteral
|
||||
{
|
||||
public let rawValue: String
|
||||
|
||||
public init(rawValue: String)
|
||||
{
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
public init(stringLiteral rawValue: String)
|
||||
{
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
}
|
||||
29
DeltaFeatures/Views/OptionPickerView.swift
Normal file
29
DeltaFeatures/Views/OptionPickerView.swift
Normal file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// OptionPickerView.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/10/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// Type must be public, but not its properties.
|
||||
public struct OptionPickerView<Value: LocalizedOptionValue>: View
|
||||
{
|
||||
var name: LocalizedStringKey
|
||||
var options: [Value]
|
||||
|
||||
@Binding
|
||||
var selectedValue: Value
|
||||
|
||||
public var body: some View {
|
||||
Picker(name, selection: $selectedValue) {
|
||||
ForEach(options, id: \.self) { value in
|
||||
value.localizedDescription
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.displayInline()
|
||||
}
|
||||
}
|
||||
23
DeltaFeatures/Views/OptionToggleView.swift
Normal file
23
DeltaFeatures/Views/OptionToggleView.swift
Normal file
@ -0,0 +1,23 @@
|
||||
//
|
||||
// OptionToggleView.swift
|
||||
// DeltaFeatures
|
||||
//
|
||||
// Created by Riley Testut on 4/11/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// Type must be public, but not its properties.
|
||||
public struct OptionToggleView: View
|
||||
{
|
||||
var name: LocalizedStringKey
|
||||
|
||||
@Binding
|
||||
var selectedValue: Bool
|
||||
|
||||
public var body: some View {
|
||||
Toggle(name, isOn: $selectedValue)
|
||||
.displayInline()
|
||||
}
|
||||
}
|
||||
21
DeltaPreviews/ContentView.swift
Normal file
21
DeltaPreviews/ContentView.swift
Normal file
@ -0,0 +1,21 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// DeltaPreviews
|
||||
//
|
||||
// Created by Riley Testut on 4/13/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
Text("Hello, World!")
|
||||
}
|
||||
}
|
||||
|
||||
struct ContentView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
19
DeltaPreviews/DeltaPreviews.h
Normal file
19
DeltaPreviews/DeltaPreviews.h
Normal file
@ -0,0 +1,19 @@
|
||||
//
|
||||
// DeltaPreviews.h
|
||||
// DeltaPreviews
|
||||
//
|
||||
// Created by Riley Testut on 4/13/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
//! Project version number for DeltaPreviews.
|
||||
FOUNDATION_EXPORT double DeltaPreviewsVersionNumber;
|
||||
|
||||
//! Project version string for DeltaPreviews.
|
||||
FOUNDATION_EXPORT const unsigned char DeltaPreviewsVersionString[];
|
||||
|
||||
// In this header, you should import all the public headers of your framework using statements like #import <DeltaPreviews/PublicHeader.h>
|
||||
|
||||
|
||||
79
DeltaPreviews/Experimental Features/CustomTintColor.swift
Normal file
79
DeltaPreviews/Experimental Features/CustomTintColor.swift
Normal file
@ -0,0 +1,79 @@
|
||||
//
|
||||
// CustomTintColor.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/6/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import DeltaFeatures
|
||||
|
||||
enum TintColor: String, CaseIterable, Identifiable
|
||||
{
|
||||
case red
|
||||
case orange
|
||||
case yellow
|
||||
case green
|
||||
case blue
|
||||
case purple
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var color: Color {
|
||||
switch self
|
||||
{
|
||||
case .red: return .red
|
||||
case .orange: return .orange
|
||||
case .yellow: return .yellow
|
||||
case .green: return .green
|
||||
case .blue: return .blue
|
||||
case .purple: return .purple
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TintColor: LocalizedOptionValue
|
||||
{
|
||||
var localizedDescription: Text {
|
||||
switch self
|
||||
{
|
||||
case .red: return Text("Red")
|
||||
case .orange: return Text("Orange")
|
||||
case .yellow: return Text("Yellow")
|
||||
case .green: return Text("Green")
|
||||
case .blue: return Text("Blue")
|
||||
case .purple: return Text("Purple")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CustomTintColorOptions
|
||||
{
|
||||
@Option(name: "Tint Color", detailView: { CustomTintColorView(tintColor: $0) })
|
||||
var value: TintColor = .purple
|
||||
|
||||
@Option(name: "Respect Dark Mode")
|
||||
var respectDarkMode: Bool = true
|
||||
}
|
||||
|
||||
private struct CustomTintColorView: View
|
||||
{
|
||||
@Binding
|
||||
var tintColor: TintColor
|
||||
|
||||
var body: some View {
|
||||
Picker("Tint Color", selection: $tintColor.animation()) {
|
||||
ForEach(TintColor.allCases) { tintColor in
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(tintColor.color)
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
tintColor.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
.pickerStyle(.inline)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
//
|
||||
// ExperimentalFeatures.swift
|
||||
// DeltaPreviews
|
||||
//
|
||||
// Created by Riley Testut on 4/17/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import DeltaFeatures
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ExperimentalFeatures: FeatureContainer
|
||||
{
|
||||
static let shared = ExperimentalFeatures()
|
||||
|
||||
@Feature(name: "Random Dancing")
|
||||
var randomDancing
|
||||
|
||||
@Feature(name: "Custom Tint Color",
|
||||
description: "Change the accent color used throughout the app.",
|
||||
options: CustomTintColorOptions())
|
||||
var customTintColor
|
||||
|
||||
private init()
|
||||
{
|
||||
self.prepareFeatures()
|
||||
}
|
||||
}
|
||||
|
||||
struct ExperimentalFeatures_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
NavigationView {
|
||||
ExperimentalFeaturesView()
|
||||
}
|
||||
}
|
||||
}
|
||||
2
External/Harmony
vendored
2
External/Harmony
vendored
@ -1 +1 @@
|
||||
Subproject commit 7234d6626a49e56ddceaaec0c04cc4f4f43b572c
|
||||
Subproject commit d348fc7440198fd91183d2236e3816dee8cc24ee
|
||||
2
External/Roxas
vendored
2
External/Roxas
vendored
@ -1 +1 @@
|
||||
Subproject commit 2bb3182495f680ce60da8e72c3d84a7d4451ef75
|
||||
Subproject commit d07c467b6f65cbd08ba296d630986efafb830f95
|
||||
11
Podfile
11
Podfile
@ -1,4 +1,4 @@
|
||||
platform :ios, '12.0'
|
||||
platform :ios, '14.0'
|
||||
|
||||
inhibit_all_warnings!
|
||||
|
||||
@ -7,8 +7,6 @@ target 'Delta' do
|
||||
|
||||
pod 'SQLite.swift', '~> 0.12.0'
|
||||
pod 'SDWebImage', '~> 3.8'
|
||||
pod 'Fabric', '~> 1.6.0'
|
||||
pod 'Crashlytics', '~> 3.8.0'
|
||||
pod 'SMCalloutView', '~> 2.1.0'
|
||||
|
||||
pod 'DeltaCore', :path => 'Cores/DeltaCore'
|
||||
@ -23,6 +21,13 @@ target 'Delta' do
|
||||
pod 'Harmony', :path => 'External/Harmony'
|
||||
end
|
||||
|
||||
target 'DeltaPreviews' do
|
||||
use_modular_headers!
|
||||
|
||||
pod 'DeltaCore', :path => 'Cores/DeltaCore'
|
||||
pod 'Roxas', :path => 'External/Roxas'
|
||||
end
|
||||
|
||||
# Unlink DeltaCore to prevent conflicts with Systems.framework
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user