Compare commits
175 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7399e8cd5c | ||
|
|
8161aa6d8c | ||
|
|
18c51d8d1b | ||
|
|
af01b97faf | ||
|
|
62ebf05a8f | ||
|
|
452e3dab06 | ||
|
|
43fedf6fcc | ||
|
|
9ab8cf29b6 | ||
|
|
e6cd5475e9 | ||
|
|
9c31b8a864 | ||
|
|
5c7e3cc5b9 | ||
|
|
d5f910ff00 | ||
|
|
d1643dbc8f | ||
|
|
dc3a5b479c | ||
|
|
b3d8dbc554 | ||
|
|
f3534e4415 | ||
|
|
061f5abd3e | ||
|
|
be047b28a6 | ||
|
|
fcdd3c7840 | ||
|
|
ca8c2cb8c5 | ||
|
|
a9f15144ed | ||
|
|
a80ac04650 | ||
|
|
25e237cfcb | ||
|
|
3227ee4c49 | ||
|
|
45ed97c255 | ||
|
|
31578e2e34 | ||
|
|
e33a7c662f | ||
|
|
8ea40a4728 | ||
|
|
29f152fcb3 | ||
|
|
5779927831 | ||
|
|
f184639c6b | ||
|
|
043fb923ae | ||
|
|
19fb333a67 | ||
|
|
35a8f90a1c | ||
|
|
c898f72847 | ||
|
|
981c868f6e | ||
|
|
15e228f287 | ||
|
|
707116a39b | ||
|
|
750740ac16 | ||
|
|
6909b6248f | ||
|
|
8bd6fe1e11 | ||
|
|
4cf705f141 | ||
|
|
99417b418a | ||
|
|
648e9b8393 | ||
|
|
69eff8fa28 | ||
|
|
3e858c652f | ||
|
|
21147969ea | ||
|
|
731de7023f | ||
|
|
810bc4572c | ||
|
|
4e8580b8f5 | ||
|
|
2e21141bc6 | ||
|
|
08a40b3516 | ||
|
|
7b9ab2488e | ||
|
|
00121bd31f | ||
|
|
ea260cb8a6 | ||
|
|
b0bd5ba906 | ||
|
|
726c4ab93b | ||
|
|
5dc91c87c6 | ||
|
|
de7a812cbd | ||
|
|
b1a3a5076f | ||
|
|
9470caf83a | ||
|
|
bdee5d17a5 | ||
|
|
85d53162c1 | ||
|
|
75814ca04d | ||
|
|
2ead48ad40 | ||
|
|
6fd7f9e1d5 | ||
|
|
7fceccc114 | ||
|
|
6bdc05f640 | ||
|
|
05e94902b8 | ||
|
|
233ef7d418 | ||
|
|
5cef975a9e | ||
|
|
39522fda58 | ||
|
|
1137189b57 | ||
|
|
32e7c1f93e | ||
|
|
7ad5df1949 | ||
|
|
77b26210ab | ||
|
|
20749c5419 | ||
|
|
14adb41ff8 | ||
|
|
8efefd19a0 | ||
|
|
bd0c72e847 | ||
|
|
5b4f9ea593 | ||
|
|
ef47d78c64 | ||
|
|
9f40223e6c | ||
|
|
6d95924145 | ||
|
|
240b74de94 | ||
|
|
4d30ef2929 | ||
|
|
415450a943 | ||
|
|
80a9132ff5 | ||
|
|
d613cc9ad7 | ||
|
|
3fb7e8b4b7 | ||
|
|
9406cfe6cc | ||
|
|
c3af9f7209 | ||
|
|
af0d239de1 | ||
|
|
ff65b15277 | ||
|
|
91d40cbeea | ||
|
|
21f628fd1e | ||
|
|
f5b124b175 | ||
|
|
10af836105 | ||
|
|
5a019e5950 | ||
|
|
c8860c6aaa | ||
|
|
fb0975f0d6 | ||
|
|
dd314a12af | ||
|
|
68ad2185dd | ||
|
|
66e5258368 | ||
|
|
fcc19ae830 | ||
|
|
6a683be907 | ||
|
|
d79002ea6b | ||
|
|
913cb788a2 | ||
|
|
45665138b2 | ||
|
|
d1c45c9ad0 | ||
|
|
d31229001f | ||
|
|
c3a83d7542 | ||
|
|
5bc2f08084 | ||
|
|
041cce64b0 | ||
|
|
74ccf1a246 | ||
|
|
a135ea236d | ||
|
|
d204ea35bd | ||
|
|
77983e73dd | ||
|
|
6aee8c84cd | ||
|
|
45757878d8 | ||
|
|
8ccd86de0f | ||
|
|
ceb66d46be | ||
|
|
0f2792fdbc | ||
|
|
eeae27cc24 | ||
|
|
7677421ef4 | ||
|
|
d17a1f3d8f | ||
|
|
7d93470738 | ||
|
|
a2b6771715 | ||
|
|
aa7ef0041f | ||
|
|
ee536f9ce5 | ||
|
|
b2ab33bcd1 | ||
|
|
48be35cbf2 | ||
|
|
fd2cf223dc | ||
|
|
8155bc5ca8 | ||
|
|
cd6de86fb1 | ||
|
|
b2568efeb1 | ||
|
|
5805b859f5 | ||
|
|
d061f56951 | ||
|
|
c3e9cfe526 | ||
|
|
3cf87afa2d | ||
|
|
ea871c7520 | ||
|
|
e1ee540d27 | ||
|
|
bb812c7f02 | ||
|
|
6ba648ed17 | ||
|
|
05a66a140e | ||
|
|
52a68e28dd | ||
|
|
4829b393c5 | ||
|
|
b11766c973 | ||
|
|
c09bfead65 | ||
|
|
7b1db2614f | ||
|
|
973238e1a4 | ||
|
|
d4e22942b8 | ||
|
|
7c934cebe1 | ||
|
|
b134c73301 | ||
|
|
fce4fc1bec | ||
|
|
977f3d8005 | ||
|
|
edab6ea432 | ||
|
|
9e437797d9 | ||
|
|
25afda3b60 | ||
|
|
836297718b | ||
|
|
da8415f4aa | ||
|
|
68c1b05313 | ||
|
|
2a4dbabae5 | ||
|
|
aafe673811 | ||
|
|
adccf8fca5 | ||
|
|
d333672b95 | ||
|
|
0df313188d | ||
|
|
fd0427d2ad | ||
|
|
2705849cf2 | ||
|
|
79bf977904 | ||
|
|
11041ef1e9 | ||
|
|
08e870c94c | ||
|
|
9492f3165e | ||
|
|
f14bb0f890 | ||
|
|
829d127269 |
3
.gitmodules
vendored
3
.gitmodules
vendored
@ -31,3 +31,6 @@
|
||||
[submodule "Cores/GPGXDeltaCore"]
|
||||
path = Cores/GPGXDeltaCore
|
||||
url = https://github.com/rileytestut/GPGXDeltaCore.git
|
||||
[submodule "External/CheatBase"]
|
||||
path = External/CheatBase
|
||||
url = https://github.com/rileytestut/CheatBase.git
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 1054482de95f60de78c48b01af3c04fb4eca9168
|
||||
Subproject commit 6c84366b3a76045782905293c9616e33f5da1a35
|
||||
@ -1 +1 @@
|
||||
Subproject commit 2a6779e1271bc5d2e09aea2aa41fa6a0b75b62aa
|
||||
Subproject commit c1db5f51cd455a7033801cc19dc3dbfcb6f2b42c
|
||||
@ -1 +1 @@
|
||||
Subproject commit 8523e03358559cebaa36b67bd0a87698df238512
|
||||
Subproject commit 8ea36dff87bc1f787765de45fa8ccbcc1256a0e3
|
||||
@ -1 +1 @@
|
||||
Subproject commit 4313fa6670ab534e70d13532c2504761f849c432
|
||||
Subproject commit 81f8ffba56823637706689fb5c6bc634ee4d9b32
|
||||
@ -1 +1 @@
|
||||
Subproject commit ad4ba365acf1800d372ebfaa98df86b9c1b23dce
|
||||
Subproject commit 18c595887a12ef23e0d54c63f83c91c99e7f4827
|
||||
@ -1 +1 @@
|
||||
Subproject commit d6b33d89043898d521546d9062d12a505b7d2101
|
||||
Subproject commit 697ba731981824f53460f6e0193f159f71f22ba2
|
||||
@ -1 +1 @@
|
||||
Subproject commit e598f512b498e1b639a8d2134113169f4b8d0d26
|
||||
Subproject commit c8816c51f82210a9c4cc62b1a7c53fa21bc705ee
|
||||
@ -1 +1 @@
|
||||
Subproject commit 78fa7db707655962a1077f4681c35fcf81510060
|
||||
Subproject commit 78a092d4e795f83153e98749b5cbeb66cf812d7e
|
||||
@ -1 +1 @@
|
||||
Subproject commit 7539cbaac26a3d2ca9daf47ba22d1b0ebbc41a2b
|
||||
Subproject commit d5717291325578f64d519822aeb2be81217c67f3
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,7 @@
|
||||
LastUpgradeVersion = "1020"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "NO"
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
@ -29,7 +29,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BFFA71D61AAC406100EE9DD1"
|
||||
BuildableName = "Delta.app"
|
||||
BuildableName = "Retro Game Emulator.app"
|
||||
BlueprintName = "Delta"
|
||||
ReferencedContainer = "container:Delta.xcodeproj">
|
||||
</BuildableReference>
|
||||
@ -45,7 +45,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BFFA71D61AAC406100EE9DD1"
|
||||
BuildableName = "Delta.app"
|
||||
BuildableName = "Retro Game Emulator.app"
|
||||
BlueprintName = "Delta"
|
||||
ReferencedContainer = "container:Delta.xcodeproj">
|
||||
</BuildableReference>
|
||||
@ -54,7 +54,7 @@
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
buildConfiguration = "Release"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
enableASanStackUseAfterReturn = "YES"
|
||||
@ -69,7 +69,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BFFA71D61AAC406100EE9DD1"
|
||||
BuildableName = "Delta.app"
|
||||
BuildableName = "Retro Game Emulator.app"
|
||||
BlueprintName = "Delta"
|
||||
ReferencedContainer = "container:Delta.xcodeproj">
|
||||
</BuildableReference>
|
||||
@ -107,7 +107,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "BFFA71D61AAC406100EE9DD1"
|
||||
BuildableName = "Delta.app"
|
||||
BuildableName = "Retro Game Emulator.app"
|
||||
BlueprintName = "Delta"
|
||||
ReferencedContainer = "container:Delta.xcodeproj">
|
||||
</BuildableReference>
|
||||
|
||||
@ -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>
|
||||
@ -1,12 +1,21 @@
|
||||
{
|
||||
"object": {
|
||||
"pins": [
|
||||
{
|
||||
"package": "AltKit",
|
||||
"repositoryURL": "https://github.com/rileytestut/AltKit.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "2fd376df1c79ec06a5c80cc8933da027f65b3148",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"package": "DeltaCore",
|
||||
"repositoryURL": "https://github.com/rileytestut/DeltaCore.git",
|
||||
"state": {
|
||||
"branch": "main",
|
||||
"revision": "eeefb30bd78fb130f1113e823afbce6f4f767cfb",
|
||||
"branch": "ios14",
|
||||
"revision": "74d2a7a6e36035cb5730d0b0cf2456cbeb6faf0c",
|
||||
"version": null
|
||||
}
|
||||
},
|
||||
@ -15,8 +24,8 @@
|
||||
"repositoryURL": "https://github.com/weichsel/ZIPFoundation.git",
|
||||
"state": {
|
||||
"branch": null,
|
||||
"revision": "ec32d62d412578542c0ffb7a6ce34d3e64b43b94",
|
||||
"version": "0.9.11"
|
||||
"revision": "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0",
|
||||
"version": "0.9.19"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@ -10,9 +10,7 @@ import UIKit
|
||||
|
||||
import DeltaCore
|
||||
import Harmony
|
||||
|
||||
import Fabric
|
||||
import Crashlytics
|
||||
import AltKit
|
||||
|
||||
private extension CFNotificationName
|
||||
{
|
||||
@ -39,30 +37,14 @@ 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
|
||||
ExternalGameControllerManager.shared.startMonitoring()
|
||||
|
||||
// JIT
|
||||
ServerManager.shared.prepare()
|
||||
|
||||
// Notifications
|
||||
let center = CFNotificationCenterGetDarwinNotifyCenter()
|
||||
CFNotificationCenterAddObserver(center, nil, ReceivedApplicationState, CFNotificationName.altstoreRequestAppState.rawValue, nil, .deliverImmediately)
|
||||
@ -110,6 +92,34 @@ class AppDelegate: UIResponder, UIApplicationDelegate
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
extension AppDelegate
|
||||
{
|
||||
func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration
|
||||
{
|
||||
// Called when a new scene session is being created.
|
||||
// Use this method to select a configuration to create the new scene with.
|
||||
|
||||
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>)
|
||||
{
|
||||
// Called when the user discards a scene session.
|
||||
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
|
||||
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
|
||||
}
|
||||
}
|
||||
|
||||
private extension AppDelegate
|
||||
{
|
||||
func registerCores()
|
||||
|
||||
@ -1,13 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13528" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="dkK-ii-Bx4">
|
||||
<device id="retina4_7" orientation="portrait">
|
||||
<adaptation id="fullscreen"/>
|
||||
</device>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="dkK-ii-Bx4">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13526"/>
|
||||
<capability name="Aspect ratio constraints" minToolsVersion="5.1"/>
|
||||
<capability name="Constraints with non-1.0 multipliers" minToolsVersion="5.1"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22684"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
@ -23,21 +19,32 @@
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Delta" translatesAutoresizingMaskIntoConstraints="NO" id="plh-tL-LY0">
|
||||
<rect key="frame" x="94" y="250" width="187.5" height="167"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="width" secondItem="plh-tL-LY0" secondAttribute="height" multiplier="64:57" id="8qM-L2-ASa"/>
|
||||
</constraints>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" insetsLayoutMarginsFromSafeArea="NO" image="LaunchViewC" translatesAutoresizingMaskIntoConstraints="NO" id="5XD-I3-tLg">
|
||||
<rect key="frame" x="0.0" y="50" width="375" height="378"/>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Retro Game Emulator" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="vhb-Xd-o6a">
|
||||
<rect key="frame" x="55.5" y="448" width="264" height="33.5"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="28"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" alpha="0.44999998807907104" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="ENJOY CLASSIC MOMENT" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="wWH-Lx-U9x">
|
||||
<rect key="frame" x="90.5" y="513.5" width="194.5" height="19.5"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.14728124936421713" alpha="1" colorSpace="calibratedWhite"/>
|
||||
<color key="backgroundColor" red="0.071399740870000006" green="0.082175157959999995" blue="0.10832635309999999" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="plh-tL-LY0" firstAttribute="width" relation="lessThanOrEqual" secondItem="8Uu-wz-ps8" secondAttribute="width" multiplier="0.5" id="8j9-39-Y2s"/>
|
||||
<constraint firstItem="plh-tL-LY0" firstAttribute="centerY" secondItem="8Uu-wz-ps8" secondAttribute="centerY" id="COW-Co-NFK"/>
|
||||
<constraint firstItem="plh-tL-LY0" firstAttribute="height" secondItem="8Uu-wz-ps8" secondAttribute="height" multiplier="0.5" priority="900" id="G3L-7B-xVc"/>
|
||||
<constraint firstItem="plh-tL-LY0" firstAttribute="width" secondItem="8Uu-wz-ps8" secondAttribute="width" multiplier="0.5" priority="950" id="n3i-kS-7eQ"/>
|
||||
<constraint firstItem="plh-tL-LY0" firstAttribute="centerX" secondItem="8Uu-wz-ps8" secondAttribute="centerX" id="sp5-Kf-N7G"/>
|
||||
<constraint firstItem="plh-tL-LY0" firstAttribute="height" relation="lessThanOrEqual" secondItem="8Uu-wz-ps8" secondAttribute="height" multiplier="0.5" id="ubN-Qh-I5H"/>
|
||||
<constraint firstItem="5XD-I3-tLg" firstAttribute="top" secondItem="qMb-3x-uIu" secondAttribute="bottom" constant="30" id="Aiv-ac-bYx"/>
|
||||
<constraint firstItem="wWH-Lx-U9x" firstAttribute="centerX" secondItem="vhb-Xd-o6a" secondAttribute="centerX" id="DEu-U7-qVq"/>
|
||||
<constraint firstAttribute="trailing" secondItem="5XD-I3-tLg" secondAttribute="trailing" id="Gc5-y0-Vsy"/>
|
||||
<constraint firstItem="vhb-Xd-o6a" firstAttribute="top" secondItem="5XD-I3-tLg" secondAttribute="bottom" constant="20" id="Ncn-Yh-ecr"/>
|
||||
<constraint firstItem="5XD-I3-tLg" firstAttribute="leading" secondItem="8Uu-wz-ps8" secondAttribute="leading" id="SSl-CS-XOC"/>
|
||||
<constraint firstItem="5XD-I3-tLg" firstAttribute="height" secondItem="8Uu-wz-ps8" secondAttribute="height" multiplier="1.7:3" id="WGx-z3-vXf"/>
|
||||
<constraint firstItem="wWH-Lx-U9x" firstAttribute="top" secondItem="vhb-Xd-o6a" secondAttribute="bottom" constant="32" id="lVY-5z-fz7"/>
|
||||
<constraint firstItem="vhb-Xd-o6a" firstAttribute="centerX" secondItem="8Uu-wz-ps8" secondAttribute="centerX" id="oCf-dA-bJX"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
@ -47,6 +54,6 @@
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="Delta" width="1342" height="1196"/>
|
||||
<image name="LaunchViewC" width="375" height="472.5"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="SPq-Bk-fQl">
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="SPq-Bk-fQl">
|
||||
<device id="retina4_7" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16086"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22684"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Games-->
|
||||
<!--Games View Controller-->
|
||||
<scene sceneID="Cd2-Pf-cua">
|
||||
<objects>
|
||||
<viewController id="jeE-WD-wXO" customClass="GamesViewController" customModule="Delta" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<viewController id="jeE-WD-wXO" customClass="GamesViewController" customModule="Retro_Game_Emulator" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="WoX-O4-qy5"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="0om-QB-N5a"/>
|
||||
@ -25,48 +25,80 @@
|
||||
<segue destination="tpK-ou-yEA" kind="embed" identifier="embedPageViewController" id="cjU-nW-cHY"/>
|
||||
</connections>
|
||||
</containerView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="bg" translatesAutoresizingMaskIntoConstraints="NO" id="J8K-ZI-4X1">
|
||||
<rect key="frame" x="0.0" y="56" width="375" height="591"/>
|
||||
</imageView>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="CxB-GP-B6S">
|
||||
<rect key="frame" x="0.0" y="538" width="375" height="109"/>
|
||||
<inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/>
|
||||
<state key="normal" image="home_tab"/>
|
||||
<connections>
|
||||
<segue destination="xMK-Cs-fAS" kind="presentation" modalPresentationStyle="fullScreen" id="bxS-9X-3vh"/>
|
||||
</connections>
|
||||
</button>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="n4H-Kw-HPj">
|
||||
<rect key="frame" x="143.5" y="517" width="100" height="100"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="100" id="SLR-9h-t9y"/>
|
||||
<constraint firstAttribute="width" constant="100" id="Tdv-6u-nlA"/>
|
||||
</constraints>
|
||||
<inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/>
|
||||
<state key="normal" image="addtwo"/>
|
||||
<connections>
|
||||
<action selector="importfilesBtn:" destination="jeE-WD-wXO" eventType="touchUpInside" id="T2w-zr-j8V"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="CxB-GP-B6S" secondAttribute="trailing" id="5sO-AB-YB5"/>
|
||||
<constraint firstAttribute="trailing" secondItem="J8K-ZI-4X1" secondAttribute="trailing" id="7MY-qA-ANn"/>
|
||||
<constraint firstAttribute="trailing" secondItem="tmn-gd-5UN" secondAttribute="trailing" id="9Rq-HM-vqk"/>
|
||||
<constraint firstItem="J8K-ZI-4X1" firstAttribute="leading" secondItem="3Bk-k3-7J9" secondAttribute="leading" id="9kW-o9-BMv"/>
|
||||
<constraint firstItem="CxB-GP-B6S" firstAttribute="centerX" secondItem="3Bk-k3-7J9" secondAttribute="centerX" id="Dbp-v1-mUp"/>
|
||||
<constraint firstItem="n4H-Kw-HPj" firstAttribute="centerX" secondItem="3Bk-k3-7J9" secondAttribute="centerX" constant="6" id="H9T-X7-RK8"/>
|
||||
<constraint firstItem="0om-QB-N5a" firstAttribute="top" secondItem="n4H-Kw-HPj" secondAttribute="bottom" constant="30" id="QHB-jA-R0s"/>
|
||||
<constraint firstItem="0om-QB-N5a" firstAttribute="top" secondItem="CxB-GP-B6S" secondAttribute="bottom" id="QzJ-Kr-VZk"/>
|
||||
<constraint firstItem="CxB-GP-B6S" firstAttribute="leading" secondItem="3Bk-k3-7J9" secondAttribute="leading" id="axc-ed-3zE"/>
|
||||
<constraint firstItem="tmn-gd-5UN" firstAttribute="leading" secondItem="3Bk-k3-7J9" secondAttribute="leading" id="f1f-sa-dBA"/>
|
||||
<constraint firstAttribute="bottom" secondItem="tmn-gd-5UN" secondAttribute="bottom" id="ifM-Wa-u9y"/>
|
||||
<constraint firstItem="J8K-ZI-4X1" firstAttribute="top" secondItem="WoX-O4-qy5" secondAttribute="bottom" id="jaI-AF-tpn"/>
|
||||
<constraint firstItem="tmn-gd-5UN" firstAttribute="top" secondItem="3Bk-k3-7J9" secondAttribute="top" id="nhS-aC-rUR"/>
|
||||
<constraint firstItem="0om-QB-N5a" firstAttribute="top" secondItem="J8K-ZI-4X1" secondAttribute="bottom" id="tvh-Sd-zA1"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<navigationItem key="navigationItem" title="Games" id="pFk-as-3k4">
|
||||
<barButtonItem key="leftBarButtonItem" image="SettingsButton" id="2gg-lC-FhX">
|
||||
<connections>
|
||||
<segue destination="xMK-Cs-fAS" kind="presentation" identifier="showSettings" id="uN5-PN-7FK"/>
|
||||
</connections>
|
||||
</barButtonItem>
|
||||
<barButtonItem key="rightBarButtonItem" systemItem="add" id="FeA-O5-xd2">
|
||||
<navigationItem key="navigationItem" id="pFk-as-3k4">
|
||||
<barButtonItem key="leftBarButtonItem" image="home" id="2gg-lC-FhX"/>
|
||||
<barButtonItem key="rightBarButtonItem" style="plain" id="FeA-O5-xd2">
|
||||
<connections>
|
||||
<action selector="importFiles" destination="jeE-WD-wXO" id="A1s-kE-NkM"/>
|
||||
</connections>
|
||||
</barButtonItem>
|
||||
</navigationItem>
|
||||
<connections>
|
||||
<outlet property="importButton" destination="FeA-O5-xd2" id="A44-3S-Okz"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="JYx-xE-nis" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="1036" y="1002"/>
|
||||
<point key="canvasLocation" x="1036" y="1001.649175412294"/>
|
||||
</scene>
|
||||
<!--Game Collection View Controller-->
|
||||
<scene sceneID="qNA-NP-TiF">
|
||||
<objects>
|
||||
<collectionViewController storyboardIdentifier="gameCollectionViewController" clearsSelectionOnViewWillAppear="NO" id="kqu-75-owz" customClass="GameCollectionViewController" customModule="Delta" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<collectionViewController storyboardIdentifier="gameCollectionViewController" clearsSelectionOnViewWillAppear="NO" id="kqu-75-owz" customClass="GameCollectionViewController" customModule="Retro_Game_Emulator" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<collectionView key="view" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" indicatorStyle="white" dataMode="prototypes" id="OIq-Z8-kxO">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="20" minimumInteritemSpacing="10" id="NKN-dd-bTh" customClass="GridCollectionViewLayout" customModule="Delta" customModuleProvider="target">
|
||||
<collectionViewFlowLayout key="collectionViewLayout" minimumLineSpacing="20" minimumInteritemSpacing="10" id="NKN-dd-bTh" customClass="GridCollectionViewLayout" customModule="Retro_Game_Emulator" customModuleProvider="target">
|
||||
<size key="itemSize" width="100" height="100"/>
|
||||
<size key="headerReferenceSize" width="0.0" height="0.0"/>
|
||||
<size key="footerReferenceSize" width="0.0" height="0.0"/>
|
||||
<inset key="sectionInset" minX="0.0" minY="20" maxX="0.0" maxY="20"/>
|
||||
</collectionViewFlowLayout>
|
||||
<cells>
|
||||
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="Cell" id="ioT-sh-j8y" customClass="GridCollectionViewCell" customModule="Delta" customModuleProvider="target">
|
||||
<collectionViewCell opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" reuseIdentifier="Cell" id="ioT-sh-j8y" customClass="GridCollectionViewCell" customModule="Retro_Game_Emulator" customModuleProvider="target">
|
||||
<rect key="frame" x="0.0" y="20" width="100" height="100"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center">
|
||||
@ -82,7 +114,7 @@
|
||||
</collectionView>
|
||||
<connections>
|
||||
<segue destination="X2o-q6-XD5" kind="unwind" identifier="unwindFromGames" unwindAction="unwindFromGamesViewControllerWith:" id="k8C-Xn-maU"/>
|
||||
<segue destination="MPk-bF-nkj" kind="presentation" identifier="saveStates" customClass="SaveStatesStoryboardSegue" customModule="Delta" customModuleProvider="target" id="1Xp-2J-0cq"/>
|
||||
<segue destination="MPk-bF-nkj" kind="presentation" identifier="saveStates" customClass="SaveStatesStoryboardSegue" customModule="Retro_Game_Emulator" customModuleProvider="target" id="1Xp-2J-0cq"/>
|
||||
<segue destination="qdE-gb-V2e" kind="presentation" identifier="preferredControllerSkins" id="i6y-cP-3WM"/>
|
||||
<segue destination="V2x-v0-jWm" kind="presentation" identifier="showDSSettings" id="kuV-tY-Y0B"/>
|
||||
</connections>
|
||||
@ -95,7 +127,7 @@
|
||||
<!--Launch View Controller-->
|
||||
<scene sceneID="p7y-IT-nlb">
|
||||
<objects>
|
||||
<viewController id="SPq-Bk-fQl" customClass="LaunchViewController" customModule="Delta" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<viewController id="SPq-Bk-fQl" customClass="LaunchViewController" customModule="Retro_Game_Emulator" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="Qap-U8-zpQ"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="dca-QO-wba"/>
|
||||
@ -114,12 +146,38 @@
|
||||
<containerView opaque="NO" contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="vf5-Iy-lAb" userLabel="Launch Screen">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" red="0.071399740870000006" green="0.082175157959999995" blue="0.10832635309999999" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<connections>
|
||||
<segue destination="ibA-aC-X3M" kind="embed" id="fsv-uf-AOE"/>
|
||||
</connections>
|
||||
</containerView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Mgn-IF-ax7">
|
||||
<rect key="frame" x="187.5" y="448" width="0.0" height="0.0"/>
|
||||
<fontDescription key="fontDescription" type="system" weight="medium" pointSize="28"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<label opaque="NO" userInteractionEnabled="NO" alpha="0.44999998807907104" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="wnW-MB-udh">
|
||||
<rect key="frame" x="187.5" y="480" width="0.0" height="0.0"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="16"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="50O-wR-1hq">
|
||||
<rect key="frame" x="0.0" y="50" width="375" height="378"/>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
|
||||
<color key="backgroundColor" red="0.071399740870000006" green="0.082175157959999995" blue="0.10832635309999999" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="50O-wR-1hq" secondAttribute="trailing" id="2Xh-ZZ-Dbc"/>
|
||||
<constraint firstItem="50O-wR-1hq" firstAttribute="leading" secondItem="8jv-0a-ItC" secondAttribute="leading" id="6rJ-Cz-C3Z"/>
|
||||
<constraint firstItem="Mgn-IF-ax7" firstAttribute="centerX" secondItem="8jv-0a-ItC" secondAttribute="centerX" id="8kV-gZ-4TW"/>
|
||||
<constraint firstItem="50O-wR-1hq" firstAttribute="height" secondItem="8jv-0a-ItC" secondAttribute="height" multiplier="1.7:3" id="FTt-Ru-HhA"/>
|
||||
<constraint firstItem="wnW-MB-udh" firstAttribute="top" secondItem="Mgn-IF-ax7" secondAttribute="bottom" constant="32" id="IAR-im-kU7"/>
|
||||
<constraint firstItem="50O-wR-1hq" firstAttribute="top" secondItem="Qap-U8-zpQ" secondAttribute="bottom" constant="30" id="bo1-Am-BdP"/>
|
||||
<constraint firstItem="wnW-MB-udh" firstAttribute="centerX" secondItem="8jv-0a-ItC" secondAttribute="centerX" id="djF-KX-NgM"/>
|
||||
<constraint firstItem="Mgn-IF-ax7" firstAttribute="top" secondItem="50O-wR-1hq" secondAttribute="bottom" constant="20" id="igk-jf-lgM"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<connections>
|
||||
<outlet property="gameViewContainerView" destination="oBZ-xU-jeC" id="jMI-iF-JlU"/>
|
||||
@ -140,7 +198,7 @@
|
||||
<!--Game View Controller-->
|
||||
<scene sceneID="ASV-Uk-0aP">
|
||||
<objects>
|
||||
<viewController id="yhz-fF-D91" customClass="GameViewController" customModule="Delta" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<viewController id="yhz-fF-D91" customClass="GameViewController" customModule="Retro_Game_Emulator" customModuleProvider="target" sceneMemberID="viewController">
|
||||
<layoutGuides>
|
||||
<viewControllerLayoutGuide type="top" id="ItC-Bu-WRI"/>
|
||||
<viewControllerLayoutGuide type="bottom" id="g58-HO-6L5"/>
|
||||
@ -148,12 +206,23 @@
|
||||
<view key="view" contentMode="scaleToFill" id="skW-1S-YD4">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="1" colorSpace="calibratedWhite"/>
|
||||
<subviews>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="bg" translatesAutoresizingMaskIntoConstraints="NO" id="0b6-zE-QS0">
|
||||
<rect key="frame" x="0.0" y="20" width="375" height="647"/>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="0.02214059979" green="0.092763282360000004" blue="0.18196243049999999" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="trailing" secondItem="0b6-zE-QS0" secondAttribute="trailing" id="5n6-uA-WLa"/>
|
||||
<constraint firstItem="0b6-zE-QS0" firstAttribute="top" secondItem="ItC-Bu-WRI" secondAttribute="bottom" id="DTE-cg-9BW"/>
|
||||
<constraint firstItem="0b6-zE-QS0" firstAttribute="leading" secondItem="skW-1S-YD4" secondAttribute="leading" id="Ej3-6D-wcR"/>
|
||||
<constraint firstItem="g58-HO-6L5" firstAttribute="top" secondItem="0b6-zE-QS0" secondAttribute="bottom" id="ozO-Ni-qpr"/>
|
||||
</constraints>
|
||||
</view>
|
||||
<connections>
|
||||
<segue destination="Yrw-9v-Pcr" kind="presentation" identifier="pause" customClass="PauseStoryboardSegue" customModule="Delta" customModuleProvider="target" id="FLq-Zt-HDv"/>
|
||||
<segue destination="wKV-3d-NIY" kind="presentation" identifier="showGamesViewController" customClass="GamesStoryboardSegue" customModule="Delta" customModuleProvider="target" id="Tey-6Z-UHp"/>
|
||||
<segue destination="wKV-3d-NIY" kind="presentation" identifier="showInitialGamesViewController" customClass="InitialGamesStoryboardSegue" customModule="Delta" customModuleProvider="target" id="eR2-0c-0Rv"/>
|
||||
<segue destination="Yrw-9v-Pcr" kind="presentation" identifier="pause" customClass="PauseStoryboardSegue" customModule="Retro_Game_Emulator" customModuleProvider="target" id="FLq-Zt-HDv"/>
|
||||
<segue destination="wKV-3d-NIY" kind="presentation" identifier="showGamesViewController" customClass="GamesStoryboardSegue" customModule="Retro_Game_Emulator" customModuleProvider="target" id="Tey-6Z-UHp"/>
|
||||
<segue destination="wKV-3d-NIY" kind="presentation" identifier="showInitialGamesViewController" customClass="InitialGamesStoryboardSegue" customModule="Retro_Game_Emulator" customModuleProvider="target" id="eR2-0c-0Rv"/>
|
||||
</connections>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="gxI-00-NlJ" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
@ -171,10 +240,12 @@
|
||||
<!--Settings-->
|
||||
<scene sceneID="L3X-MM-hJL">
|
||||
<objects>
|
||||
<viewControllerPlaceholder storyboardName="Settings" id="xMK-Cs-fAS" sceneMemberID="viewController"/>
|
||||
<viewControllerPlaceholder storyboardName="Settings" id="xMK-Cs-fAS" sceneMemberID="viewController">
|
||||
<navigationItem key="navigationItem" id="7yR-QM-2bX"/>
|
||||
</viewControllerPlaceholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="2N5-3k-beA" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="1036" y="605"/>
|
||||
<point key="canvasLocation" x="1578" y="774"/>
|
||||
</scene>
|
||||
<!--Navigation Controller-->
|
||||
<scene sceneID="zJI-sC-1sm">
|
||||
@ -185,6 +256,8 @@
|
||||
<navigationBar key="navigationBar" contentMode="scaleToFill" id="wj9-1e-eev">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="56"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<color key="barTintColor" red="0.032843004910000001" green="0.076728828250000006" blue="0.13726195690000001" alpha="1" colorSpace="custom" customColorSpace="displayP3"/>
|
||||
</navigationBar>
|
||||
<nil name="viewControllers"/>
|
||||
<toolbar key="toolbar" opaque="NO" clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="M4r-sO-G4H">
|
||||
@ -219,7 +292,7 @@
|
||||
<nil name="viewControllers"/>
|
||||
<connections>
|
||||
<segue destination="Eae-Qk-9MI" kind="relationship" relationship="rootViewController" id="1Jh-Zf-ntp"/>
|
||||
<segue destination="WQV-Du-4IA" kind="unwind" identifier="unwindFromSaveStates" customClass="SaveStatesStoryboardUnwindSegue" customModule="Delta" customModuleProvider="target" unwindAction="unwindToGameCollectionViewController:" id="dwO-iv-XDr"/>
|
||||
<segue destination="WQV-Du-4IA" kind="unwind" identifier="unwindFromSaveStates" customClass="SaveStatesStoryboardUnwindSegue" customModule="Retro_Game_Emulator" customModuleProvider="target" unwindAction="unwindToGameCollectionViewController:" id="dwO-iv-XDr"/>
|
||||
</connections>
|
||||
</navigationController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="htj-tq-2KP" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
@ -274,29 +347,19 @@
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="56"/>
|
||||
<autoresizingMask key="autoresizingMask"/>
|
||||
</navigationBar>
|
||||
<connections>
|
||||
<segue destination="cFV-KV-B18" kind="relationship" relationship="rootViewController" id="VBP-fg-oNH"/>
|
||||
</connections>
|
||||
</navigationController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="Jo9-gl-p5p" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="2652" y="3085"/>
|
||||
</scene>
|
||||
<!--dsSettingsViewController-->
|
||||
<scene sceneID="anM-Cb-BaB">
|
||||
<objects>
|
||||
<viewControllerPlaceholder storyboardName="Settings" referencedIdentifier="dsSettingsViewController" id="cFV-KV-B18" sceneMemberID="viewController">
|
||||
<navigationItem key="navigationItem" id="Dkm-Hm-sQa"/>
|
||||
</viewControllerPlaceholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="IS2-hO-HBN" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="3258" y="3084"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<inferredMetricsTieBreakers>
|
||||
<segue reference="Tey-6Z-UHp"/>
|
||||
</inferredMetricsTieBreakers>
|
||||
<resources>
|
||||
<image name="SettingsButton" width="22" height="22"/>
|
||||
<image name="addtwo" width="519" height="519"/>
|
||||
<image name="bg" width="375" height="812"/>
|
||||
<image name="home" width="92" height="37"/>
|
||||
<image name="home_tab" width="384.5" height="109"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -39,7 +39,7 @@ class GridCollectionViewCell: UICollectionViewCell
|
||||
}
|
||||
}
|
||||
|
||||
var maximumImageSize: CGSize = CGSize(width: 100, height: 100) {
|
||||
var maximumImageSize: CGSize = CGSize(width: 150, height: 120) {
|
||||
didSet {
|
||||
self.updateMaximumImageSize()
|
||||
}
|
||||
@ -109,13 +109,15 @@ class GridCollectionViewCell: UICollectionViewCell
|
||||
// Image View
|
||||
self.imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
self.imageView.topAnchor.constraint(equalTo: self.contentView.topAnchor).isActive = true
|
||||
self.imageView.centerXAnchor.constraint(equalTo: self.contentView.centerXAnchor).isActive = true
|
||||
self.imageView.topAnchor.constraint(equalTo: self.vibrancyView.topAnchor).isActive = true
|
||||
self.imageView.centerXAnchor.constraint(equalTo: self.vibrancyView.centerXAnchor).isActive = true
|
||||
|
||||
self.imageViewWidthConstraint = self.imageView.widthAnchor.constraint(equalToConstant: self.maximumImageSize.width)
|
||||
self.imageViewWidthConstraint = self.imageView.widthAnchor.constraint(equalToConstant: 150)
|
||||
self.imageViewWidthConstraint.isActive = true
|
||||
|
||||
self.imageViewHeightConstraint = self.imageView.heightAnchor.constraint(equalToConstant: self.maximumImageSize.height)
|
||||
self.imageViewHeightConstraint = self.imageView.heightAnchor.constraint(equalToConstant: 120)
|
||||
|
||||
self.imageViewHeightConstraint.priority = UILayoutPriority(999) // Fixes "Unable to simultaneously satisfy constraints" runtime error when inserting new grid row.
|
||||
self.imageViewHeightConstraint.isActive = true
|
||||
|
||||
|
||||
@ -181,10 +183,15 @@ private extension GridCollectionViewCell
|
||||
{
|
||||
func updateMaximumImageSize()
|
||||
{
|
||||
self.imageViewWidthConstraint.constant = self.maximumImageSize.width
|
||||
self.imageViewHeightConstraint.constant = self.maximumImageSize.height
|
||||
self.imageViewWidthConstraint.constant = 150
|
||||
self.imageViewHeightConstraint.constant = 120
|
||||
|
||||
self.textLabelVerticalSpacingConstraint.constant = 8
|
||||
self.textLabelFocusedVerticalSpacingConstraint?.constant = self.maximumImageSize.height / 10.0
|
||||
// 修改 textLabel 的固定高度为 30
|
||||
// let textLabelFixedHeight: CGFloat = 30
|
||||
//
|
||||
// self.textLabelVerticalSpacingConstraint.constant = textLabelFixedHeight
|
||||
// self.textLabelFocusedVerticalSpacingConstraint?.constant = textLabelFixedHeight / 50
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,11 +43,21 @@ 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 }
|
||||
|
||||
if #available(iOS 17, *)
|
||||
{
|
||||
guard let view = titleControl.subviews.first else { return nil }
|
||||
containerView = view
|
||||
}
|
||||
else
|
||||
{
|
||||
containerView = titleControl
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
containerView = contentView
|
||||
|
||||
143
Delta/Database/Cheats/CheatBase.swift
Normal file
143
Delta/Database/Cheats/CheatBase.swift
Normal file
@ -0,0 +1,143 @@
|
||||
//
|
||||
// CheatBase.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 1/17/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SQLite
|
||||
|
||||
import Roxas
|
||||
|
||||
private extension UserDefaults
|
||||
{
|
||||
@NSManaged var previousCheatBaseVersion: Int
|
||||
}
|
||||
|
||||
extension ExpressionType
|
||||
{
|
||||
static var cheatID: SQLite.Expression<Int> {
|
||||
return SQLite.Expression<Int>("cheatID")
|
||||
}
|
||||
|
||||
static var cheatName: SQLite.Expression<String> {
|
||||
return SQLite.Expression<String>("cheatName")
|
||||
}
|
||||
|
||||
static var cheatDescription: SQLite.Expression<String?> {
|
||||
return SQLite.Expression<String?>("cheatDescription")
|
||||
}
|
||||
|
||||
static var cheatCode: SQLite.Expression<String> {
|
||||
return SQLite.Expression<String>("cheatCode")
|
||||
}
|
||||
|
||||
static var cheatDeviceID: SQLite.Expression<Int> {
|
||||
return SQLite.Expression<Int>("cheatDeviceID")
|
||||
}
|
||||
|
||||
static var cheatActivation: SQLite.Expression<String?> {
|
||||
return SQLite.Expression<String?>("cheatActivation")
|
||||
}
|
||||
|
||||
static var cheatCategoryID: SQLite.Expression<Int> {
|
||||
return SQLite.Expression<Int>("cheatCategoryID")
|
||||
}
|
||||
|
||||
static var cheatCategoryName: SQLite.Expression<String> {
|
||||
return SQLite.Expression<String>("cheatCategory")
|
||||
}
|
||||
|
||||
static var cheatCategoryDescription: SQLite.Expression<String> {
|
||||
return SQLite.Expression<String>("cheatCategoryDescription")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension Table
|
||||
{
|
||||
static var cheats: Table {
|
||||
return Table("CHEATS")
|
||||
}
|
||||
|
||||
static var cheatCategories: Table {
|
||||
return Table("CHEAT_CATEGORIES")
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
class CheatBase: GamesDatabase
|
||||
{
|
||||
static let cheatsVersion = 1
|
||||
static var previousCheatsVersion: Int? {
|
||||
return UserDefaults.standard.previousCheatBaseVersion
|
||||
}
|
||||
|
||||
private let connection: Connection
|
||||
|
||||
override init() throws
|
||||
{
|
||||
let fileURL = DatabaseManager.cheatBaseURL
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { throw GamesDatabase.Error.doesNotExist }
|
||||
|
||||
self.connection = try Connection(fileURL.path)
|
||||
|
||||
try super.init()
|
||||
|
||||
UserDefaults.standard.previousCheatBaseVersion = CheatBase.cheatsVersion
|
||||
}
|
||||
|
||||
func cheats(for game: Game) async throws -> [CheatMetadata]?
|
||||
{
|
||||
let metadata = await withCheckedContinuation { continuation in
|
||||
if let context = game.managedObjectContext
|
||||
{
|
||||
context.perform {
|
||||
let metadata = self.metadata(for: game)
|
||||
continuation.resume(returning: metadata)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
let metadata = self.metadata(for: game)
|
||||
continuation.resume(returning: metadata)
|
||||
}
|
||||
}
|
||||
|
||||
guard let romIDValue = metadata?.romID else { return nil }
|
||||
|
||||
let cheatID = Expression<Any>.cheatID
|
||||
let cheatName = Expression<Any>.cheatName
|
||||
let cheatCode = Expression<Any>.cheatCode
|
||||
let cheatDescription = Expression<Any>.cheatDescription
|
||||
let cheatActivation = Expression<Any>.cheatActivation
|
||||
let cheatDeviceID = Expression<Any>.cheatDeviceID
|
||||
|
||||
let categoryID = Expression<Any>.cheatCategoryID
|
||||
let categoryName = Expression<Any>.cheatCategoryName
|
||||
let categoryDescription = Expression<Any>.cheatCategoryDescription
|
||||
|
||||
let romID = Expression<Any>.romID
|
||||
|
||||
let query = Table.cheats.select(cheatID, cheatName, cheatCode, cheatDescription, cheatActivation, cheatDeviceID, Table.cheats[categoryID], categoryName, categoryDescription)
|
||||
.filter(romID == romIDValue)
|
||||
.join(Table.cheatCategories, on: Table.cheats[categoryID] == Table.cheatCategories[categoryID])
|
||||
.order(cheatName)
|
||||
|
||||
let rows = try self.connection.prepare(query)
|
||||
|
||||
let results = rows.compactMap { (row) -> CheatMetadata? in
|
||||
guard case let deviceID = Int16(row[cheatDeviceID]), let device = CheatDevice(rawValue: deviceID) else { return nil }
|
||||
|
||||
let id = row[Table.cheats[categoryID]]
|
||||
|
||||
let category = CheatCategory(id: id, name: row[categoryName], categoryDescription: row[categoryDescription])
|
||||
let metadata = CheatMetadata(id: row[cheatID], name: row[cheatName], code: row[cheatCode], description: row[cheatDescription], activationHint: row[cheatActivation], device: device, category: category)
|
||||
return metadata
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
273
Delta/Database/Cheats/CheatBaseView.swift
Normal file
273
Delta/Database/Cheats/CheatBaseView.swift
Normal file
@ -0,0 +1,273 @@
|
||||
//
|
||||
// CheatBaseView.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 1/17/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
import enum SQLite.Result
|
||||
|
||||
@available(iOS 14, *)
|
||||
extension CheatBaseView
|
||||
{
|
||||
private class ViewModel: ObservableObject
|
||||
{
|
||||
@Published
|
||||
var allCheats: [CheatMetadata]? {
|
||||
didSet {
|
||||
guard let cheats = allCheats else {
|
||||
self.cheatsByCategory = nil
|
||||
return
|
||||
}
|
||||
|
||||
let cheatsByCategory = Dictionary(grouping: cheats, by: { $0.category }).sorted { $0.key.id < $1.key.id }
|
||||
self.cheatsByCategory = cheatsByCategory
|
||||
}
|
||||
}
|
||||
|
||||
@Published
|
||||
private(set) var cheatsByCategory: [(CheatCategory, [CheatMetadata])]?
|
||||
|
||||
@Published
|
||||
private(set) var error: Error?
|
||||
|
||||
@Published
|
||||
var searchText: String = "" {
|
||||
didSet {
|
||||
self.searchCheats()
|
||||
}
|
||||
}
|
||||
|
||||
@Published
|
||||
private(set) var filteredCheats: [CheatMetadata]?
|
||||
|
||||
@MainActor
|
||||
func fetchCheats(for game: Game) async
|
||||
{
|
||||
guard self.allCheats == nil else { return }
|
||||
|
||||
do
|
||||
{
|
||||
let database = try CheatBase()
|
||||
let cheats = try await database.cheats(for: game) ?? []
|
||||
self.allCheats = cheats
|
||||
}
|
||||
catch
|
||||
{
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
|
||||
private func searchCheats()
|
||||
{
|
||||
if let cheats = self.allCheats, !self.searchText.isEmpty
|
||||
{
|
||||
let predicate = NSPredicate(forSearchingForText: self.searchText, inValuesForKeyPaths: [#keyPath(CheatMetadata.name), #keyPath(CheatMetadata.cheatDescription)])
|
||||
|
||||
let filteredCheats = cheats.filter { predicate.evaluate(with: $0) }
|
||||
self.filteredCheats = filteredCheats
|
||||
}
|
||||
else
|
||||
{
|
||||
self.filteredCheats = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
struct CheatBaseView: View
|
||||
{
|
||||
let game: Game?
|
||||
|
||||
var cancellationHandler: (() -> Void)?
|
||||
var selectionHandler: ((CheatMetadata) -> Void)?
|
||||
|
||||
@StateObject
|
||||
private var viewModel = ViewModel()
|
||||
|
||||
@State
|
||||
private var activationHintCheat: CheatMetadata?
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
if let cheats = viewModel.allCheats, !cheats.isEmpty
|
||||
{
|
||||
// Only show List if there is at least one cheat for this game.
|
||||
cheatList()
|
||||
}
|
||||
|
||||
// Place above List
|
||||
placeholderView()
|
||||
}
|
||||
.alert(item: $activationHintCheat) { cheat in
|
||||
Alert(title: Text("How to Activate"),
|
||||
message: Text(cheat.activationHint ?? ""),
|
||||
dismissButton: .default(Text("OK")))
|
||||
}
|
||||
.navigationTitle(Text(game?.name ?? "CheatBase"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
cancellationHandler?()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
guard let game = self.game else { return }
|
||||
await viewModel.fetchCheats(for: game)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cheatList() -> some View
|
||||
{
|
||||
VStack {
|
||||
if #unavailable(iOS 15)
|
||||
{
|
||||
LegacySearchBar(text: $viewModel.searchText)
|
||||
}
|
||||
|
||||
let listView = List {
|
||||
if let filteredCheats = viewModel.filteredCheats
|
||||
{
|
||||
ForEach(filteredCheats) { cheat in
|
||||
cell(for: cheat)
|
||||
}
|
||||
}
|
||||
else if let cheats = viewModel.cheatsByCategory
|
||||
{
|
||||
ForEach(cheats, id: \.0.id) { (category, cheats) in
|
||||
Section {
|
||||
DisclosureGroup {
|
||||
ForEach(cheats) { cheat in
|
||||
cell(for: cheat)
|
||||
}
|
||||
} label: {
|
||||
Text(category.name)
|
||||
}
|
||||
} footer: {
|
||||
Text(category.categoryDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if #available(iOS 15, *)
|
||||
{
|
||||
listView.searchable(text: $viewModel.searchText)
|
||||
}
|
||||
else
|
||||
{
|
||||
listView
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
|
||||
|
||||
private func cell(for cheat: CheatMetadata) -> some View
|
||||
{
|
||||
ZStack(alignment: .leading) {
|
||||
Button(action: { choose(cheat) }) {}
|
||||
|
||||
HStack {
|
||||
// Name + Description
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(cheat.name)
|
||||
|
||||
if let description = cheat.cheatDescription
|
||||
{
|
||||
Text(description)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
// Activation Hint
|
||||
if cheat.activationHint != nil
|
||||
{
|
||||
Spacer()
|
||||
|
||||
Button(action: { activationHintCheat = cheat }) {
|
||||
Image(systemName: "info.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
}
|
||||
|
||||
private func placeholderView() -> some View
|
||||
{
|
||||
VStack(spacing: 8) {
|
||||
if let error = viewModel.error
|
||||
{
|
||||
Text("Unable to Load Cheats")
|
||||
.font(.title)
|
||||
|
||||
if let error = error as? SQLite.Result
|
||||
{
|
||||
// SQLite.Result implements CustomStringConvertible.description, but not localizedDescription.
|
||||
Text(String(describing: error))
|
||||
.font(.callout)
|
||||
}
|
||||
else
|
||||
{
|
||||
Text(error.localizedDescription)
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
else if let filteredCheats = viewModel.filteredCheats, filteredCheats.isEmpty
|
||||
{
|
||||
Text("Cheat Not Found")
|
||||
.font(.title)
|
||||
|
||||
Text("Please make sure the name is correct, or try searching for another cheat.")
|
||||
.font(.callout)
|
||||
}
|
||||
else if let cheats = viewModel.allCheats, cheats.isEmpty
|
||||
{
|
||||
Text("No Cheats")
|
||||
.font(.title)
|
||||
|
||||
Text("There are no cheats for this game in Delta's CheatBase. Please try a different game.")
|
||||
.font(.callout)
|
||||
}
|
||||
else if viewModel.allCheats == nil
|
||||
{
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
|
||||
.foregroundColor(.gray)
|
||||
.padding()
|
||||
}
|
||||
|
||||
init(game: Game, cheats: [CheatMetadata]? = nil)
|
||||
{
|
||||
self.game = game
|
||||
|
||||
let viewModel = ViewModel()
|
||||
viewModel.allCheats = cheats
|
||||
self._viewModel = StateObject(wrappedValue: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
private extension CheatBaseView
|
||||
{
|
||||
func choose(_ cheatMetadata: CheatMetadata)
|
||||
{
|
||||
self.selectionHandler?(cheatMetadata)
|
||||
}
|
||||
}
|
||||
111
Delta/Database/Cheats/CheatDevice.swift
Normal file
111
Delta/Database/Cheats/CheatDevice.swift
Normal file
@ -0,0 +1,111 @@
|
||||
//
|
||||
// CheatDevice.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 1/30/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
import DeltaCore
|
||||
import NESDeltaCore
|
||||
|
||||
@objc
|
||||
enum CheatDevice: Int16
|
||||
{
|
||||
case famicomGameGenie = 1
|
||||
case famicomRaw = 2
|
||||
case famicomRawCompare = 3
|
||||
|
||||
case gbGameGenie = 4
|
||||
|
||||
case gbaActionReplayMax = 5
|
||||
case gbaCodeBreaker = 6
|
||||
case gbaGameShark = 7
|
||||
|
||||
case gbcGameShark = 8
|
||||
|
||||
case n64GameShark = 9
|
||||
|
||||
case dsActionReplay = 10
|
||||
case dsCodeBreaker = 11
|
||||
|
||||
case nesGameGenie = 12
|
||||
case nesRaw = 13
|
||||
case nesRawCompare = 14
|
||||
|
||||
case snesActionReplay = 15
|
||||
case snesGameGenie = 16
|
||||
|
||||
case gameGearActionReplay = 17
|
||||
case gameGearGameGenie = 18
|
||||
|
||||
case masterSystemActionReplay = 19
|
||||
case masterSystemGameGenie = 20
|
||||
|
||||
case cdActionReplay10 = 21
|
||||
case cdActionReplay8 = 22
|
||||
|
||||
case genesisActionReplay10 = 23
|
||||
case genesisActionReplay8 = 24
|
||||
}
|
||||
|
||||
extension CheatDevice
|
||||
{
|
||||
var cheatType: CheatType? {
|
||||
switch self
|
||||
{
|
||||
case .snesActionReplay, .gbaActionReplayMax, .dsActionReplay, .gameGearActionReplay, .masterSystemActionReplay, .genesisActionReplay8, .genesisActionReplay10, .cdActionReplay8, .cdActionReplay10:
|
||||
return .actionReplay
|
||||
|
||||
case .n64GameShark, .gbcGameShark, .gbaGameShark:
|
||||
return .gameShark
|
||||
|
||||
case .famicomGameGenie, .snesGameGenie, .gbGameGenie, .gameGearGameGenie, .masterSystemGameGenie:
|
||||
return .gameGenie
|
||||
|
||||
case .nesGameGenie:
|
||||
return CheatType(rawValue: DeltaCore.CheatType.gameGenie8.rawValue)
|
||||
|
||||
case .gbaCodeBreaker, .dsCodeBreaker:
|
||||
return .codeBreaker
|
||||
|
||||
case .famicomRaw, .famicomRawCompare:
|
||||
return nil
|
||||
|
||||
case .nesRaw, .nesRawCompare:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var gameType: GameType? {
|
||||
switch self
|
||||
{
|
||||
case .famicomGameGenie, .famicomRaw, .famicomRawCompare: return .nes
|
||||
case .nesGameGenie, .nesRaw, .nesRawCompare: return .nes
|
||||
case .snesActionReplay, .snesGameGenie: return .snes
|
||||
case .n64GameShark: return .n64
|
||||
case .gbGameGenie, .gbcGameShark: return .gbc
|
||||
case .gbaActionReplayMax, .gbaGameShark, .gbaCodeBreaker: return .gba
|
||||
case .dsActionReplay, .dsCodeBreaker: return .ds
|
||||
case .genesisActionReplay8, .genesisActionReplay10: return .genesis
|
||||
case .cdActionReplay8, .cdActionReplay10: return .genesis
|
||||
|
||||
// Not yet supported
|
||||
case .gameGearActionReplay, .gameGearGameGenie: return nil
|
||||
case .masterSystemActionReplay, .masterSystemGameGenie: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var cheatFormat: CheatFormat? {
|
||||
guard
|
||||
let cheatType = self.cheatType,
|
||||
let gameType = self.gameType,
|
||||
let deltaCore = Delta.core(for: gameType)
|
||||
else { return nil }
|
||||
|
||||
let cheatFormat = deltaCore.supportedCheatFormats.first { $0.type == cheatType }
|
||||
return cheatFormat
|
||||
}
|
||||
}
|
||||
45
Delta/Database/Cheats/CheatMetadata.swift
Normal file
45
Delta/Database/Cheats/CheatMetadata.swift
Normal file
@ -0,0 +1,45 @@
|
||||
//
|
||||
// CheatMetadata.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 1/17/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import DeltaCore
|
||||
|
||||
struct CheatCategory: Identifiable, Hashable
|
||||
{
|
||||
var id: Int
|
||||
|
||||
var name: String
|
||||
var categoryDescription: String
|
||||
}
|
||||
|
||||
@objcMembers // @objcMembers required for NSPredicate-based filtering.
|
||||
final class CheatMetadata: NSObject, Identifiable
|
||||
{
|
||||
let id: Int
|
||||
|
||||
let name: String
|
||||
let code: String
|
||||
|
||||
let cheatDescription: String?
|
||||
let activationHint: String?
|
||||
|
||||
let device: CheatDevice
|
||||
let category: CheatCategory
|
||||
|
||||
init(id: Int, name: String, code: String, description: String?, activationHint: String?, device: CheatDevice, category: CheatCategory)
|
||||
{
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.code = code
|
||||
self.cheatDescription = description
|
||||
self.activationHint = activationHint
|
||||
self.device = device
|
||||
self.category = category
|
||||
}
|
||||
}
|
||||
51
Delta/Database/Cheats/LegacySearchBar.swift
Normal file
51
Delta/Database/Cheats/LegacySearchBar.swift
Normal file
@ -0,0 +1,51 @@
|
||||
//
|
||||
// LegacySearchBar.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 1/25/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 13, *)
|
||||
struct LegacySearchBar: UIViewRepresentable
|
||||
{
|
||||
class Coordinator: NSObject, UISearchBarDelegate
|
||||
{
|
||||
@Binding
|
||||
var text: String
|
||||
|
||||
init(text: Binding<String>)
|
||||
{
|
||||
self._text = text
|
||||
}
|
||||
|
||||
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String)
|
||||
{
|
||||
self.text = searchText
|
||||
}
|
||||
}
|
||||
|
||||
@Binding
|
||||
var text: String
|
||||
|
||||
func makeUIView(context: Context) -> UISearchBar
|
||||
{
|
||||
let searchBar = UISearchBar(frame: .zero)
|
||||
searchBar.delegate = context.coordinator
|
||||
searchBar.placeholder = NSLocalizedString("Search", comment: "")
|
||||
return searchBar
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UISearchBar, context: Context)
|
||||
{
|
||||
uiView.text = self.text
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator
|
||||
{
|
||||
return Coordinator(text: $text)
|
||||
}
|
||||
}
|
||||
@ -261,6 +261,23 @@ private extension DatabaseManager
|
||||
try FileManager.default.copyItem(at: bundleURL, to: DatabaseManager.gamesDatabaseURL, shouldReplace: true)
|
||||
}
|
||||
|
||||
if #available(iOS 14, *), !FileManager.default.fileExists(atPath: DatabaseManager.cheatBaseURL.path) || CheatBase.cheatsVersion != CheatBase.previousCheatsVersion
|
||||
{
|
||||
guard let archiveURL = Bundle.main.url(forResource: "cheatbase", withExtension: "zip") else { throw GamesDatabase.Error.doesNotExist }
|
||||
|
||||
let temporaryDirectoryURL = FileManager.default.uniqueTemporaryURL()
|
||||
try FileManager.default.createDirectory(at: temporaryDirectoryURL, withIntermediateDirectories: true)
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: temporaryDirectoryURL)
|
||||
}
|
||||
|
||||
// Unzip to temporaryDirectoryURL first to ensure we don't accidentally unzip other items into DatabaseManager.cheatBaseURL directory (e.g. __MACOSX directory).
|
||||
try FileManager.default.unzipItem(at: archiveURL, to: temporaryDirectoryURL, skipCRC32: true) // skipCRC32 to avoid ~10 second extraction.
|
||||
|
||||
let extractedDatabaseURL = temporaryDirectoryURL.appendingPathComponent("cheatbase.sqlite")
|
||||
try FileManager.default.copyItem(at: extractedDatabaseURL, to: DatabaseManager.cheatBaseURL, shouldReplace: true)
|
||||
}
|
||||
|
||||
self.gamesDatabase = try GamesDatabase()
|
||||
}
|
||||
catch
|
||||
@ -528,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)
|
||||
}
|
||||
@ -619,6 +636,12 @@ extension DatabaseManager
|
||||
return gamesDatabaseURL
|
||||
}
|
||||
|
||||
class var cheatBaseURL: URL
|
||||
{
|
||||
let gamesDatabaseURL = self.defaultDirectoryURL().appendingPathComponent("cheatbase.sqlite")
|
||||
return gamesDatabaseURL
|
||||
}
|
||||
|
||||
class var gamesDirectoryURL: URL
|
||||
{
|
||||
let gamesDirectoryURL = DatabaseManager.defaultDirectoryURL().appendingPathComponent("Games")
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -71,11 +82,6 @@ extension ControllerSkin: ControllerSkinProtocol
|
||||
return self.controllerSkin?.thumbstick(for: item, traits: traits, preferredSize: preferredSize)
|
||||
}
|
||||
|
||||
public func inputs(for traits: DeltaCore.ControllerSkin.Traits, at point: CGPoint) -> [Input]?
|
||||
{
|
||||
return self.controllerSkin?.inputs(for: traits, at: point)
|
||||
}
|
||||
|
||||
public func items(for traits: DeltaCore.ControllerSkin.Traits) -> [DeltaCore.ControllerSkin.Item]?
|
||||
{
|
||||
return self.controllerSkin?.items(for: traits)
|
||||
@ -95,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
|
||||
@ -118,4 +129,9 @@ extension ControllerSkin: Syncable
|
||||
public var syncableLocalizedName: String? {
|
||||
return self.name
|
||||
}
|
||||
|
||||
public func resolveConflict(_ record: AnyRecord) -> ConflictResolution
|
||||
{
|
||||
return .newest
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,7 +24,7 @@ public class Game: _Game, GameProtocol
|
||||
{
|
||||
public var fileURL: URL {
|
||||
var fileURL: URL!
|
||||
|
||||
//通过将指定的路径组件附加到 self 来返回 URL
|
||||
self.managedObjectContext?.performAndWait {
|
||||
fileURL = DatabaseManager.gamesDirectoryURL.appendingPathComponent(self.filename)
|
||||
}
|
||||
@ -102,11 +102,17 @@ extension Game
|
||||
|
||||
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,18 +134,23 @@ 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 }
|
||||
let verifiedGameID = record.remoteMetadata?[.verifiedGameID]
|
||||
|
||||
do
|
||||
{
|
||||
guard let game = self.game else { return }
|
||||
|
||||
if let system = System(gameType: game.type), self.coreIdentifier == nil
|
||||
{
|
||||
if let coreIdentifier = record.remoteMetadata?[.coreID]
|
||||
{
|
||||
// SaveState was synced to older version of Delta and lost its coreIdentifier,
|
||||
@ -161,4 +166,29 @@ extension SaveState: Syncable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 */
|
||||
|
||||
@ -11,15 +11,17 @@ import Foundation
|
||||
// Must be an NSObject subclass so it can be used with RSTCellContentDataSource.
|
||||
class GameMetadata: NSObject
|
||||
{
|
||||
let identifier: Int
|
||||
let releaseID: Int
|
||||
let romID: Int
|
||||
|
||||
let name: String?
|
||||
let artworkURL: URL?
|
||||
|
||||
init(identifier: Int, name: String?, artworkURL: URL?)
|
||||
init(releaseID: Int, romID: Int, name: String?, artworkURL: URL?)
|
||||
{
|
||||
self.releaseID = releaseID
|
||||
self.romID = romID
|
||||
self.name = name
|
||||
self.identifier = identifier
|
||||
self.artworkURL = artworkURL
|
||||
}
|
||||
}
|
||||
@ -27,13 +29,13 @@ class GameMetadata: NSObject
|
||||
extension GameMetadata
|
||||
{
|
||||
override var hash: Int {
|
||||
return self.identifier.hashValue
|
||||
return self.releaseID.hashValue ^ self.romID.hashValue
|
||||
}
|
||||
|
||||
override func isEqual(_ object: Any?) -> Bool
|
||||
{
|
||||
guard let metadata = object as? GameMetadata else { return false }
|
||||
|
||||
return self.identifier == metadata.identifier
|
||||
return self.releaseID == metadata.releaseID && self.romID == metadata.romID
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,16 +57,23 @@ extension VirtualTable
|
||||
|
||||
extension GamesDatabase
|
||||
{
|
||||
enum Error: Swift.Error
|
||||
enum Error: LocalizedError
|
||||
{
|
||||
case doesNotExist
|
||||
case connection(Swift.Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self
|
||||
{
|
||||
case .doesNotExist:
|
||||
return NSLocalizedString("The SQLite database could not be found.", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GamesDatabase
|
||||
{
|
||||
static let version = 2
|
||||
static let version = 3
|
||||
static var previousVersion: Int? {
|
||||
return UserDefaults.standard.previousGamesDatabaseVersion
|
||||
}
|
||||
@ -83,7 +90,7 @@ class GamesDatabase
|
||||
}
|
||||
catch
|
||||
{
|
||||
throw Error.connection(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
self.invalidateVirtualTableIfNeeded()
|
||||
@ -92,10 +99,11 @@ class GamesDatabase
|
||||
func metadataResults(forGameName gameName: String) -> [GameMetadata]
|
||||
{
|
||||
let releaseID = Expression<Any>.releaseID
|
||||
let romID = Expression<Any>.romID
|
||||
let name = Expression<Any>.name
|
||||
let artworkAddress = Expression<Any>.artworkAddress
|
||||
|
||||
let query = VirtualTable.search.select(releaseID, name, artworkAddress).filter(name.match(gameName + "*"))
|
||||
let query = VirtualTable.search.select(releaseID, romID, name, artworkAddress).filter(name.match(gameName + "*"))
|
||||
|
||||
do
|
||||
{
|
||||
@ -114,7 +122,7 @@ class GamesDatabase
|
||||
}
|
||||
|
||||
|
||||
let metadata = GameMetadata(identifier: row[releaseID], name: row[name], artworkURL: artworkURL)
|
||||
let metadata = GameMetadata(releaseID: row[releaseID], romID: row[romID], name: row[name], artworkURL: artworkURL)
|
||||
return metadata
|
||||
}
|
||||
|
||||
@ -148,7 +156,7 @@ class GamesDatabase
|
||||
let romID = Expression<Any>.romID
|
||||
|
||||
let gameHash = game.identifier.uppercased()
|
||||
let query = Table.roms.select(releaseID, name, artworkAddress).filter(sha1Hash == gameHash).join(Table.releases, on: Table.roms[romID] == Table.releases[romID])
|
||||
let query = Table.roms.select(releaseID, name, artworkAddress, Table.roms[romID]).filter(sha1Hash == gameHash).join(Table.releases, on: Table.roms[romID] == Table.releases[romID])
|
||||
|
||||
do
|
||||
{
|
||||
@ -164,7 +172,7 @@ class GamesDatabase
|
||||
artworkURL = nil
|
||||
}
|
||||
|
||||
let metadata = GameMetadata(identifier: row[releaseID], name: row[name], artworkURL: artworkURL)
|
||||
let metadata = GameMetadata(releaseID: row[releaseID], romID: row[Table.roms[romID]], name: row[name], artworkURL: artworkURL)
|
||||
return metadata
|
||||
}
|
||||
}
|
||||
@ -200,12 +208,13 @@ private extension GamesDatabase
|
||||
let name = Expression<Any>.name
|
||||
let artworkAddress = Expression<Any>.artworkAddress
|
||||
let releaseID = Expression<Any>.releaseID
|
||||
let romID = Expression<Any>.romID
|
||||
|
||||
do
|
||||
{
|
||||
try self.connection.run(VirtualTable.search.create(.FTS4([releaseID, name, artworkAddress], tokenize: .Unicode61())))
|
||||
try self.connection.run(VirtualTable.search.create(.FTS4([releaseID, romID, name, artworkAddress], tokenize: .Unicode61())))
|
||||
|
||||
let update = VirtualTable.search.insert(Table.releases.select(releaseID, name, artworkAddress))
|
||||
let update = VirtualTable.search.insert(Table.releases.select(releaseID, romID, name, artworkAddress))
|
||||
_ = try self.connection.run(update)
|
||||
}
|
||||
catch
|
||||
|
||||
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: "")
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,8 @@ extension UIActivity.ActivityType
|
||||
|
||||
class CopyDeepLinkActivity: UIActivity
|
||||
{
|
||||
private var deepLink: URL?
|
||||
|
||||
override class var activityCategory: UIActivity.Category {
|
||||
return .action
|
||||
}
|
||||
@ -28,7 +30,7 @@ class CopyDeepLinkActivity: UIActivity
|
||||
}
|
||||
|
||||
override var activityImage: UIImage? {
|
||||
return UIImage(named: "Link")
|
||||
return UIImage(symbolNameIfAvailable: "link") ?? UIImage(named: "Link")
|
||||
}
|
||||
|
||||
override func canPerform(withActivityItems activityItems: [Any]) -> Bool
|
||||
@ -47,7 +49,19 @@ class CopyDeepLinkActivity: UIActivity
|
||||
{
|
||||
guard let game = activityItems.first(where: { $0 is Game }) as? Game else { return }
|
||||
|
||||
let deepLink = URL(action: .launchGame(identifier: game.identifier))
|
||||
self.deepLink = URL(action: .launchGame(identifier: game.identifier))
|
||||
}
|
||||
|
||||
override func perform()
|
||||
{
|
||||
if let deepLink = self.deepLink
|
||||
{
|
||||
UIPasteboard.general.url = deepLink
|
||||
self.activityDidFinish(true)
|
||||
}
|
||||
else
|
||||
{
|
||||
self.activityDidFinish(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,9 +23,17 @@ extension UIViewController
|
||||
struct DeepLinkController
|
||||
{
|
||||
private var window: UIWindow? {
|
||||
if #available(iOS 13, *)
|
||||
{
|
||||
guard let delegate = UIApplication.shared.connectedScenes.lazy.compactMap({ $0.delegate as? UIWindowSceneDelegate }).first, let window = delegate.window else { return nil }
|
||||
return window
|
||||
}
|
||||
else
|
||||
{
|
||||
guard let delegate = UIApplication.shared.delegate, let window = delegate.window else { return nil }
|
||||
return window
|
||||
}
|
||||
}
|
||||
|
||||
private var topViewController: UIViewController? {
|
||||
guard let window = self.window else { return nil }
|
||||
|
||||
@ -7,14 +7,17 @@
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
import DeltaCore
|
||||
import GBADeltaCore
|
||||
import MelonDSDeltaCore
|
||||
import Systems
|
||||
|
||||
import struct DSDeltaCore.DS
|
||||
|
||||
import Roxas
|
||||
import AltKit
|
||||
|
||||
private var kvoContext = 0
|
||||
|
||||
@ -59,6 +62,7 @@ private extension GameViewController
|
||||
}
|
||||
|
||||
// Only intercept controller skin inputs.
|
||||
// 只拦截控制器皮肤输入。
|
||||
guard controllerInput.type == .controller(.controllerSkin) else { return nil }
|
||||
|
||||
let actionInput = ActionInput(stringValue: controllerInput.stringValue)
|
||||
@ -89,6 +93,7 @@ private extension GameViewController
|
||||
class GameViewController: DeltaCore.GameViewController
|
||||
{
|
||||
/// Assumed to be Delta.Game instance
|
||||
/// /// 假设为 Delta.Game 实例
|
||||
override var game: GameProtocol? {
|
||||
willSet {
|
||||
self.emulatorCore?.removeObserver(self, forKeyPath: #keyPath(EmulatorCore.state), context: &kvoContext)
|
||||
@ -110,6 +115,7 @@ class GameViewController: DeltaCore.GameViewController
|
||||
}
|
||||
|
||||
self.updateControllers()
|
||||
self.updateAudio()
|
||||
|
||||
self.presentedGyroAlert = false
|
||||
}
|
||||
@ -167,6 +173,8 @@ class GameViewController: DeltaCore.GameViewController
|
||||
private var isGyroActive = false
|
||||
private var presentedGyroAlert = false
|
||||
|
||||
private var presentedJITAlert = false
|
||||
|
||||
override var shouldAutorotate: Bool {
|
||||
return !self.isGyroActive
|
||||
}
|
||||
@ -175,6 +183,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()
|
||||
@ -198,13 +214,18 @@ 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)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(GameViewController.didDeactivateGyro(with:)), name: GBA.didDeactivateGyroNotification, object: nil)
|
||||
|
||||
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
|
||||
@ -330,12 +351,18 @@ extension GameViewController
|
||||
|
||||
UserDefaults.standard.desmumeDeprecatedAlertCount += 1
|
||||
}
|
||||
else if self.emulatorCore?.deltaCore == MelonDS.core, ProcessInfo.processInfo.isJITAvailable
|
||||
{
|
||||
self.showJITEnabledAlert()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator)
|
||||
{
|
||||
super.viewWillTransition(to: size, with: coordinator)
|
||||
|
||||
guard UIApplication.shared.applicationState != .background else { return }
|
||||
|
||||
coordinator.animate(alongsideTransition: { (context) in
|
||||
self.updateControllerSkin()
|
||||
}, completion: nil)
|
||||
@ -391,6 +418,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
|
||||
@ -474,6 +504,13 @@ extension GameViewController
|
||||
}
|
||||
|
||||
self._isLoadingSaveState = false
|
||||
|
||||
if self.emulatorCore?.deltaCore == MelonDS.core, ProcessInfo.processInfo.isJITAvailable
|
||||
{
|
||||
self.transitionCoordinator?.animate(alongsideTransition: nil, completion: { (context) in
|
||||
self.showJITEnabledAlert()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
case "unwindToGames":
|
||||
@ -646,19 +683,64 @@ private extension GameViewController
|
||||
else if let controllerSkin = DeltaCore.ControllerSkin.standardControllerSkin(for: game.type), controllerSkin.hasTouchScreen(for: traits)
|
||||
{
|
||||
var touchControllerSkin = TouchControllerSkin(controllerSkin: controllerSkin)
|
||||
touchControllerSkin.layoutGuide = self.view.safeAreaLayoutGuide
|
||||
|
||||
switch traits.orientation
|
||||
if UIApplication.shared.isExternalDisplayConnected
|
||||
{
|
||||
case .portrait: touchControllerSkin.screenLayoutAxis = .vertical
|
||||
case .landscape: touchControllerSkin.screenLayoutAxis = .horizontal
|
||||
// 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
|
||||
}
|
||||
else
|
||||
{
|
||||
touchControllerSkin.screenLayoutAxis = .vertical
|
||||
}
|
||||
|
||||
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 -
|
||||
@ -675,23 +757,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
|
||||
{
|
||||
@ -810,6 +899,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()
|
||||
@ -857,6 +952,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
|
||||
{
|
||||
@ -914,6 +1014,16 @@ extension GameViewController: CheatsViewControllerDelegate
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - Audio -
|
||||
/// Audio
|
||||
private extension GameViewController
|
||||
{
|
||||
func updateAudio()
|
||||
{
|
||||
self.emulatorCore?.audioManager.respectsSilentMode = Settings.respectSilentMode
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - Sustain Buttons -
|
||||
private extension GameViewController
|
||||
{
|
||||
@ -934,6 +1044,8 @@ private extension GameViewController
|
||||
UIView.animate(withDuration: 0.4) {
|
||||
self.sustainButtonsBlurView.effect = blurEffect
|
||||
self.sustainButtonsBackgroundView.alpha = 1.0
|
||||
} completion: { _ in
|
||||
self.controllerView.becomeFirstResponder()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1028,14 +1140,191 @@ extension GameViewController
|
||||
guard let emulatorCore = self.emulatorCore else { return }
|
||||
|
||||
if activate
|
||||
{
|
||||
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 -
|
||||
@ -1044,6 +1333,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 }
|
||||
@ -1061,12 +1352,16 @@ extension GameViewController: GameViewControllerDelegate
|
||||
else if self.presentedViewController == nil
|
||||
{
|
||||
self.pauseEmulation()
|
||||
self.controllerView.resignFirstResponder()
|
||||
|
||||
self.performSegue(withIdentifier: "pause", sender: gameController)
|
||||
}
|
||||
}
|
||||
|
||||
func gameViewControllerShouldResumeEmulation(_ gameViewController: DeltaCore.GameViewController) -> Bool
|
||||
{
|
||||
guard gameViewController == self else { return false }
|
||||
|
||||
var result = false
|
||||
|
||||
rst_dispatch_sync_on_main_thread {
|
||||
@ -1075,6 +1370,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
|
||||
@ -1085,6 +1394,48 @@ private extension GameViewController
|
||||
toastView.presentationEdge = .top
|
||||
toastView.show(in: self.view, duration: duration)
|
||||
}
|
||||
|
||||
func showJITEnabledAlert()
|
||||
{
|
||||
guard !self.presentedJITAlert, self.presentedViewController == nil, self.game != nil else { return }
|
||||
self.presentedJITAlert = true
|
||||
|
||||
func presentToastView()
|
||||
{
|
||||
let detailText: String?
|
||||
let duration: TimeInterval
|
||||
|
||||
if UserDefaults.standard.jitEnabledAlertCount < 3
|
||||
{
|
||||
detailText = NSLocalizedString("You can now Fast Forward DS games up to 3x speed.", comment: "")
|
||||
duration = 5.0
|
||||
}
|
||||
else
|
||||
{
|
||||
detailText = nil
|
||||
duration = 2.0
|
||||
}
|
||||
|
||||
let toastView = RSTToastView(text: NSLocalizedString("JIT Compilation Enabled", comment: ""), detailText: detailText)
|
||||
toastView.edgeOffset.vertical = 8
|
||||
self.show(toastView, duration: duration)
|
||||
|
||||
UserDefaults.standard.jitEnabledAlertCount += 1
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if let transitionCoordinator = self.transitionCoordinator
|
||||
{
|
||||
transitionCoordinator.animate(alongsideTransition: nil) { (context) in
|
||||
presentToastView()
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
presentToastView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - Notifications -
|
||||
@ -1127,9 +1478,24 @@ private extension GameViewController
|
||||
self.updateControllerSkin()
|
||||
}
|
||||
|
||||
case .translucentControllerSkinOpacity: self.controllerView.translucentControllerSkinOpacity = Settings.translucentControllerSkinOpacity
|
||||
case .translucentControllerSkinOpacity:
|
||||
self.controllerView.translucentControllerSkinOpacity = Settings.translucentControllerSkinOpacity
|
||||
|
||||
case .syncingService: break
|
||||
case .respectSilentMode:
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -1207,6 +1573,60 @@ private extension GameViewController
|
||||
self.isGyroActive = false
|
||||
}
|
||||
|
||||
@objc func didEnableJIT(with notification: Notification)
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
self.showJITEnabledAlert()
|
||||
}
|
||||
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
guard let emulatorCore = self.emulatorCore, let emulatorBridge = emulatorCore.deltaCore.emulatorBridge as? MelonDSEmulatorBridge, !emulatorBridge.isJITEnabled
|
||||
else { return }
|
||||
|
||||
guard emulatorCore.state != .stopped else {
|
||||
// Emulator core is not running, which means we can set
|
||||
// isJITEnabled to true without resetting the core.
|
||||
emulatorBridge.isJITEnabled = true
|
||||
return
|
||||
}
|
||||
|
||||
let isVideoEnabled = emulatorCore.videoManager.isEnabled
|
||||
emulatorCore.videoManager.isEnabled = false
|
||||
|
||||
let isRunning = (emulatorCore.state == .running)
|
||||
if isRunning
|
||||
{
|
||||
self.pauseEmulation()
|
||||
}
|
||||
|
||||
let temporaryFileURL = FileManager.default.uniqueTemporaryURL()
|
||||
|
||||
let saveState = emulatorCore.saveSaveState(to: temporaryFileURL)
|
||||
emulatorCore.stop()
|
||||
|
||||
emulatorBridge.isJITEnabled = true
|
||||
|
||||
emulatorCore.start()
|
||||
emulatorCore.pause()
|
||||
|
||||
do
|
||||
{
|
||||
try emulatorCore.load(saveState)
|
||||
}
|
||||
catch
|
||||
{
|
||||
print("Failed to load save state after enabling JIT.", error)
|
||||
}
|
||||
|
||||
if isRunning
|
||||
{
|
||||
self.resumeEmulation()
|
||||
}
|
||||
|
||||
emulatorCore.videoManager.isEnabled = isVideoEnabled
|
||||
}
|
||||
}
|
||||
|
||||
@objc func emulationDidQuit(with notification: Notification)
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
@ -1226,9 +1646,23 @@ 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
|
||||
{
|
||||
@NSManaged var desmumeDeprecatedAlertCount: Int
|
||||
|
||||
@NSManaged var jitEnabledAlertCount: Int
|
||||
}
|
||||
|
||||
@ -15,12 +15,15 @@ private var kvoContext = 0
|
||||
class PreviewGameViewController: DeltaCore.GameViewController
|
||||
{
|
||||
// If non-nil, will override the default preview action items returned in previewActionItems()
|
||||
// 如果非nil,将覆盖previewActionItems()中返回的默认预览操作项
|
||||
var overridePreviewActionItems: [UIPreviewActionItem]?
|
||||
|
||||
// Save state to be loaded upon preview
|
||||
// 保存预览时加载的状态
|
||||
var previewSaveState: SaveStateProtocol?
|
||||
|
||||
// Initial image to be shown while loading
|
||||
// 加载时显示的初始图像
|
||||
var previewImage: UIImage? {
|
||||
didSet {
|
||||
self.updatePreviewImage()
|
||||
@ -78,6 +81,8 @@ class PreviewGameViewController: DeltaCore.GameViewController
|
||||
{
|
||||
// Explicitly stop emulatorCore _before_ we remove ourselves as observer
|
||||
// so we can wait until stopped before restoring save files (again).
|
||||
// 在我们删除自己作为观察者之前显式停止 emulatorCore
|
||||
// 所以我们可以等到停止后再恢复保存文件(再次)。
|
||||
self.emulatorCore?.stop()
|
||||
|
||||
self.emulatorCore?.removeObserver(self, forKeyPath: #keyPath(EmulatorCore.state), context: &kvoContext)
|
||||
@ -93,9 +98,10 @@ extension PreviewGameViewController
|
||||
super.viewDidLoad()
|
||||
|
||||
self.controllerView.isHidden = true
|
||||
self.controllerView.controllerSkin = nil // Skip loading controller skin from disk, which may be slow.
|
||||
self.controllerView.controllerSkin = nil // Skip loading controller skin from disk, which may be slow.跳过从磁盘加载控制器皮肤,这可能会很慢。
|
||||
|
||||
// Temporarily prevent emulatorCore from updating gameView to prevent flicker of black, or other visual glitches
|
||||
// 暂时阻止 emulatorCore 更新 gameView 以防止黑色闪烁或其他视觉故障
|
||||
self.emulatorCore?.remove(self.gameView)
|
||||
}
|
||||
|
||||
@ -120,6 +126,7 @@ extension PreviewGameViewController
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
// Pause in viewWillDisappear and not viewDidDisappear like DeltaCore.GameViewController so the audio cuts off earlier if being dismissed
|
||||
// 在 viewWillDisappear 中暂停,而不是像 DeltaCore.GameViewController 那样在 viewDidDisappear 中暂停,因此如果被关闭,音频会更早切断
|
||||
self.emulatorCore?.pause()
|
||||
}
|
||||
|
||||
@ -128,10 +135,13 @@ extension PreviewGameViewController
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
// Already stopped = we've already restored save files and removed directory.
|
||||
// 已经停止 = 我们已经恢复了保存文件并删除了目录。
|
||||
if self.emulatorCore?.state != .stopped
|
||||
{
|
||||
// Pre-emptively restore save files in case something goes wrong while stopping emulation.
|
||||
// This also ensures if the core is never stopped (for some reason), saves are still restored.
|
||||
// 抢先恢复保存文件,以防在停止仿真时出现问题。
|
||||
// 这也确保如果核心从未停止(由于某种原因),保存仍然可以恢复。
|
||||
self.restoreSaveFiles(removeCopyDirectory: false)
|
||||
}
|
||||
}
|
||||
@ -141,6 +151,8 @@ extension PreviewGameViewController
|
||||
super.viewDidLayoutSubviews()
|
||||
|
||||
// Need to update in viewDidLayoutSubviews() to ensure bounds of gameView are not CGRect.zero
|
||||
|
||||
// 需要在 viewDidLayoutSubviews() 中进行更新以确保 gameView 的边界不是 CGRect.zero
|
||||
self.updatePreviewImage()
|
||||
}
|
||||
|
||||
@ -148,6 +160,7 @@ extension PreviewGameViewController
|
||||
{
|
||||
super.didReceiveMemoryWarning()
|
||||
// Dispose of any resources that can be recreated.
|
||||
// 处理掉所有可以重新创建的资源。
|
||||
}
|
||||
|
||||
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?)
|
||||
@ -165,6 +178,7 @@ extension PreviewGameViewController
|
||||
case .running where previousState == .stopped:
|
||||
self.emulatorCoreQueue.async {
|
||||
// Pause to prevent it from starting before visible (in case user peeked slowly)
|
||||
// 暂停以防止它在可见之前启动(以防用户缓慢地偷看)
|
||||
self.emulatorCore?.pause()
|
||||
self.preparePreview()
|
||||
}
|
||||
@ -172,6 +186,8 @@ extension PreviewGameViewController
|
||||
case .stopped:
|
||||
// Emulation has stopped, so we can safely restore save files,
|
||||
// and also remove the directory they were copied to.
|
||||
// 模拟已停止,因此我们可以安全地恢复保存文件,
|
||||
// 并删除它们复制到的目录。
|
||||
self.restoreSaveFiles(removeCopyDirectory: true)
|
||||
|
||||
default: break
|
||||
@ -224,6 +240,7 @@ private extension PreviewGameViewController
|
||||
self.emulatorCore?.updateCheats()
|
||||
|
||||
// Re-enable emulatorCore to update gameView again
|
||||
// 重新启用emulatorCore以再次更新gameView
|
||||
self.emulatorCore?.add(self.gameView)
|
||||
|
||||
self.emulatorCore?.resume()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Delta/Extensions/CharacterSet+Filename.swift
Normal file
22
Delta/Extensions/CharacterSet+Filename.swift
Normal file
@ -0,0 +1,22 @@
|
||||
//
|
||||
// CharacterSet+Filename.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 4/28/22.
|
||||
// Copyright © 2022 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
extension CharacterSet
|
||||
{
|
||||
// Different than .urlPathAllowed
|
||||
// Copied from https://stackoverflow.com/a/39443252
|
||||
static var urlFilenameAllowed: CharacterSet {
|
||||
var illegalCharacters = CharacterSet(charactersIn: ":/")
|
||||
illegalCharacters.formUnion(.newlines)
|
||||
illegalCharacters.formUnion(.illegalCharacters)
|
||||
illegalCharacters.formUnion(.controlCharacters)
|
||||
return illegalCharacters.inverted
|
||||
}
|
||||
}
|
||||
@ -34,20 +34,19 @@ 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")")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
41
Delta/Extensions/ProcessInfo+JIT.swift
Normal file
41
Delta/Extensions/ProcessInfo+JIT.swift
Normal file
@ -0,0 +1,41 @@
|
||||
//
|
||||
// ProcessInfo+JIT.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 9/14/21.
|
||||
// Copyright © 2021 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
private let CS_OPS_STATUS: UInt32 = 0 /* OK */
|
||||
private let CS_DEBUGGED: UInt32 = 0x10000000 /* Process is or has been attached to debugger. */
|
||||
|
||||
@_silgen_name("csops")
|
||||
func csops(_ pid: pid_t, _ ops: UInt32, _ useraddr: UnsafeMutableRawPointer?, _ usersize: Int) -> Int
|
||||
|
||||
extension ProcessInfo
|
||||
{
|
||||
static var isJITDisabled = false
|
||||
|
||||
var isDebugging: Bool {
|
||||
var flags: UInt32 = 0
|
||||
let result = csops(getpid(), CS_OPS_STATUS, &flags, MemoryLayout<UInt32>.size)
|
||||
|
||||
let isDebugging = result == 0 && (flags & CS_DEBUGGED == CS_DEBUGGED)
|
||||
return isDebugging
|
||||
}
|
||||
|
||||
var isJITAvailable: Bool {
|
||||
guard UIDevice.current.supportsJIT && !ProcessInfo.isJITDisabled else { return false }
|
||||
|
||||
let ios14_4 = OperatingSystemVersion(majorVersion: 14, minorVersion: 4, patchVersion: 0)
|
||||
if #available(iOS 14.2, *), !ProcessInfo.processInfo.isOperatingSystemAtLeast(ios14_4)
|
||||
{
|
||||
// JIT is always available on supported devices running iOS 14.2 - 14.3.
|
||||
return true
|
||||
}
|
||||
|
||||
return self.isDebugging
|
||||
}
|
||||
}
|
||||
104
Delta/Extensions/ServerManager+Delta.swift
Normal file
104
Delta/Extensions/ServerManager+Delta.swift
Normal file
@ -0,0 +1,104 @@
|
||||
//
|
||||
// ServerManager+Delta.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 9/15/21.
|
||||
// Copyright © 2021 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import AltKit
|
||||
|
||||
extension ServerManager
|
||||
{
|
||||
static let didEnableJITNotification = Notification.Name("didEnableJITNotification")
|
||||
}
|
||||
|
||||
extension ServerManager
|
||||
{
|
||||
func prepare()
|
||||
{
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(ServerManager.didChangeJITMode(_:)), name: Settings.didChangeNotification, object: nil)
|
||||
|
||||
#if DEBUG
|
||||
if ProcessInfo.processInfo.isDebugging
|
||||
{
|
||||
// Debugger is attached at app launch, so we assume
|
||||
// we're connected to Xcode for debugging purposes.
|
||||
// In that case, we manually treat JIT as unavailable
|
||||
// until AltServer is discovered to simulate real-world use.
|
||||
ProcessInfo.isJITDisabled = true
|
||||
}
|
||||
#endif
|
||||
|
||||
self.start()
|
||||
}
|
||||
}
|
||||
|
||||
private extension ServerManager
|
||||
{
|
||||
func start()
|
||||
{
|
||||
guard Settings.isAltJITEnabled && !ProcessInfo.processInfo.isJITAvailable else { return }
|
||||
|
||||
self.startDiscovering()
|
||||
self.autoconnect()
|
||||
}
|
||||
|
||||
func autoconnect()
|
||||
{
|
||||
self.autoconnect { result in
|
||||
switch result
|
||||
{
|
||||
case .failure(let error):
|
||||
print("Could not auto-connect to server.", error)
|
||||
self.autoconnect()
|
||||
|
||||
case .success(let connection):
|
||||
func finish(result: Result<Void, Error>)
|
||||
{
|
||||
switch result
|
||||
{
|
||||
case .failure(ALTServerError.unknownRequest), .failure(ALTServerError.deviceNotFound):
|
||||
// Try connecting to a different server.
|
||||
self.autoconnect()
|
||||
|
||||
case .failure(let error):
|
||||
print("Could not enable JIT compilation.", error)
|
||||
|
||||
case .success:
|
||||
print("Successfully enabled JIT compilation!")
|
||||
|
||||
NotificationCenter.default.post(name: ServerManager.didEnableJITNotification, object: nil)
|
||||
self.stopDiscovering()
|
||||
}
|
||||
|
||||
connection.disconnect()
|
||||
}
|
||||
|
||||
if ProcessInfo.isJITDisabled
|
||||
{
|
||||
ProcessInfo.isJITDisabled = false
|
||||
finish(result: .success(()))
|
||||
}
|
||||
else
|
||||
{
|
||||
connection.enableUnsignedCodeExecution(completion: finish)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func didChangeJITMode(_ notification: Notification)
|
||||
{
|
||||
guard let name = notification.userInfo?[Settings.NotificationUserInfoKey.name] as? Settings.Name, name == Settings.Name.isAltJITEnabled else { return }
|
||||
|
||||
if Settings.isAltJITEnabled
|
||||
{
|
||||
self.start()
|
||||
}
|
||||
else
|
||||
{
|
||||
self.stopDiscovering()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,6 @@ import UIKit
|
||||
|
||||
extension UIColor
|
||||
{
|
||||
static let deltaPurple = UIColor(named: "Purple")!
|
||||
static let deltaDarkGray = UIColor(named: "DarkGray")!
|
||||
static let deltaPurple = UIColor.purple
|
||||
static let deltaDarkGray = #colorLiteral(red: 0.07139974087, green: 0.08217515796, blue: 0.1083263531, alpha: 1)
|
||||
}
|
||||
|
||||
@ -26,12 +26,9 @@ extension UIDevice
|
||||
}
|
||||
|
||||
var supportsJIT: Bool {
|
||||
// As of iOS 14.4 beta 2, JIT is no longer supported :(
|
||||
// Hopefully this change is reversed before the public release...
|
||||
let ios14_4 = OperatingSystemVersion(majorVersion: 14, minorVersion: 4, patchVersion: 0)
|
||||
guard #available(iOS 14.2, *), !ProcessInfo.processInfo.isOperatingSystemAtLeast(ios14_4) else { return false }
|
||||
guard #available(iOS 14.0, *) else { return false }
|
||||
|
||||
// JIT is supported on devices with an A12 processor or better running iOS 14.2 or later.
|
||||
// JIT is supported on devices with an A12 processor or better running iOS 14.0 or later.
|
||||
// ARKit 3 is only supported by devices with an A12 processor or better, according to the documentation.
|
||||
return ARBodyTrackingConfiguration.isSupported
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -43,6 +43,8 @@ class GameCollectionViewController: UICollectionViewController
|
||||
|
||||
// Calling reloadData sometimes will not update the cells correctly if an insertion/deletion animation is in progress
|
||||
// As a workaround, we manually iterate over and configure each cell ourselves
|
||||
// 如果正在进行插入/删除动画,调用 reloadData 有时不会正确更新单元格
|
||||
// 作为解决方法,我们自己手动迭代并配置每个单元
|
||||
for cell in self.collectionView?.visibleCells ?? []
|
||||
{
|
||||
if let indexPath = self.collectionView?.indexPath(for: cell)
|
||||
@ -66,6 +68,8 @@ class GameCollectionViewController: UICollectionViewController
|
||||
private weak var _previewTransitionViewController: PreviewGameViewController?
|
||||
private weak var _previewTransitionDestinationViewController: UIViewController?
|
||||
|
||||
private weak var _popoverSourceView: UIView?
|
||||
|
||||
private var _renameAction: UIAlertAction?
|
||||
private var _changingArtworkGame: Game?
|
||||
private var _importingSaveFileGame: Game?
|
||||
@ -93,10 +97,6 @@ extension GameCollectionViewController
|
||||
self.collectionView?.prefetchDataSource = self.dataSource
|
||||
self.collectionView?.delegate = self
|
||||
|
||||
let layout = self.collectionViewLayout as! GridCollectionViewLayout
|
||||
layout.itemWidth = 90
|
||||
layout.minimumInteritemSpacing = 12
|
||||
|
||||
if #available(iOS 13, *) {}
|
||||
else
|
||||
{
|
||||
@ -105,6 +105,8 @@ extension GameCollectionViewController
|
||||
let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(GameCollectionViewController.handleLongPressGesture(_:)))
|
||||
self.collectionView?.addGestureRecognizer(longPressGestureRecognizer)
|
||||
}
|
||||
|
||||
self.update()
|
||||
}
|
||||
|
||||
override func viewWillDisappear(_ animated: Bool)
|
||||
@ -131,6 +133,13 @@ extension GameCollectionViewController
|
||||
super.didReceiveMemoryWarning()
|
||||
// Dispose of any resources that can be recreated.
|
||||
}
|
||||
|
||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?)
|
||||
{
|
||||
super.traitCollectionDidChange(previousTraitCollection)
|
||||
|
||||
self.update()
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - Segues -
|
||||
@ -170,6 +179,7 @@ extension GameCollectionViewController
|
||||
if let emulatorBridge = destinationViewController.emulatorCore?.deltaCore.emulatorBridge as? MelonDSEmulatorBridge
|
||||
{
|
||||
//TODO: Update this to work with multiple processes by retrieving emulatorBridge directly from emulatorCore.
|
||||
//TODO:通过直接从 emulatorCore 检索 emulatorBridge 来更新它以与多个进程一起使用。
|
||||
|
||||
if game.identifier == Game.melonDSDSiBIOSIdentifier
|
||||
{
|
||||
@ -180,12 +190,14 @@ extension GameCollectionViewController
|
||||
emulatorBridge.systemType = .ds
|
||||
}
|
||||
|
||||
emulatorBridge.isJITEnabled = UIDevice.current.supportsJIT
|
||||
emulatorBridge.isJITEnabled = ProcessInfo.processInfo.isJITAvailable
|
||||
}
|
||||
|
||||
if let saveState = self.activeSaveState
|
||||
{
|
||||
// Must be synchronous or else there will be a flash of black
|
||||
|
||||
// 必须同步否则会有黑闪
|
||||
destinationViewController.emulatorCore?.start()
|
||||
destinationViewController.emulatorCore?.pause()
|
||||
|
||||
@ -224,6 +236,27 @@ extension GameCollectionViewController
|
||||
//MARK: - Private Methods -
|
||||
private extension GameCollectionViewController
|
||||
{
|
||||
func update()
|
||||
{
|
||||
let layout = self.collectionViewLayout as! GridCollectionViewLayout
|
||||
|
||||
switch self.traitCollection.horizontalSizeClass
|
||||
{
|
||||
case .regular:
|
||||
layout.itemWidth = 150
|
||||
layout.minimumInteritemSpacing = 25 // 30 == only 3 games per line for iPad mini 6 in portrait
|
||||
// 30 == 对于纵向 iPad mini 6,每行仅 3 场游戏
|
||||
|
||||
case .unspecified, .compact:
|
||||
layout.itemWidth = 90
|
||||
layout.minimumInteritemSpacing = 12
|
||||
|
||||
@unknown default: break
|
||||
}
|
||||
|
||||
self.collectionView.reloadData()
|
||||
}
|
||||
|
||||
//MARK: - Data Source
|
||||
func prepareDataSource()
|
||||
{
|
||||
@ -282,9 +315,21 @@ private extension GameCollectionViewController
|
||||
cell.isImageViewVibrancyEnabled = true
|
||||
}
|
||||
|
||||
cell.imageView.image = #imageLiteral(resourceName: "BoxArt")
|
||||
cell.imageView.image = #imageLiteral(resourceName: "zw")
|
||||
|
||||
if self.traitCollection.horizontalSizeClass == .regular
|
||||
{
|
||||
let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .subheadline).withSymbolicTraits(.traitBold)!
|
||||
cell.textLabel.font = UIFont(descriptor: fontDescriptor, size: 0)
|
||||
}
|
||||
else
|
||||
{
|
||||
cell.textLabel.font = UIFont.preferredFont(forTextStyle: .caption1)
|
||||
}
|
||||
|
||||
let layout = self.collectionViewLayout as! GridCollectionViewLayout
|
||||
cell.maximumImageSize = CGSize(width: layout.itemWidth, height: layout.itemWidth)
|
||||
|
||||
cell.maximumImageSize = CGSize(width: 90, height: 90)
|
||||
cell.textLabel.text = game.name
|
||||
cell.textLabel.textColor = UIColor.gray
|
||||
cell.tintColor = cell.textLabel.textColor
|
||||
@ -330,11 +375,13 @@ private extension GameCollectionViewController
|
||||
}
|
||||
|
||||
// Disable videoManager to prevent flash of black
|
||||
// 禁用videoManager以防止黑屏闪烁
|
||||
self.activeEmulatorCore?.videoManager.isEnabled = false
|
||||
|
||||
launchGame(ignoringErrors: [LaunchError.alreadyRunning])
|
||||
|
||||
// The game hasn't changed, so the activeEmulatorCore is the same as before, so we need to enable videoManager it again
|
||||
// 游戏没有改变,所以activeEmulatorCore和之前一样,所以我们需要再次启用videoManager
|
||||
self.activeEmulatorCore?.videoManager.isEnabled = true
|
||||
}))
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Restart", comment: ""), style: .destructive, handler: { (action) in
|
||||
@ -374,7 +421,7 @@ private extension GameCollectionViewController
|
||||
launchGame(ignoringErrors: [])
|
||||
}
|
||||
}
|
||||
|
||||
//验证启动游戏
|
||||
func validateLaunchingGame(_ game: Game, ignoringErrors ignoredErrors: [Error]) throws
|
||||
{
|
||||
let ignoredErrors = ignoredErrors.map { $0 as NSError }
|
||||
@ -484,7 +531,9 @@ private extension GameCollectionViewController
|
||||
|
||||
func delete(_ game: Game)
|
||||
{
|
||||
let confirmationAlertController = UIAlertController(title: NSLocalizedString("Are you sure you want to delete this game? All associated data, such as saves, save states, and cheat codes, will also be deleted.", comment: ""), message: nil, preferredStyle: .actionSheet)
|
||||
let confirmationAlertController = UIAlertController(title: NSLocalizedString("Are you sure you want to delete this game?", comment: ""),
|
||||
message: NSLocalizedString("All associated data, such as saves, save states, and cheat codes, will also be deleted.", comment: ""),
|
||||
preferredStyle: .alert)
|
||||
confirmationAlertController.addAction(UIAlertAction(title: NSLocalizedString("Delete Game", comment: ""), style: .destructive, handler: { action in
|
||||
|
||||
DatabaseManager.shared.performBackgroundTask { (context) in
|
||||
@ -554,6 +603,7 @@ private extension GameCollectionViewController
|
||||
let importController = ImportController(documentTypes: [kUTTypeImage as String])
|
||||
importController.delegate = self
|
||||
importController.importOptions = [clipboardImportOption, photoLibraryImportOption, gamesDatabaseImportOption]
|
||||
importController.sourceView = self._popoverSourceView
|
||||
self.present(importController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@ -629,6 +679,8 @@ private extension GameCollectionViewController
|
||||
{
|
||||
// Manually reload item because collection view may not be in window hierarchy,
|
||||
// which means it won't automatically update when we save the context.
|
||||
// 手动重新加载项目,因为集合视图可能不在窗口层次结构中,
|
||||
// 这意味着当我们保存上下文时它不会自动更新。
|
||||
self.collectionView.reloadItems(at: [indexPath])
|
||||
}
|
||||
|
||||
@ -663,26 +715,36 @@ private extension GameCollectionViewController
|
||||
|
||||
func share(_ game: Game)
|
||||
{
|
||||
let temporaryDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
let symbolicURL = temporaryDirectory.appendingPathComponent(game.name + "." + game.fileURL.pathExtension)
|
||||
let temporaryDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
|
||||
let sanitizedName = game.name.components(separatedBy: .urlFilenameAllowed.inverted).joined()
|
||||
let temporaryURL = temporaryDirectory.appendingPathComponent(sanitizedName + "." + game.fileURL.pathExtension, isDirectory: false)
|
||||
|
||||
do
|
||||
{
|
||||
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil)
|
||||
|
||||
// Create a symbolic link so we can control the file name used when sharing.
|
||||
// Make a temporary copy so we can control the filename used when sharing.
|
||||
// Otherwise, if we just passed in game.fileURL to UIActivityViewController, the file name would be the game's SHA1 hash.
|
||||
try FileManager.default.createSymbolicLink(at: symbolicURL, withDestinationURL: game.fileURL)
|
||||
try FileManager.default.copyItem(at: game.fileURL, to: temporaryURL, shouldReplace: true)
|
||||
}
|
||||
catch
|
||||
{
|
||||
print(error)
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Could Not Share Game", comment: ""), error: error)
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let copyDeepLinkActivity = CopyDeepLinkActivity()
|
||||
|
||||
let activityViewController = UIActivityViewController(activityItems: [symbolicURL, game], applicationActivities: [copyDeepLinkActivity])
|
||||
let activityViewController = UIActivityViewController(activityItems: [temporaryURL, game], applicationActivities: [copyDeepLinkActivity])
|
||||
activityViewController.popoverPresentationController?.sourceView = self._popoverSourceView?.superview
|
||||
activityViewController.popoverPresentationController?.sourceRect = self._popoverSourceView?.frame ?? .zero
|
||||
activityViewController.completionWithItemsHandler = { (activityType, finished, returnedItems, error) in
|
||||
// Make sure the user either shared the game or cancelled before deleting temporaryDirectory.
|
||||
guard finished || activityType == nil else { return }
|
||||
|
||||
do
|
||||
{
|
||||
try FileManager.default.removeItem(at: temporaryDirectory)
|
||||
@ -692,6 +754,7 @@ private extension GameCollectionViewController
|
||||
print(error)
|
||||
}
|
||||
}
|
||||
|
||||
self.present(activityViewController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
@ -747,8 +810,7 @@ private extension GameCollectionViewController
|
||||
{
|
||||
do
|
||||
{
|
||||
let illegalCharacterSet = CharacterSet(charactersIn: "\"\\/?<>:*|")
|
||||
let sanitizedFilename = game.name.components(separatedBy: illegalCharacterSet).joined() + "." + game.gameSaveURL.pathExtension
|
||||
let sanitizedFilename = game.name.components(separatedBy: .urlFilenameAllowed.inverted).joined()
|
||||
|
||||
let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(sanitizedFilename)
|
||||
try FileManager.default.copyItem(at: game.gameSaveURL, to: temporaryURL, shouldReplace: true)
|
||||
@ -807,6 +869,9 @@ extension GameCollectionViewController: UIViewControllerPreviewingDelegate
|
||||
|
||||
previewingContext.sourceRect = layoutAttributes.frame
|
||||
|
||||
let cell = collectionView.cellForItem(at: indexPath)
|
||||
self._popoverSourceView = cell
|
||||
|
||||
let game = self.dataSource.item(at: indexPath)
|
||||
|
||||
let gameViewController = self.makePreviewGameViewController(for: game)
|
||||
@ -843,7 +908,7 @@ extension GameCollectionViewController: UIViewControllerPreviewingDelegate
|
||||
emulatorBridge.systemType = .ds
|
||||
}
|
||||
|
||||
emulatorBridge.isJITEnabled = UIDevice.current.supportsJIT
|
||||
emulatorBridge.isJITEnabled = ProcessInfo.processInfo.isJITAvailable
|
||||
}
|
||||
|
||||
let actions = self.actions(for: game).previewActions
|
||||
@ -953,6 +1018,7 @@ extension GameCollectionViewController: UICollectionViewDelegateFlowLayout
|
||||
{
|
||||
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
|
||||
{
|
||||
|
||||
let collectionViewLayout = collectionView.collectionViewLayout as! GridCollectionViewLayout
|
||||
|
||||
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionViewLayout.itemWidth)
|
||||
@ -962,7 +1028,9 @@ extension GameCollectionViewController: UICollectionViewDelegateFlowLayout
|
||||
self.configure(self.prototypeCell, for: indexPath)
|
||||
|
||||
let size = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
return size
|
||||
// return size
|
||||
return CGSize(width: 150, height: 150)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -974,6 +1042,9 @@ extension GameCollectionViewController
|
||||
let game = self.dataSource.item(at: indexPath)
|
||||
let actions = self.actions(for: game)
|
||||
|
||||
let cell = self.collectionView.cellForItem(at: indexPath)
|
||||
self._popoverSourceView = cell
|
||||
|
||||
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { [weak self] in
|
||||
guard let self = self else { return nil }
|
||||
|
||||
|
||||
@ -47,6 +47,7 @@ class GamesViewController: UIViewController
|
||||
private let fetchedResultsController: NSFetchedResultsController<NSFetchRequestResult>
|
||||
|
||||
private var searchController: RSTSearchController?
|
||||
private lazy var importController: ImportController = self.makeImportController()
|
||||
|
||||
private var syncingToastView: RSTToastView? {
|
||||
didSet {
|
||||
@ -58,6 +59,8 @@ class GamesViewController: UIViewController
|
||||
}
|
||||
private var syncingProgressObservation: NSKeyValueObservation?
|
||||
|
||||
@IBOutlet private var importButton: UIBarButtonItem!
|
||||
|
||||
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
|
||||
fatalError("initWithNibName: not implemented")
|
||||
}
|
||||
@ -75,9 +78,16 @@ 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)
|
||||
}
|
||||
|
||||
@IBAction func importfilesBtn(_ sender: UIButton) {
|
||||
|
||||
self.present(self.importController, animated: true, completion: nil)
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - UIViewController -
|
||||
@ -90,9 +100,16 @@ extension GamesViewController
|
||||
|
||||
self.placeholderView = RSTPlaceholderView(frame: self.view.bounds)
|
||||
self.placeholderView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
self.placeholderView.textLabel.text = NSLocalizedString("No Games", comment: "")
|
||||
self.placeholderView.detailTextLabel.text = NSLocalizedString("You can import games by pressing the + button in the top right.", comment: "")
|
||||
self.placeholderView.textLabel.text = NSLocalizedString("", comment: "")
|
||||
self.placeholderView.detailTextLabel.text = NSLocalizedString("You have not added any games", comment: "")
|
||||
|
||||
let placeholderImagV = UIImageView(image: UIImage(named: "nogame"))
|
||||
placeholderImagV.frame = CGRect(x: (self.view.bounds.width - 87) / 2, y: (self.view.bounds.height - (placeholderImagV.bounds.height + 80)) / 2, width: 87, height: 64)
|
||||
placeholderImagV.contentMode = .center
|
||||
|
||||
|
||||
self.view.insertSubview(self.placeholderView, at: 0)
|
||||
self.placeholderView.addSubview(placeholderImagV)
|
||||
|
||||
self.pageControl = UIPageControl()
|
||||
self.pageControl.translatesAutoresizingMaskIntoConstraints = false
|
||||
@ -105,25 +122,47 @@ extension GamesViewController
|
||||
self.pageControl.centerXAnchor.constraint(equalTo: (self.navigationController?.toolbar.centerXAnchor)!, constant: 0).isActive = true
|
||||
self.pageControl.centerYAnchor.constraint(equalTo: (self.navigationController?.toolbar.centerYAnchor)!, constant: 0).isActive = true
|
||||
|
||||
if let navigationController = self.navigationController
|
||||
{
|
||||
if #available(iOS 13.0, *)
|
||||
{
|
||||
navigationController.overrideUserInterfaceStyle = .dark
|
||||
// if let navigationController = self.navigationController
|
||||
// {
|
||||
// if #available(iOS 13.0, *)
|
||||
// {
|
||||
// navigationController.overrideUserInterfaceStyle = .dark
|
||||
//
|
||||
// let navigationBarAppearance = navigationController.navigationBar.standardAppearance.copy()
|
||||
// navigationBarAppearance.backgroundEffect = UIBlurEffect(style: .dark)
|
||||
// navigationController.navigationBar.standardAppearance = navigationBarAppearance
|
||||
// navigationController.navigationBar.scrollEdgeAppearance = navigationBarAppearance
|
||||
//
|
||||
// let toolbarAppearance = navigationController.toolbar.standardAppearance.copy()
|
||||
// toolbarAppearance.backgroundEffect = UIBlurEffect(style: .dark)
|
||||
// navigationController.toolbar.standardAppearance = toolbarAppearance
|
||||
//
|
||||
// if #available(iOS 15, *)
|
||||
// {
|
||||
// navigationController.toolbar.scrollEdgeAppearance = toolbarAppearance
|
||||
// }
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// navigationController.navigationBar.barStyle = .blackTranslucent
|
||||
// navigationController.toolbar.barStyle = .blackTranslucent
|
||||
// }
|
||||
// }
|
||||
|
||||
let navigationBarAppearance = navigationController.navigationBar.standardAppearance.copy()
|
||||
navigationBarAppearance.backgroundEffect = UIBlurEffect(style: .dark)
|
||||
navigationController.navigationBar.standardAppearance = navigationBarAppearance
|
||||
if #available(iOS 14, *)
|
||||
{
|
||||
self.importController.presentingViewController = self
|
||||
|
||||
let toolbarAppearance = navigationController.toolbar.standardAppearance.copy()
|
||||
toolbarAppearance.backgroundEffect = UIBlurEffect(style: .dark)
|
||||
navigationController.toolbar.standardAppearance = toolbarAppearance
|
||||
let importActions = self.importController.makeActions().menuActions
|
||||
let importMenu = UIMenu(title: NSLocalizedString("Import From…", comment: ""), image: UIImage(systemName: "square.and.arrow.down"), children: importActions)
|
||||
self.importButton.menu = importMenu
|
||||
|
||||
self.importButton.action = nil
|
||||
self.importButton.target = nil
|
||||
}
|
||||
else
|
||||
{
|
||||
navigationController.navigationBar.barStyle = .blackTranslucent
|
||||
navigationController.toolbar.barStyle = .blackTranslucent
|
||||
}
|
||||
self.importController.barButtonItem = self.importButton
|
||||
}
|
||||
|
||||
self.prepareSearchController()
|
||||
@ -227,6 +266,7 @@ private extension GamesViewController
|
||||
return nil
|
||||
}
|
||||
self.searchController?.searchBar.barStyle = .black
|
||||
self.searchController?.searchBar.placeholder = "Game here"
|
||||
|
||||
self.navigationItem.searchController = self.searchController
|
||||
self.navigationItem.hidesSearchBarWhenScrolling = false
|
||||
@ -329,7 +369,8 @@ private extension GamesViewController
|
||||
|
||||
self.pageViewController.setViewControllers([viewController], direction: .forward, animated: false, completion: nil)
|
||||
|
||||
self.title = viewController.title
|
||||
// self.title = viewController.title
|
||||
self.title = ""
|
||||
self.pageControl.currentPage = index
|
||||
}
|
||||
}
|
||||
@ -352,7 +393,7 @@ private extension GamesViewController
|
||||
/// Importing
|
||||
extension GamesViewController: ImportControllerDelegate
|
||||
{
|
||||
@IBAction private func importFiles()
|
||||
private func makeImportController() -> ImportController
|
||||
{
|
||||
var documentTypes = Set(System.registeredSystems.map { $0.gameType.rawValue })
|
||||
documentTypes.insert(kUTTypeZipArchive as String)
|
||||
@ -368,14 +409,24 @@ extension GamesViewController: ImportControllerDelegate
|
||||
documentTypes.insert("com.rileytestut.gbc")
|
||||
documentTypes.insert("com.rileytestut.gb")
|
||||
|
||||
let itunesImportOption = iTunesImportOption(presentingViewController: self)
|
||||
// let itunesImportOption = iTunesImportOption(presentingViewController: self)
|
||||
|
||||
let importController = ImportController(documentTypes: documentTypes)
|
||||
importController.delegate = self
|
||||
importController.importOptions = [itunesImportOption]
|
||||
self.present(importController, animated: true, completion: nil)
|
||||
// importController.importOptions = [itunesImportOption]
|
||||
|
||||
return importController
|
||||
}
|
||||
|
||||
@IBAction private func importFiles()
|
||||
{
|
||||
self.present(self.importController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func importController(_ importController: ImportController, didImportItemsAt urls: Set<URL>, errors: [Error])
|
||||
{
|
||||
for error in errors
|
||||
@ -425,6 +476,8 @@ private extension GamesViewController
|
||||
SyncManager.shared.sync()
|
||||
}
|
||||
|
||||
|
||||
|
||||
func showSyncingToastViewIfNeeded()
|
||||
{
|
||||
guard let coordinator = SyncManager.shared.coordinator, let syncProgress = SyncManager.shared.syncProgress, coordinator.isSyncing && self.syncingToastView == nil else { return }
|
||||
@ -615,7 +668,7 @@ extension GamesViewController: NSFetchedResultsControllerDelegate
|
||||
|
||||
extension GamesViewController: UIAdaptivePresentationControllerDelegate
|
||||
{
|
||||
func presentationControllerWillDismiss(_ presentationController: UIPresentationController)
|
||||
func presentationControllerDidDismiss(_ presentationController: UIPresentationController)
|
||||
{
|
||||
self.sync()
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ import DeltaCore
|
||||
struct iTunesImportOption: ImportOption
|
||||
{
|
||||
let title = NSLocalizedString("iTunes", comment: "")
|
||||
let image: UIImage? = nil
|
||||
let image: UIImage? = UIImage(symbolNameIfAvailable: "music.note")
|
||||
|
||||
private let presentingViewController: UIViewController
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
|
||||
import UIKit
|
||||
import MobileCoreServices
|
||||
import UniformTypeIdentifiers
|
||||
import ObjectiveC
|
||||
|
||||
import DeltaCore
|
||||
@ -37,7 +38,10 @@ class ImportController: NSObject
|
||||
var delegate: ImportControllerDelegate?
|
||||
var importOptions: [ImportOption]?
|
||||
|
||||
private weak var presentingViewController: UIViewController?
|
||||
weak var presentingViewController: UIViewController?
|
||||
|
||||
weak var barButtonItem: UIBarButtonItem?
|
||||
weak var sourceView: UIView?
|
||||
|
||||
// Store presentedViewController separately, since when we dismiss we don't know if it has already been dismissed.
|
||||
// Calling dismiss on presentingViewController in that case would dismiss presentingViewController, which is bad.
|
||||
@ -61,26 +65,54 @@ class ImportController: NSObject
|
||||
super.init()
|
||||
}
|
||||
|
||||
func makeActions() -> [Action]
|
||||
{
|
||||
assert(self.presentingViewController != nil, "presentingViewController must be set before calling makeActions()")
|
||||
|
||||
var actions = (self.importOptions ?? []).map { (option) -> Action in
|
||||
let action = Action(title: option.title, style: .default, image: option.image) { _ in
|
||||
option.import { importedURLs in
|
||||
self.finish(with: importedURLs, errors: [])
|
||||
}
|
||||
}
|
||||
|
||||
return action
|
||||
}
|
||||
|
||||
let filesAction = Action(title: NSLocalizedString("Files", comment: ""), style: .default, image: UIImage(symbolNameIfAvailable: "doc")) { action in
|
||||
self.presentDocumentBrowser()
|
||||
}
|
||||
actions.append(filesAction)
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
fileprivate func presentImportController(from presentingViewController: UIViewController, animated: Bool, completionHandler: (() -> Void)?)
|
||||
{
|
||||
self.presentingViewController = presentingViewController
|
||||
|
||||
let actions = self.makeActions()
|
||||
|
||||
if actions.count > 1
|
||||
{
|
||||
let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||
alertController.addAction(UIAlertAction.cancel)
|
||||
|
||||
if let importOptions = self.importOptions
|
||||
let alertActions = actions.map { UIAlertAction($0) }
|
||||
for action in alertActions
|
||||
{
|
||||
for importOption in importOptions
|
||||
{
|
||||
alertController.add(importOption) { [unowned self] (urls) in
|
||||
self.finish(with: urls, errors: [])
|
||||
}
|
||||
alertController.addAction(action)
|
||||
}
|
||||
|
||||
let filesAction = UIAlertAction(title: NSLocalizedString("Files", comment: ""), style: .default) { (action) in
|
||||
self.presentDocumentBrowser()
|
||||
if let sourceView = self.sourceView
|
||||
{
|
||||
alertController.popoverPresentationController?.sourceView = sourceView.superview
|
||||
alertController.popoverPresentationController?.sourceRect = sourceView.frame
|
||||
}
|
||||
else
|
||||
{
|
||||
alertController.popoverPresentationController?.barButtonItem = self.barButtonItem
|
||||
}
|
||||
alertController.addAction(filesAction)
|
||||
|
||||
self.presentedViewController = alertController
|
||||
self.presentingViewController?.present(alertController, animated: true, completion: nil)
|
||||
@ -116,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))
|
||||
let presentedViewController: UIViewController
|
||||
|
||||
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]
|
||||
|
||||
self.presentedViewController = documentBrowserViewController
|
||||
self.presentingViewController?.present(documentBrowserViewController, animated: true, completion: nil)
|
||||
presentedViewController = documentBrowserViewController
|
||||
}
|
||||
|
||||
self.presentedViewController = presentedViewController
|
||||
self.presentingViewController?.present(presentedViewController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,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]()
|
||||
@ -198,7 +263,7 @@ private var ImportControllerKey: UInt8 = 0
|
||||
|
||||
extension UIViewController
|
||||
{
|
||||
fileprivate(set) var importController: ImportController?
|
||||
fileprivate var importController: ImportController?
|
||||
{
|
||||
set
|
||||
{
|
||||
|
||||
@ -13,7 +13,9 @@ import Harmony
|
||||
|
||||
class LaunchViewController: RSTLaunchViewController
|
||||
{
|
||||
|
||||
@IBOutlet private var gameViewContainerView: UIView!
|
||||
|
||||
private var gameViewController: GameViewController!
|
||||
|
||||
private var presentedGameViewController: Bool = false
|
||||
@ -76,7 +78,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)
|
||||
|
||||
@ -12,13 +12,26 @@ import DeltaCore
|
||||
|
||||
extension CheatValidator
|
||||
{
|
||||
enum Error: Swift.Error
|
||||
enum Error: LocalizedError
|
||||
{
|
||||
case invalidCode
|
||||
case invalidName
|
||||
case invalidGame
|
||||
case duplicateName
|
||||
case duplicateCode
|
||||
case unknownCheatType
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self
|
||||
{
|
||||
case .invalidCode: return NSLocalizedString("The cheat code isn't in the correct format.", comment: "")
|
||||
case .invalidName: return NSLocalizedString("The name of this cheat is invalid.", comment: "")
|
||||
case .invalidGame: return NSLocalizedString("There is no associated game with this cheat.", comment: "")
|
||||
case .duplicateName: return NSLocalizedString("A cheat already exists with this name.", comment: "")
|
||||
case .duplicateCode: return NSLocalizedString("A cheat already exists with this code.", comment: "")
|
||||
case .unknownCheatType: return NSLocalizedString("Delta does not support this cheat type.", comment: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,8 +8,10 @@
|
||||
|
||||
import UIKit
|
||||
import CoreData
|
||||
import SwiftUI
|
||||
|
||||
import DeltaCore
|
||||
import MelonDSDeltaCore
|
||||
|
||||
import Roxas
|
||||
|
||||
@ -30,6 +32,8 @@ class CheatsViewController: UITableViewController
|
||||
weak var delegate: CheatsViewControllerDelegate?
|
||||
|
||||
private let dataSource = RSTFetchedResultsTableViewDataSource<Cheat>(fetchedResultsController: NSFetchedResultsController())
|
||||
|
||||
private var cheatBaseCheats: [CheatMetadata]?
|
||||
}
|
||||
|
||||
extension CheatsViewController
|
||||
@ -61,6 +65,21 @@ extension CheatsViewController
|
||||
self.tableView.separatorEffect = vibrancyEffect
|
||||
|
||||
self.registerForPreviewing(with: self, sourceView: self.tableView)
|
||||
|
||||
if #available(iOS 14, *)
|
||||
{
|
||||
self.updateAddCheatMenu()
|
||||
}
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool)
|
||||
{
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
if #available(iOS 14, *), self.cheatBaseCheats == nil
|
||||
{
|
||||
self.fetchCheatBaseCheats()
|
||||
}
|
||||
}
|
||||
|
||||
override func didReceiveMemoryWarning()
|
||||
@ -91,6 +110,37 @@ private extension CheatsViewController
|
||||
|
||||
self.dataSource.fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: nil, cacheName: nil)
|
||||
}
|
||||
|
||||
@available(iOS 14, *) @MainActor
|
||||
func updateAddCheatMenu()
|
||||
{
|
||||
// CheatBase only contains DS cheats for now, so hide option completely for other systems.
|
||||
guard self.game.type == .ds else { return }
|
||||
|
||||
var searchCheatBaseTitle = NSLocalizedString("Search CheatBase", comment: "")
|
||||
var attributes: UIMenuElement.Attributes = []
|
||||
|
||||
if let cheats = self.cheatBaseCheats, cheats.isEmpty
|
||||
{
|
||||
searchCheatBaseTitle = NSLocalizedString("No Cheats in CheatBase", comment: "")
|
||||
attributes = [.disabled]
|
||||
}
|
||||
|
||||
let addCheatMenu = UIMenu(children: [
|
||||
UIAction(title: NSLocalizedString("New Cheat Code", comment: ""), image: UIImage(systemName: "square.and.pencil")) { [weak self] _ in
|
||||
self?.addCheat()
|
||||
},
|
||||
|
||||
UIAction(title: searchCheatBaseTitle, image: UIImage(systemName: "magnifyingglass"), attributes: attributes) { [weak self] _ in
|
||||
self?.searchCheatBase()
|
||||
},
|
||||
])
|
||||
|
||||
self.navigationItem.rightBarButtonItem?.target = nil
|
||||
self.navigationItem.rightBarButtonItem?.action = nil
|
||||
|
||||
self.navigationItem.rightBarButtonItem?.menu = addCheatMenu
|
||||
}
|
||||
}
|
||||
|
||||
//MARK: - Managing Cheats -
|
||||
@ -103,6 +153,78 @@ private extension CheatsViewController
|
||||
editCheatViewController.presentWithPresentingViewController(self)
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
func fetchCheatBaseCheats()
|
||||
{
|
||||
Task {
|
||||
do
|
||||
{
|
||||
let cheatBase = try CheatBase()
|
||||
let cheats = try await cheatBase.cheats(for: self.game) ?? []
|
||||
self.cheatBaseCheats = cheats
|
||||
|
||||
self.updateAddCheatMenu()
|
||||
}
|
||||
catch
|
||||
{
|
||||
print("[RSTLog] Failed to prefetch cheats from CheatBase:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
func searchCheatBase()
|
||||
{
|
||||
var rootView = CheatBaseView(game: self.game, cheats: self.cheatBaseCheats)
|
||||
rootView.cancellationHandler = { [weak self] in
|
||||
self?.presentedViewController?.dismiss(animated: true)
|
||||
}
|
||||
|
||||
rootView.selectionHandler = { [weak self] cheatMetadata in
|
||||
self?.saveCheatMetadata(cheatMetadata)
|
||||
self?.presentedViewController?.dismiss(animated: true)
|
||||
}
|
||||
|
||||
let hostingController = UIHostingController(rootView: rootView)
|
||||
self.present(hostingController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func saveCheatMetadata(_ cheatMetadata: CheatMetadata)
|
||||
{
|
||||
DatabaseManager.shared.performBackgroundTask { context in
|
||||
do
|
||||
{
|
||||
guard let cheatType = cheatMetadata.device.cheatType, let cheatFormat = cheatMetadata.device.cheatFormat else { throw CheatValidator.Error.unknownCheatType }
|
||||
|
||||
let cheat = Cheat(context: context)
|
||||
cheat.name = cheatMetadata.name
|
||||
cheat.type = cheatType
|
||||
cheat.isEnabled = true
|
||||
|
||||
let sanitizedCode = cheatMetadata.code.components(separatedBy: .whitespacesAndNewlines).joined()
|
||||
let formattedCode = sanitizedCode.formatted(with: cheatFormat)
|
||||
cheat.code = formattedCode
|
||||
|
||||
let game = context.object(with: self.game.objectID) as! Game
|
||||
cheat.game = game
|
||||
|
||||
let validator = CheatValidator(format: cheatFormat, managedObjectContext: context)
|
||||
try validator.validate(cheat)
|
||||
|
||||
self.delegate?.cheatsViewController(self, activateCheat: cheat)
|
||||
|
||||
try context.save()
|
||||
}
|
||||
catch
|
||||
{
|
||||
DispatchQueue.main.async {
|
||||
let alertController = UIAlertController(title: NSLocalizedString("Unable to Add Cheat", comment: ""), error: error)
|
||||
self.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteCheat(_ cheat: Cheat)
|
||||
{
|
||||
self.delegate?.cheatsViewController(self, deactivateCheat: cheat)
|
||||
|
||||
@ -70,7 +70,7 @@ extension GridMenuViewController
|
||||
collectionViewLayout.usesEqualHorizontalSpacingDistributionForSingleRow = true
|
||||
|
||||
// Manually update prototype cell properties
|
||||
self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionViewLayout.itemWidth).isActive = true
|
||||
self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: 150).isActive = true
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool)
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,14 +93,6 @@ extension SaveStatesViewController
|
||||
self.collectionView?.dataSource = self.dataSource
|
||||
self.collectionView?.prefetchDataSource = self.dataSource
|
||||
|
||||
let collectionViewLayout = self.collectionViewLayout as! GridCollectionViewLayout
|
||||
let averageHorizontalInset = (collectionViewLayout.sectionInset.left + collectionViewLayout.sectionInset.right) / 2
|
||||
let portraitScreenWidth = UIScreen.main.coordinateSpace.convert(UIScreen.main.bounds, to: UIScreen.main.fixedCoordinateSpace).width
|
||||
|
||||
// Use dimensions that allow two cells to fill the screen horizontally with padding in portrait mode
|
||||
// We'll keep the same size for landscape orientation, which will allow more to fit
|
||||
collectionViewLayout.itemWidth = floor((portraitScreenWidth - (averageHorizontalInset * 3)) / 2)
|
||||
|
||||
switch self.mode
|
||||
{
|
||||
case .saving:
|
||||
@ -113,8 +105,7 @@ extension SaveStatesViewController
|
||||
self.navigationItem.rightBarButtonItems?.removeFirst()
|
||||
}
|
||||
|
||||
// Manually update prototype cell properties
|
||||
self.prototypeCellWidthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionViewLayout.itemWidth)
|
||||
self.prototypeCellWidthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: 0)
|
||||
self.prototypeCellWidthConstraint.isActive = true
|
||||
|
||||
self.prepareEmulatorCoreSaveState()
|
||||
@ -238,6 +229,26 @@ private extension SaveStatesViewController
|
||||
}
|
||||
|
||||
self.sortButton.transform = CGAffineTransform.identity.rotated(by: Settings.sortSaveStatesByOldestFirst ? 0 : .pi)
|
||||
|
||||
let collectionViewLayout = self.collectionViewLayout as! GridCollectionViewLayout
|
||||
|
||||
if self.traitCollection.horizontalSizeClass == .regular
|
||||
{
|
||||
collectionViewLayout.itemWidth = 180
|
||||
collectionViewLayout.minimumInteritemSpacing = 30
|
||||
}
|
||||
else
|
||||
{
|
||||
let averageHorizontalInset = (collectionViewLayout.sectionInset.left + collectionViewLayout.sectionInset.right) / 2
|
||||
let portraitScreenWidth = UIScreen.main.coordinateSpace.convert(UIScreen.main.bounds, to: UIScreen.main.fixedCoordinateSpace).width
|
||||
|
||||
// Use dimensions that allow two cells to fill the screen horizontally with padding in portrait mode
|
||||
// We'll keep the same size for landscape orientation, which will allow more to fit
|
||||
collectionViewLayout.itemWidth = floor((portraitScreenWidth - (averageHorizontalInset * 3)) / 2)
|
||||
}
|
||||
|
||||
// Manually update prototype cell properties
|
||||
self.prototypeCellWidthConstraint.constant = collectionViewLayout.itemWidth
|
||||
}
|
||||
|
||||
//MARK: - Configure Views -
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
190
Delta/Scenes/SceneDelegate.swift
Normal file
190
Delta/Scenes/SceneDelegate.swift
Normal file
@ -0,0 +1,190 @@
|
||||
//
|
||||
// SceneDelegate.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 6/6/22.
|
||||
// Copyright © 2022 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
import DeltaCore
|
||||
import Harmony
|
||||
|
||||
@objc(SceneDelegate) @available(iOS 13, *)
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate
|
||||
{
|
||||
var window: UIWindow? {
|
||||
get {
|
||||
if _window == nil
|
||||
{
|
||||
_window = GameWindow()
|
||||
}
|
||||
|
||||
return _window
|
||||
}
|
||||
set {
|
||||
_window = newValue as? GameWindow
|
||||
}
|
||||
}
|
||||
private var _window: GameWindow?
|
||||
|
||||
private let deepLinkController = DeepLinkController()
|
||||
|
||||
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).
|
||||
|
||||
self.window?.tintColor = .deltaPurple
|
||||
|
||||
if let context = connectionOptions.urlContexts.first
|
||||
{
|
||||
self.handle(.url(context.url))
|
||||
}
|
||||
|
||||
if let shortcutItem = connectionOptions.shortcutItem
|
||||
{
|
||||
self.handle(.shortcut(shortcutItem))
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
extension SceneDelegate
|
||||
{
|
||||
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>)
|
||||
{
|
||||
guard let context = URLContexts.first else { return }
|
||||
self.handle(.url(context.url))
|
||||
}
|
||||
|
||||
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void)
|
||||
{
|
||||
self.handle(.shortcut(shortcutItem))
|
||||
completionHandler(true)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 13, *)
|
||||
private extension SceneDelegate
|
||||
{
|
||||
func handle(_ deepLink: DeepLink)
|
||||
{
|
||||
guard DatabaseManager.shared.isStarted else {
|
||||
// Wait until DatabaseManager is ready before handling deep link.
|
||||
|
||||
// NotificationCenter.default.notifications requires iOS 15 or later :(
|
||||
// _ = await NotificationCenter.default.notifications(named: DatabaseManager.didStartNotification).first(where: { _ in true })
|
||||
|
||||
var observer: NSObjectProtocol?
|
||||
observer = NotificationCenter.default.addObserver(forName: DatabaseManager.didStartNotification, object: DatabaseManager.shared, queue: .main) { [weak observer] _ in
|
||||
observer.map { NotificationCenter.default.removeObserver($0) }
|
||||
self.handle(deepLink)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
// DeepLinkController expects to be called from main thread.
|
||||
|
||||
switch deepLink
|
||||
{
|
||||
case .shortcut:
|
||||
_ = self.deepLinkController.handle(deepLink)
|
||||
|
||||
case .url(let url):
|
||||
if url.isFileURL
|
||||
{
|
||||
if GameType(fileExtension: url.pathExtension) != nil || url.pathExtension.lowercased() == "zip"
|
||||
{
|
||||
self.importGame(at: url)
|
||||
}
|
||||
else if url.pathExtension.lowercased() == "deltaskin"
|
||||
{
|
||||
self.importControllerSkin(at: url)
|
||||
}
|
||||
}
|
||||
else if url.scheme?.hasPrefix("db-") == true
|
||||
{
|
||||
_ = DropboxService.shared.handleDropboxURL(url)
|
||||
}
|
||||
else if url.scheme?.lowercased() == "delta"
|
||||
{
|
||||
_ = self.deepLinkController.handle(deepLink)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func importGame(at url: URL)
|
||||
{
|
||||
DatabaseManager.shared.importGames(at: [url]) { (games, errors) in
|
||||
if errors.count > 0
|
||||
{
|
||||
let alertController = UIAlertController.alertController(for: .games, with: errors)
|
||||
self.present(alertController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func importControllerSkin(at url: URL)
|
||||
{
|
||||
DatabaseManager.shared.importControllerSkins(at: [url]) { (games, errors) in
|
||||
if errors.count > 0
|
||||
{
|
||||
let alertController = UIAlertController.alertController(for: .controllerSkins, with: errors)
|
||||
self.present(alertController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func present(_ alertController: UIAlertController)
|
||||
{
|
||||
var rootViewController = self.window?.rootViewController
|
||||
|
||||
while rootViewController?.presentedViewController != nil
|
||||
{
|
||||
rootViewController = rootViewController?.presentedViewController
|
||||
}
|
||||
|
||||
rootViewController?.present(alertController, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
34
Delta/Settings/AboutVC.swift
Normal file
34
Delta/Settings/AboutVC.swift
Normal file
@ -0,0 +1,34 @@
|
||||
//
|
||||
// AboutVC.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by 忆海16 on 2024/5/9.
|
||||
// Copyright © 2024 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
class AboutVC: UIViewController {
|
||||
|
||||
@IBOutlet weak var logoimageV: UIImageView!
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
logoimageV.layer.cornerRadius = 10
|
||||
}
|
||||
|
||||
@IBAction func done(_ sender: Any) {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
/*
|
||||
// MARK: - Navigation
|
||||
|
||||
// In a storyboard-based application, you will often want to do a little preparation before navigation
|
||||
override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
|
||||
// Get the new view controller using segue.destination.
|
||||
// Pass the selected object to the new view controller.
|
||||
}
|
||||
*/
|
||||
|
||||
}
|
||||
68
Delta/Settings/AboutVC.xib
Normal file
68
Delta/Settings/AboutVC.xib
Normal file
@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="32700.99.1234" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<device id="retina6_1" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22684"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="AboutVC" customModule="Retro_Game_Emulator" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="logoimageV" destination="ziO-Do-7xL" id="LQE-1P-2mg"/>
|
||||
<outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/>
|
||||
</connections>
|
||||
</placeholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="i5M-Pr-FkT">
|
||||
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="About" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rKh-5S-VFi">
|
||||
<rect key="frame" x="178" y="68" width="58" height="27"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="22"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="logo" translatesAutoresizingMaskIntoConstraints="NO" id="ziO-Do-7xL">
|
||||
<rect key="frame" x="137" y="125" width="140" height="140"/>
|
||||
<constraints>
|
||||
<constraint firstAttribute="height" constant="140" id="1uH-on-wZs"/>
|
||||
<constraint firstAttribute="width" constant="140" id="W0c-de-bK4"/>
|
||||
</constraints>
|
||||
</imageView>
|
||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Version:1.0" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="5zF-2q-6sp">
|
||||
<rect key="frame" x="165.5" y="275" width="83" height="21"/>
|
||||
<fontDescription key="fontDescription" type="system" pointSize="17"/>
|
||||
<color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
<nil key="highlightedColor"/>
|
||||
</label>
|
||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="nXc-vQ-hCt">
|
||||
<rect key="frame" x="357" y="66.5" width="37" height="30"/>
|
||||
<inset key="imageEdgeInsets" minX="0.0" minY="0.0" maxX="2.2250738585072014e-308" maxY="0.0"/>
|
||||
<state key="normal" title="Done"/>
|
||||
<connections>
|
||||
<action selector="done:" destination="-1" eventType="touchUpInside" id="ZLY-fU-FB3"/>
|
||||
</connections>
|
||||
</button>
|
||||
</subviews>
|
||||
<viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/>
|
||||
<color key="backgroundColor" red="0.071399740870000006" green="0.082175157959999995" blue="0.10832635309999999" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="nXc-vQ-hCt" firstAttribute="centerY" secondItem="rKh-5S-VFi" secondAttribute="centerY" id="2cR-wN-cus"/>
|
||||
<constraint firstItem="5zF-2q-6sp" firstAttribute="centerX" secondItem="i5M-Pr-FkT" secondAttribute="centerX" id="574-uc-3Ic"/>
|
||||
<constraint firstItem="rKh-5S-VFi" firstAttribute="top" secondItem="fnl-2z-Ty3" secondAttribute="top" constant="20" id="98V-2P-ZdQ"/>
|
||||
<constraint firstItem="5zF-2q-6sp" firstAttribute="top" secondItem="ziO-Do-7xL" secondAttribute="bottom" constant="10" id="KlD-4y-BYr"/>
|
||||
<constraint firstItem="fnl-2z-Ty3" firstAttribute="trailing" secondItem="nXc-vQ-hCt" secondAttribute="trailing" constant="20" id="PZs-sE-MjT"/>
|
||||
<constraint firstItem="ziO-Do-7xL" firstAttribute="top" secondItem="rKh-5S-VFi" secondAttribute="bottom" constant="30" id="jZu-I1-dgp"/>
|
||||
<constraint firstItem="rKh-5S-VFi" firstAttribute="centerX" secondItem="i5M-Pr-FkT" secondAttribute="centerX" id="qWc-Y0-H2U"/>
|
||||
<constraint firstItem="ziO-Do-7xL" firstAttribute="centerX" secondItem="i5M-Pr-FkT" secondAttribute="centerX" id="zny-8Z-VNI"/>
|
||||
</constraints>
|
||||
<point key="canvasLocation" x="18" y="-12"/>
|
||||
</view>
|
||||
</objects>
|
||||
<resources>
|
||||
<image name="logo" width="1024" height="1024"/>
|
||||
</resources>
|
||||
</document>
|
||||
@ -14,7 +14,7 @@ import DeltaCore
|
||||
import Roxas
|
||||
|
||||
@objc(SwitchTableViewCell)
|
||||
private class SwitchTableViewCell: UITableViewCell
|
||||
class SwitchTableViewCell: UITableViewCell
|
||||
{
|
||||
@IBOutlet var switchView: UISwitch!
|
||||
}
|
||||
|
||||
45
Delta/Settings/Contributors/Contributor.swift
Normal file
45
Delta/Settings/Contributors/Contributor.swift
Normal file
@ -0,0 +1,45 @@
|
||||
//
|
||||
// Contributor.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 2/3/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Contributor: Identifiable, Decodable
|
||||
{
|
||||
var name: String
|
||||
|
||||
var id: String {
|
||||
// Use names as identifiers for now.
|
||||
return self.name
|
||||
}
|
||||
|
||||
var url: URL? {
|
||||
guard let link = self.link, let url = URL(string: link) else { return nil }
|
||||
return url
|
||||
}
|
||||
private var link: String?
|
||||
|
||||
var linkName: String?
|
||||
|
||||
var contributions: [Contribution]
|
||||
}
|
||||
|
||||
struct Contribution: Identifiable, Decodable
|
||||
{
|
||||
var name: String
|
||||
|
||||
var id: String {
|
||||
// Use names as identifiers for now.
|
||||
return self.name
|
||||
}
|
||||
|
||||
var url: URL? {
|
||||
guard let link = self.link, let url = URL(string: link) else { return nil }
|
||||
return url
|
||||
}
|
||||
private var link: String?
|
||||
}
|
||||
205
Delta/Settings/Contributors/ContributorsView.swift
Normal file
205
Delta/Settings/Contributors/ContributorsView.swift
Normal file
@ -0,0 +1,205 @@
|
||||
//
|
||||
// ContributionsView.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by Riley Testut on 2/2/23.
|
||||
// Copyright © 2023 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SafariServices
|
||||
|
||||
@available(iOS 14, *)
|
||||
private extension NavigationLink where Label == EmptyView, Destination == EmptyView
|
||||
{
|
||||
// Copied from https://stackoverflow.com/a/66891173
|
||||
static var empty: NavigationLink {
|
||||
self.init(destination: EmptyView(), label: { EmptyView() })
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
extension ContributorsView
|
||||
{
|
||||
fileprivate class ViewModel: ObservableObject
|
||||
{
|
||||
@Published
|
||||
var contributors: [Contributor]?
|
||||
|
||||
@Published
|
||||
var error: Error?
|
||||
|
||||
@Published
|
||||
var webViewURL: URL?
|
||||
|
||||
weak var hostingController: UIViewController?
|
||||
|
||||
func loadContributors()
|
||||
{
|
||||
guard self.contributors == nil else { return }
|
||||
|
||||
do
|
||||
{
|
||||
let fileURL = Bundle.main.url(forResource: "Contributors", withExtension: "plist")!
|
||||
let data = try Data(contentsOf: fileURL)
|
||||
|
||||
let contributors = try PropertyListDecoder().decode([Contributor].self, from: data)
|
||||
self.contributors = contributors
|
||||
}
|
||||
catch
|
||||
{
|
||||
self.error = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func makeViewController() -> UIHostingController<some View>
|
||||
{
|
||||
let viewModel = ViewModel()
|
||||
let contributorsView = ContributorsView(viewModel: viewModel)
|
||||
|
||||
let hostingController = UIHostingController(rootView: contributorsView)
|
||||
hostingController.navigationItem.largeTitleDisplayMode = .never
|
||||
hostingController.navigationItem.title = contributorsView.localizedTitle
|
||||
|
||||
viewModel.hostingController = hostingController
|
||||
|
||||
return hostingController
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
struct ContributorsView: View
|
||||
{
|
||||
@StateObject
|
||||
private var viewModel: ViewModel
|
||||
|
||||
@State
|
||||
private var showErrorAlert: Bool = false
|
||||
|
||||
private var localizedTitle: String { NSLocalizedString("Contributors", comment: "") }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section(content: {}, footer: {
|
||||
Text("These individuals have contributed to the open-source Delta project on GitHub.\n\nThank you to all our contributors, your help is much appreciated 💜")
|
||||
.font(.subheadline)
|
||||
})
|
||||
|
||||
ForEach(viewModel.contributors ?? []) { contributor in
|
||||
Section {
|
||||
// First row = contributor
|
||||
ContributionCell(name: Text(contributor.name).bold(), url: contributor.url, linkName: contributor.linkName) { webViewURL in
|
||||
viewModel.webViewURL = webViewURL
|
||||
}
|
||||
|
||||
// Remaining rows = contributions
|
||||
ForEach(contributor.contributions) { contribution in
|
||||
ContributionCell(name: Text(contribution.name), url: contribution.url) { webViewURL in
|
||||
viewModel.webViewURL = webViewURL
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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")) {
|
||||
guard let hostingController = viewModel.hostingController else { return }
|
||||
hostingController.navigationController?.popViewController(animated: true)
|
||||
})
|
||||
}
|
||||
.onReceive(viewModel.$error) { error in
|
||||
guard error != nil else { return }
|
||||
showErrorAlert = true
|
||||
}
|
||||
.onReceive(viewModel.$webViewURL) { webViewURL in
|
||||
guard let webViewURL else { return }
|
||||
openURL(webViewURL)
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.loadContributors()
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate init(contributors: [Contributor]? = nil, viewModel: ViewModel = ViewModel())
|
||||
{
|
||||
if let contributors
|
||||
{
|
||||
// Don't overwrite passed-in viewModel.contributors if contributors is nil.
|
||||
viewModel.contributors = contributors
|
||||
}
|
||||
|
||||
self._viewModel = StateObject(wrappedValue: viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
struct ContributionCell: View
|
||||
{
|
||||
var name: Text
|
||||
var url: URL?
|
||||
var linkName: String?
|
||||
|
||||
var action: (URL) -> Void
|
||||
|
||||
var body: some View {
|
||||
|
||||
let body = Button {
|
||||
guard let url else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
// Dispatch Task to avoid "Publishing changes from within view updates is not allowed, this will cause undefined behavior." runtime error on iOS 16.
|
||||
self.action(url)
|
||||
}
|
||||
|
||||
} label: {
|
||||
HStack {
|
||||
self.name
|
||||
.font(.system(size: 17)) // Match Settings screen
|
||||
|
||||
Spacer()
|
||||
|
||||
if let linkName
|
||||
{
|
||||
Text(linkName)
|
||||
.font(.system(size: 17)) // Match Settings screen
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
|
||||
if url != nil
|
||||
{
|
||||
NavigationLink.empty
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
.accentColor(.primary)
|
||||
|
||||
if url != nil
|
||||
{
|
||||
body
|
||||
}
|
||||
else
|
||||
{
|
||||
// No URL to open, so disable cell highlighting.
|
||||
body.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
private extension ContributorsView
|
||||
{
|
||||
func openURL(_ url: URL)
|
||||
{
|
||||
guard let hostingController = viewModel.hostingController else { return }
|
||||
|
||||
let safariViewController = SFSafariViewController(url: url)
|
||||
safariViewController.preferredControlTintColor = .deltaPurple
|
||||
hostingController.present(safariViewController, animated: true)
|
||||
}
|
||||
}
|
||||
@ -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)",
|
||||
|
||||
@ -40,6 +40,8 @@ class ControllerInputsViewController: UIViewController
|
||||
|
||||
private var activeCalloutView: InputCalloutView?
|
||||
|
||||
private var _didLayoutSubviews = false
|
||||
|
||||
@IBOutlet private var actionsMenuViewControllerHeightConstraint: NSLayoutConstraint!
|
||||
@IBOutlet private var cancelTapGestureRecognizer: UITapGestureRecognizer!
|
||||
|
||||
@ -65,7 +67,15 @@ class ControllerInputsViewController: UIViewController
|
||||
|
||||
self.gameViewController.controllerView.addReceiver(self)
|
||||
|
||||
if let navigationController = self.navigationController, #available(iOS 13, *)
|
||||
{
|
||||
navigationController.overrideUserInterfaceStyle = .dark
|
||||
navigationController.navigationBar.scrollEdgeAppearance = navigationController.navigationBar.standardAppearance // Fixes invisible navigation bar on iPad.
|
||||
}
|
||||
else
|
||||
{
|
||||
self.navigationController?.navigationBar.barStyle = .black
|
||||
}
|
||||
|
||||
NSLayoutConstraint.activate([self.gameViewController.gameView.centerYAnchor.constraint(equalTo: self.actionsMenuViewController.view.centerYAnchor)])
|
||||
|
||||
@ -81,6 +91,23 @@ class ControllerInputsViewController: UIViewController
|
||||
{
|
||||
self.actionsMenuViewControllerHeightConstraint.constant = self.actionsMenuViewController.preferredContentSize.height
|
||||
}
|
||||
|
||||
if let window = self.view.window, !_didLayoutSubviews
|
||||
{
|
||||
var traits = DeltaCore.ControllerSkin.Traits.defaults(for: window)
|
||||
traits.orientation = .portrait
|
||||
|
||||
if traits.device == .ipad
|
||||
{
|
||||
// Use standard iPhone skins instead of iPad skins.
|
||||
traits.device = .iphone
|
||||
traits.displayType = .standard
|
||||
}
|
||||
|
||||
self.gameViewController.controllerView.overrideControllerSkinTraits = traits
|
||||
|
||||
_didLayoutSubviews = true
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool)
|
||||
@ -410,6 +437,7 @@ private extension ControllerInputsViewController
|
||||
}
|
||||
|
||||
let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
||||
alertController.popoverPresentationController?.barButtonItem = sender
|
||||
alertController.addAction(.cancel)
|
||||
alertController.addAction(UIAlertAction(title: NSLocalizedString("Reset Controls to Defaults", comment: ""), style: .destructive, handler: { (action) in
|
||||
reset()
|
||||
@ -425,6 +453,12 @@ extension ControllerInputsViewController: UIGestureRecognizerDelegate
|
||||
return self.activeCalloutView != nil
|
||||
}
|
||||
|
||||
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool
|
||||
{
|
||||
// Necessary to prevent other gestures (e.g. GameViewController's resumeEmulationIfNeeded() tap gesture) from cancelling tap.
|
||||
return true
|
||||
}
|
||||
|
||||
@IBAction private func handleTapGesture(_ tapGestureRecognizer: UITapGestureRecognizer)
|
||||
{
|
||||
self.updateActiveCalloutView(with: nil)
|
||||
@ -539,13 +573,19 @@ extension ControllerInputsViewController: GameControllerReceiver
|
||||
{
|
||||
func gameController(_ gameController: GameController, didActivate controllerInput: DeltaCore.Input, value: Double)
|
||||
{
|
||||
guard self.isViewLoaded else { return }
|
||||
guard self.isViewLoaded, value > 0.9 else { return }
|
||||
|
||||
switch gameController
|
||||
{
|
||||
case self.gameViewController.controllerView:
|
||||
if let calloutView = self.calloutViews[AnyInput(controllerInput)]
|
||||
{
|
||||
if controllerInput.isContinuous
|
||||
{
|
||||
// Make sure we only toggle calloutView once in a single gesture.
|
||||
guard calloutView.state == .normal else { break }
|
||||
}
|
||||
|
||||
self.toggle(calloutView)
|
||||
}
|
||||
|
||||
@ -569,3 +609,15 @@ extension ControllerInputsViewController: SMCalloutViewDelegate
|
||||
self.toggle(calloutView)
|
||||
}
|
||||
}
|
||||
|
||||
extension ControllerInputsViewController: UIAdaptivePresentationControllerDelegate
|
||||
{
|
||||
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle
|
||||
{
|
||||
switch (traitCollection.horizontalSizeClass, traitCollection.verticalSizeClass)
|
||||
{
|
||||
case (.regular, .regular): return .formSheet // Regular width and height, so display as form sheet
|
||||
default: return .fullScreen // Compact width and/or height, so display full screen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ extension ControllersSettingsViewController
|
||||
{
|
||||
private enum Section: Int
|
||||
{
|
||||
case none
|
||||
case localDevice
|
||||
case externalControllers
|
||||
case customizeControls
|
||||
@ -108,9 +109,18 @@ extension ControllersSettingsViewController
|
||||
switch identifier
|
||||
{
|
||||
case "controllerInputsSegue":
|
||||
let controllerInputsViewController = (segue.destination as! UINavigationController).topViewController as! ControllerInputsViewController
|
||||
let navigationController = segue.destination as! UINavigationController
|
||||
|
||||
let controllerInputsViewController = navigationController.topViewController as! ControllerInputsViewController
|
||||
controllerInputsViewController.gameController = self.gameController
|
||||
|
||||
if self.view.traitCollection.userInterfaceIdiom == .pad
|
||||
{
|
||||
// For now, only iPads can display ControllerInputsViewController as a form sheet.
|
||||
navigationController.modalPresentationStyle = .formSheet
|
||||
navigationController.presentationController?.delegate = controllerInputsViewController
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
|
||||
@ -140,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
|
||||
|
||||
@ -248,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
|
||||
@ -285,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
|
||||
@ -300,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]
|
||||
@ -329,7 +373,7 @@ extension ControllersSettingsViewController
|
||||
}
|
||||
else
|
||||
{
|
||||
previousIndexPath = nil
|
||||
previousIndexPath = IndexPath(row: 0, section: Section.none.rawValue)
|
||||
}
|
||||
|
||||
self.tableView.beginUpdates()
|
||||
|
||||
@ -23,11 +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
|
||||
{
|
||||
@ -149,6 +157,13 @@ private extension MelonDSCoreSettingsViewController
|
||||
|
||||
switch section
|
||||
{
|
||||
case .performance:
|
||||
// Hide AltJIT section for public builds.
|
||||
guard isBeta else { return true }
|
||||
|
||||
guard Settings.preferredCore(for: .ds) == MelonDS.core else { return true }
|
||||
return !UIDevice.current.supportsJIT
|
||||
|
||||
case .dsBIOS where Settings.preferredCore(for: .ds) == DS.core:
|
||||
// Using DeSmuME core, which doesn't require BIOS.
|
||||
return true
|
||||
@ -252,6 +267,37 @@ private extension MelonDSCoreSettingsViewController
|
||||
}
|
||||
}
|
||||
|
||||
@IBAction func toggleAltJITEnabled(_ sender: UISwitch)
|
||||
{
|
||||
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()
|
||||
@ -264,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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -300,6 +344,20 @@ 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
|
||||
|
||||
case .dsBIOS:
|
||||
let bios = DSBIOS.allCases[indexPath.row]
|
||||
|
||||
@ -378,6 +436,8 @@ extension MelonDSCoreSettingsViewController
|
||||
|
||||
case .changeCore:
|
||||
self.changeCore()
|
||||
|
||||
case .airPlay, .performance: break
|
||||
}
|
||||
}
|
||||
|
||||
@ -398,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
|
||||
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: "")
|
||||
}
|
||||
else
|
||||
{
|
||||
return super.tableView(tableView, titleForFooterInSection: section.rawValue)
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
34
Delta/Settings/PrivacyVC.swift
Normal file
34
Delta/Settings/PrivacyVC.swift
Normal file
@ -0,0 +1,34 @@
|
||||
//
|
||||
// PrivacyVC.swift
|
||||
// Delta
|
||||
//
|
||||
// Created by 忆海16 on 2024/5/9.
|
||||
// Copyright © 2024 Riley Testut. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import WebKit
|
||||
class PrivacyVC: UIViewController {
|
||||
var webView: WKWebView!
|
||||
var type:Int = 0
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
webView = WKWebView()
|
||||
view = webView
|
||||
|
||||
if self.type == 0 {
|
||||
if let url = URL(string: "https://retrogameemulator.mystrikingly.com/privacy") {
|
||||
let request = URLRequest(url: url)
|
||||
webView.load(request)
|
||||
}
|
||||
}else{
|
||||
if let url = URL(string: "https://retrogameemulator.mystrikingly.com/terms") {
|
||||
let request = URLRequest(url: url)
|
||||
webView.load(request)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
22
Delta/Settings/PrivacyVC.xib
Normal file
22
Delta/Settings/PrivacyVC.xib
Normal file
@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="13142" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12042"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="PrivacyVC" customModuleProvider="target">
|
||||
<connections>
|
||||
<outlet property="view" destination="i5M-Pr-FkT" id="sfx-zR-JGt"/>
|
||||
</connections>
|
||||
</placeholder>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
|
||||
<view clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="i5M-Pr-FkT">
|
||||
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
|
||||
<viewLayoutGuide key="safeArea" id="fnl-2z-Ty3"/>
|
||||
</view>
|
||||
</objects>
|
||||
</document>
|
||||
@ -9,36 +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
|
||||
}
|
||||
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
|
||||
@ -48,25 +40,43 @@ 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,
|
||||
#keyPath(UserDefaults.sortSaveStatesByOldestFirst): true,
|
||||
#keyPath(UserDefaults.isPreviewsEnabled): true,
|
||||
#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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -76,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])
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,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 }
|
||||
}
|
||||
@ -153,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])
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,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])
|
||||
}
|
||||
}
|
||||
|
||||
@ -175,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])
|
||||
}
|
||||
}
|
||||
|
||||
@ -195,6 +205,28 @@ extension Settings
|
||||
}
|
||||
}
|
||||
|
||||
static var isAltJITEnabled: Bool {
|
||||
get {
|
||||
let isAltJITEnabled = UserDefaults.standard.isAltJITEnabled
|
||||
return isAltJITEnabled
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.isAltJITEnabled = newValue
|
||||
NotificationCenter.default.post(name: Settings.didChangeNotification, object: nil, userInfo: [NotificationUserInfoKey.name: Name.isAltJITEnabled])
|
||||
}
|
||||
}
|
||||
|
||||
static var respectSilentMode: Bool {
|
||||
get {
|
||||
let respectSilentMode = UserDefaults.standard.respectSilentMode
|
||||
return respectSilentMode
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.respectSilentMode = newValue
|
||||
NotificationCenter.default.post(name: Settings.didChangeNotification, object: nil, userInfo: [NotificationUserInfoKey.name: Name.respectSilentMode])
|
||||
}
|
||||
}
|
||||
|
||||
static func preferredCore(for gameType: GameType) -> DeltaCoreProtocol?
|
||||
{
|
||||
let key = self.preferredCoreSettingsKey(for: gameType)
|
||||
@ -212,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?
|
||||
@ -263,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?
|
||||
@ -321,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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -390,4 +422,8 @@ private extension UserDefaults
|
||||
@NSManaged var sortSaveStatesByOldestFirst: Bool
|
||||
|
||||
@NSManaged var isPreviewsEnabled: Bool
|
||||
|
||||
@NSManaged var isAltJITEnabled: Bool
|
||||
|
||||
@NSManaged var respectSilentMode: Bool
|
||||
}
|
||||
|
||||
@ -20,10 +20,12 @@ private extension SettingsViewController
|
||||
case controllers
|
||||
case controllerSkins
|
||||
case controllerOpacity
|
||||
case gameAudio
|
||||
case hapticFeedback
|
||||
case syncing
|
||||
case hapticTouch
|
||||
case cores
|
||||
case advanced
|
||||
case patreon
|
||||
case credits
|
||||
}
|
||||
@ -44,24 +46,33 @@ private extension SettingsViewController
|
||||
enum CreditsRow: Int, CaseIterable
|
||||
{
|
||||
case riley
|
||||
case shane
|
||||
case caroline
|
||||
case grant
|
||||
case litRitt
|
||||
case contributors
|
||||
case softwareLicenses
|
||||
}
|
||||
}
|
||||
|
||||
class SettingsViewController: UITableViewController
|
||||
{
|
||||
|
||||
|
||||
|
||||
@IBOutlet private var controllerOpacityLabel: UILabel!
|
||||
@IBOutlet private var controllerOpacitySlider: UISlider!
|
||||
|
||||
@IBOutlet private var respectSilentModeSwitch: UISwitch!
|
||||
@IBOutlet private var buttonHapticFeedbackEnabledSwitch: UISwitch!
|
||||
@IBOutlet private var thumbstickHapticFeedbackEnabledSwitch: UISwitch!
|
||||
@IBOutlet private var previewsEnabledSwitch: UISwitch!
|
||||
|
||||
@IBOutlet private var versionLabel: UILabel!
|
||||
|
||||
|
||||
|
||||
|
||||
@IBOutlet private var syncingServiceLabel: UILabel!
|
||||
|
||||
private var selectionFeedbackGenerator: UISelectionFeedbackGenerator?
|
||||
@ -74,7 +85,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)
|
||||
}
|
||||
@ -83,22 +94,23 @@ class SettingsViewController: UITableViewController
|
||||
{
|
||||
super.viewDidLoad()
|
||||
|
||||
if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
||||
{
|
||||
#if LITE
|
||||
self.versionLabel.text = NSLocalizedString(String(format: "Delta Lite %@", version), comment: "Delta Version")
|
||||
#else
|
||||
self.versionLabel.text = NSLocalizedString(String(format: "Delta %@", version), comment: "Delta Version")
|
||||
#endif
|
||||
}
|
||||
else
|
||||
{
|
||||
#if LITE
|
||||
self.versionLabel.text = NSLocalizedString("Delta Lite", comment: "")
|
||||
#else
|
||||
self.versionLabel.text = NSLocalizedString("Delta", comment: "")
|
||||
#endif
|
||||
}
|
||||
|
||||
// if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
||||
// {
|
||||
// #if LITE
|
||||
// self.versionLabel.text = NSLocalizedString(String(format: "Delta Lite %@", version), comment: "Delta Version")
|
||||
// #else
|
||||
// self.versionLabel.text = NSLocalizedString(String(format: "Delta %@", version), comment: "Delta Version")
|
||||
// #endif
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// #if LITE
|
||||
// self.versionLabel.text = NSLocalizedString("Delta Lite", comment: "")
|
||||
// #else
|
||||
// self.versionLabel.text = NSLocalizedString("Delta", comment: "")
|
||||
// #endif
|
||||
// }
|
||||
}
|
||||
|
||||
override func viewWillAppear(_ animated: Bool)
|
||||
@ -120,6 +132,32 @@ class SettingsViewController: UITableViewController
|
||||
self.update()
|
||||
}
|
||||
|
||||
|
||||
@IBAction func didmisiigame(_ sender: Any) {
|
||||
dismiss(animated: true)
|
||||
}
|
||||
|
||||
|
||||
@IBAction func aboutbtn(_ sender: Any) {
|
||||
let vc = AboutVC()
|
||||
self.present(vc, animated: true)
|
||||
}
|
||||
|
||||
@IBAction func privacybtn(_ sender: Any) {
|
||||
let vc = PrivacyVC()
|
||||
vc.type = 0
|
||||
self.present(vc, animated: true)
|
||||
}
|
||||
|
||||
|
||||
@IBAction func useragreemBtn(_ sender: Any) {
|
||||
let vc = PrivacyVC()
|
||||
vc.type = 1
|
||||
self.present(vc, animated: true)
|
||||
}
|
||||
|
||||
|
||||
|
||||
override func didReceiveMemoryWarning()
|
||||
{
|
||||
super.didReceiveMemoryWarning()
|
||||
@ -155,27 +193,31 @@ class SettingsViewController: UITableViewController
|
||||
|
||||
private extension SettingsViewController
|
||||
{
|
||||
|
||||
|
||||
func update()
|
||||
{
|
||||
self.controllerOpacitySlider.value = Float(Settings.translucentControllerSkinOpacity)
|
||||
self.updateControllerOpacityLabel()
|
||||
|
||||
self.syncingServiceLabel.text = Settings.syncingService?.localizedName
|
||||
|
||||
do
|
||||
{
|
||||
let records = try SyncManager.shared.recordController?.fetchConflictedRecords() ?? []
|
||||
self.syncingConflictsCount = records.count
|
||||
}
|
||||
catch
|
||||
{
|
||||
print(error)
|
||||
}
|
||||
|
||||
self.buttonHapticFeedbackEnabledSwitch.isOn = Settings.isButtonHapticFeedbackEnabled
|
||||
self.thumbstickHapticFeedbackEnabledSwitch.isOn = Settings.isThumbstickHapticFeedbackEnabled
|
||||
self.previewsEnabledSwitch.isOn = Settings.isPreviewsEnabled
|
||||
// self.controllerOpacitySlider.value = Float(Settings.translucentControllerSkinOpacity)
|
||||
// self.updateControllerOpacityLabel()
|
||||
//
|
||||
// self.respectSilentModeSwitch.isOn = Settings.respectSilentMode
|
||||
//
|
||||
// self.syncingServiceLabel.text = Settings.syncingService?.localizedName
|
||||
//
|
||||
// do
|
||||
// {
|
||||
// let records = try SyncManager.shared.recordController?.fetchConflictedRecords() ?? []
|
||||
// self.syncingConflictsCount = records.count
|
||||
// }
|
||||
// catch
|
||||
// {
|
||||
// print(error)
|
||||
// }
|
||||
|
||||
// self.buttonHapticFeedbackEnabledSwitch.isOn = Settings.isButtonHapticFeedbackEnabled
|
||||
// self.thumbstickHapticFeedbackEnabledSwitch.isOn = Settings.isThumbstickHapticFeedbackEnabled
|
||||
// self.previewsEnabledSwitch.isOn = Settings.isPreviewsEnabled
|
||||
//
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
|
||||
@ -207,6 +249,9 @@ private extension SettingsViewController
|
||||
|
||||
private extension SettingsViewController
|
||||
{
|
||||
|
||||
|
||||
|
||||
@IBAction func beginChangingControllerOpacity(with sender: UISlider)
|
||||
{
|
||||
self.selectionFeedbackGenerator = UISelectionFeedbackGenerator()
|
||||
@ -248,6 +293,13 @@ private extension SettingsViewController
|
||||
Settings.isPreviewsEnabled = sender.isOn
|
||||
}
|
||||
|
||||
@IBAction func toggleRespectSilentMode(_ sender: UISwitch)
|
||||
{
|
||||
Settings.respectSilentMode = sender.isOn
|
||||
}
|
||||
|
||||
|
||||
|
||||
func openTwitter(username: String)
|
||||
{
|
||||
let twitterAppURL = URL(string: "twitter://user?screen_name=" + username)!
|
||||
@ -269,6 +321,19 @@ private extension SettingsViewController
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14, *)
|
||||
func showContributors()
|
||||
{
|
||||
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
|
||||
@ -290,7 +355,8 @@ private extension SettingsViewController
|
||||
self.tableView.selectRow(at: selectedIndexPath, animated: true, scrollPosition: .none)
|
||||
}
|
||||
|
||||
case .localControllerPlayerIndex, .preferredControllerSkin, .translucentControllerSkinOpacity, .isButtonHapticFeedbackEnabled, .isThumbstickHapticFeedbackEnabled: break
|
||||
case .localControllerPlayerIndex, .preferredControllerSkin, .translucentControllerSkinOpacity, .respectSilentMode, .isButtonHapticFeedbackEnabled, .isThumbstickHapticFeedbackEnabled, .isAltJITEnabled: break
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
@ -307,14 +373,17 @@ private extension SettingsViewController
|
||||
|
||||
extension SettingsViewController
|
||||
{
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection sectionIndex: Int) -> Int
|
||||
{
|
||||
let section = Section(rawValue: sectionIndex)!
|
||||
switch section
|
||||
{
|
||||
case .controllers: return 1 // Temporarily hide other controller indexes until controller logic is finalized
|
||||
case .controllerSkins: return System.registeredSystems.count
|
||||
case .syncing: return SyncManager.shared.coordinator?.account == nil ? 1 : super.tableView(tableView, numberOfRowsInSection: sectionIndex)
|
||||
case .controllers: return 1
|
||||
case .controllerSkins: return 1 /*System.registeredSystems.count*/
|
||||
case .syncing: return 0 /*SyncManager.shared.coordinator?.account == nil ? 1 : super.tableView(tableView, numberOfRowsInSection: sectionIndex)*/
|
||||
case .controllerOpacity: return 3
|
||||
case .gameAudio: return 1
|
||||
default:
|
||||
if isSectionHidden(section)
|
||||
{
|
||||
@ -322,7 +391,8 @@ extension SettingsViewController
|
||||
}
|
||||
else
|
||||
{
|
||||
return super.tableView(tableView, numberOfRowsInSection: sectionIndex)
|
||||
// return super.tableView(tableView, numberOfRowsInSection: sectionIndex)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -367,7 +437,7 @@ extension SettingsViewController
|
||||
let preferredCore = Settings.preferredCore(for: .ds)
|
||||
cell.detailTextLabel?.text = preferredCore?.metadata?.name.value ?? preferredCore?.name ?? NSLocalizedString("Unknown", comment: "")
|
||||
|
||||
case .controllerOpacity, .hapticFeedback, .hapticTouch, .patreon, .credits: break
|
||||
case .controllerOpacity, .gameAudio, .hapticFeedback, .hapticTouch, .advanced, .patreon, .credits: break
|
||||
}
|
||||
|
||||
return cell
|
||||
@ -383,7 +453,8 @@ extension SettingsViewController
|
||||
case .controllers: self.performSegue(withIdentifier: Segue.controllers.rawValue, sender: cell)
|
||||
case .controllerSkins: self.performSegue(withIdentifier: Segue.controllerSkins.rawValue, sender: cell)
|
||||
case .cores: self.performSegue(withIdentifier: Segue.dsSettings.rawValue, sender: cell)
|
||||
case .controllerOpacity, .hapticFeedback, .hapticTouch, .syncing: break
|
||||
case .controllerOpacity, .gameAudio, .hapticFeedback, .hapticTouch, .syncing: break
|
||||
case .advanced: self.showExperimentalFeatures()
|
||||
case .patreon:
|
||||
let patreonURL = URL(string: "altstore://patreon")!
|
||||
|
||||
@ -404,9 +475,14 @@ extension SettingsViewController
|
||||
switch row
|
||||
{
|
||||
case .riley: self.openTwitter(username: "rileytestut")
|
||||
case .shane: self.openTwitter(username: "shanegillio")
|
||||
case .caroline: self.openTwitter(username: "1carolinemoore")
|
||||
case .grant: self.openTwitter(username: "grantgliner")
|
||||
case .litRitt: self.openTwitter(username: "litritt_z")
|
||||
case .litRitt: self.openTwitter(username: "lit_ritt")
|
||||
case .contributors:
|
||||
guard #available(iOS 14, *) else { return }
|
||||
self.showContributors()
|
||||
|
||||
case .softwareLicenses: break
|
||||
}
|
||||
}
|
||||
@ -414,13 +490,35 @@ extension SettingsViewController
|
||||
|
||||
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat
|
||||
{
|
||||
primary:
|
||||
switch Section(rawValue: indexPath.section)!
|
||||
{
|
||||
#if !BETA
|
||||
case .credits where indexPath.row == CreditsRow.litRitt.rawValue: return 0.0
|
||||
#endif
|
||||
default: return super.tableView(tableView, heightForRowAt: indexPath)
|
||||
case .credits:
|
||||
let row = CreditsRow(rawValue: indexPath.row)!
|
||||
switch row
|
||||
{
|
||||
case .grant:
|
||||
// Hide row on iOS 14 and above
|
||||
guard #available(iOS 14, *) else { break primary }
|
||||
return 0.0
|
||||
|
||||
case .litRitt:
|
||||
// Hide row on iOS 14 and above
|
||||
guard #available(iOS 14, *) else { break primary }
|
||||
return 0.0
|
||||
|
||||
case .contributors:
|
||||
// Hide row on iOS 13 and below
|
||||
guard #unavailable(iOS 14) else { break primary }
|
||||
return 0.0
|
||||
|
||||
default: break
|
||||
}
|
||||
|
||||
default: break
|
||||
}
|
||||
|
||||
return super.tableView(tableView, heightForRowAt: indexPath)
|
||||
}
|
||||
|
||||
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String?
|
||||
@ -476,4 +574,6 @@ extension SettingsViewController
|
||||
return super.tableView(tableView, heightForFooterInSection: section.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user