Compare commits

...

247 Commits
1.2 ... develop

Author SHA1 Message Date
忆海16
7399e8cd5c xibme 2024-05-29 14:55:03 +08:00
忆海16
8161aa6d8c 第一次提交 2024-05-27 11:54:20 +08:00
忆海16
18c51d8d1b 第一次提交 2024-05-27 11:53:32 +08:00
Riley Testut
af01b97faf Fixes N64 games crashing when compiled with Xcode 15
# Conflicts:
#	Delta.xcodeproj/project.pbxproj
2023-09-18 16:27:22 -05:00
Riley Testut
62ebf05a8f Updates wording of “Database Repaired” alert to mention conflicts.txt 2023-08-11 21:52:01 -05:00
Riley Testut
452e3dab06 Writes text file of possibly corrupted save files to Files apps instead of actually conflicting them
In practice, the number of conflicts was far too high for the number of save files that actually matter.
2023-08-11 21:51:32 -05:00
Riley Testut
43fedf6fcc Fixes compiling with Xcode 14 2023-08-11 21:37:21 -05:00
Riley Testut
9ab8cf29b6 Updates app version to 1.5b4 2023-08-11 18:57:28 -05:00
Riley Testut
e6cd5475e9 Replaces Logger.debug() usage with Logger.notice()
Debug logs aren’t exported from release builds, so we now use `notice` level instead.
2023-08-11 18:57:28 -05:00
Riley Testut
9c31b8a864 Updates wording of “Database Repaired” alert 2023-08-11 18:57:28 -05:00
Riley Testut
5c7e3cc5b9 [ExpFeat] Adds filter button to change RepairSaveStatesViewController’s date range
* Recent (Last month)
* All
2023-08-11 18:57:28 -05:00
Riley Testut
d5f910ff00 Adds missing throws to SaveState.awakeFromSync() 2023-08-11 18:57:28 -05:00
Riley Testut
d1643dbc8f Throws SyncValidationError when downloading corrupted versions of “verified” SaveStates
After reviewing save states upon first launch, Delta will upload the verified game ID as metadata to ensure other devices don’t download remote versions with incorrect relationships.
2023-08-11 18:57:27 -05:00
Riley Testut
dc3a5b479c [Contributors] Credits Chris Rittenhouse for Alternate App Icons 2023-08-11 15:58:43 -05:00
Riley Testut
b3d8dbc554 [ExpFeat] Fixes Controller Skin alternate app icon filename 2023-08-11 15:57:39 -05:00
Riley Testut
f3534e4415 [ExpFeat] Alternate App Icons (#259)
commit 34ed9726034adbb439515ccb3d576311603bfe2b
Author: Chris Rittenhouse <dev@litritt.com>
Date:   Thu Aug 3 15:31:47 2023 -0400

    Removes `experimentalFeatures` property from `Settings`

commit 4e31d22d56d68439a340707d2fdbac3763df1e3a
Author: Chris Rittenhouse <dev@litritt.com>
Date:   Thu Aug 3 15:17:47 2023 -0400

    Reorder icons

commit 7509eaa29946b622ce0ad920a740f0212db5e771
Author: Chris Rittenhouse <dev@litritt.com>
Date:   Thu Aug 3 15:17:24 2023 -0400

    Moves icon changing code to `AlternateAppIcons`

commit 84821ef99ded74c066563041618aec3c4acb30d7
Author: Chris Rittenhouse <dev@litritt.com>
Date:   Thu Aug 3 14:33:15 2023 -0400

    Fixes icon authors

commit 0b821830f09b434fa375c37a2504c21ee5f90bdb
Author: Chris Rittenhouse <dev@litritt.com>
Date:   Fri Jul 21 01:44:00 2023 -0400

    Adds comments for functional alternate app icon code

commit e8dd6165619bc143f13dde51e5160af2695ef17e
Author: Chris Rittenhouse <dev@litritt.com>
Date:   Fri Jul 21 01:30:03 2023 -0400

    Adds implementation for changing app icon

commit 1e07483d18d1e865804ae34d6011844b63cb33cc
Author: Chris Rittenhouse <dev@litritt.com>
Date:   Fri Jul 21 01:28:24 2023 -0400

    Adds Alternate App Icon experimental feature and options

commit 46ed2ce3ae9593d4cc95f29d6caa09f33f2ce62d
Author: Chris Rittenhouse <dev@litritt.com>
Date:   Fri Jul 21 01:26:51 2023 -0400

    Adds extension to handle retrieving app icon images

commit 9dddb5f98f4523e7877f97c66a5ae95e6513cc09
Author: Chris Rittenhouse <dev@litritt.com>
Date:   Fri Jul 21 01:24:19 2023 -0400

    Adds experimental features as a property of `Settings`

commit d74038fd418a0e6fcd45942cb67e722904fc3e24
Author: Chris Rittenhouse <dev@litritt.com>
Date:   Fri Jul 21 01:23:02 2023 -0400

    Adds alternate icon assets

Co-authored-by: Chris Rittenhouse <dev@litritt.com>
2023-08-10 19:46:08 -05:00
Riley Testut
061f5abd3e Allows explicitly downloading corrupted record versions from RecordVersionsViewController 2023-08-10 19:33:27 -05:00
Riley Testut
be047b28a6 Fixes crash when restoring remote record without local version 2023-08-10 19:33:27 -05:00
Riley Testut
fcdd3c7840 Throws SyncValidationError when downloading corrupted Game or GameSave record 2023-08-10 19:33:27 -05:00
Riley Testut
ca8c2cb8c5 [ExpFeat] Adds “Review Save States” to fix save states associated with wrong game 2023-08-10 19:33:27 -05:00
Riley Testut
a9f15144ed Repairs corrupted Game, GameSave, and SaveState relationships on initial launch
Automatically fixes Game and GameSaves, but requires user to manually review + fix all recent SaveStates.
2023-08-10 19:33:27 -05:00
Riley Testut
a80ac04650 Fixes corrupted Harmony database relationships after heavyweight migration 2023-08-10 19:33:26 -05:00
Riley Testut
25e237cfcb [Delta Sync] Uses local modification date when uploading changes
Previously we used the server’s modification dates, which could be confusing when comparing against local modification dates since the same version may have different dates.
2023-07-10 19:00:39 -05:00
Riley Testut
3227ee4c49 Migrates Core Data model from v6 to v7 2023-07-10 16:09:07 -05:00
Riley Testut
45ed97c255 Actually fixes crash loading save states on iOS 17
The underlying issue causing the crash is that we were returning cached *supplementary view* layout attributes by accident from layoutAttributesForItem(at:).

Now, we only cache layout attributes for cells.
2023-07-10 15:39:31 -05:00
Riley Testut
31578e2e34 Updated mogenerator target to use Homebrew version 2023-07-07 18:22:33 -05:00
Riley Testut
e33a7c662f [Delta Sync] Delays sync until after interactive Settings dismissal has completed
Avoids false-positives when user scrolls to top of a Settings screen.
2023-07-07 18:22:33 -05:00
Riley Testut
8ea40a4728 [Delta Sync] Fixes queued syncs sometimes fetching outdated changes
Rare, but more common when performing initial sync.
2023-07-07 18:22:32 -05:00
Riley Testut
29f152fcb3 [Delta Sync] Repairs references to remote files when signing out then back in
Fixes redundant uploads and potentially ophaned remote files, the latter due to no remote files being deleted if LocalRecord.remoteFiles is empty.
2023-07-07 18:22:26 -05:00
Riley Testut
5779927831 [Delta Sync] Delays seeding Harmony database until initial sync 2023-07-07 18:19:07 -05:00
Riley Testut
f184639c6b [Delta Sync] Fixes incorrect mass conflicts when signing out then back in 2023-07-07 18:17:13 -05:00
Riley Testut
043fb923ae [Delta Sync] Displays activity indicator when signing-in
Without an indicator it can feel like the app has froze, when really it’s just preparing to sync.
2023-07-07 14:59:41 -05:00
Riley Testut
19fb333a67 Toggles Delta Sync switch back on if user cancels “Disable Syncing?” warning alert 2023-07-06 19:11:09 -05:00
Riley Testut
35a8f90a1c Converts Delta Sync action sheets into alerts
Implicitly fixes crashing on iPad.
2023-07-06 18:44:18 -05:00
Riley Testut
c898f72847 Fixes “file does not exist” sync error after restoring previous version
Due to a bug, it was possible for Harmony records to contain duplicate file entries after restoring a previous version. Harmony would then try to download _all_ files, including the duplicates, and would fail if any of them no longer exist.

These changes fix the underlying bug, and also fix existing corrupted records on the fly.
2023-06-28 13:45:44 -05:00
Riley Testut
981c868f6e Fixes “Harmony.sqlite couldn’t be opened” sync error when there are more than 1000 games 2023-06-28 13:37:20 -05:00
Riley Testut
15e228f287 Adds GameSave.sha1 to sync hash between devices
Fixes redundant record uploads post-sync due to comparing hashes against locally-cached hash via extended attributes.
2023-06-28 13:33:16 -05:00
Riley Testut
707116a39b Automatically resolves Cheat + ControllerSkin sync conflicts 2023-06-27 18:58:41 -05:00
Riley Testut
750740ac16 Updates app version to 1.5b3 (69) 2023-06-22 13:11:13 -05:00
Riley Testut
6909b6248f Skips checksum verification when importing zipped games
Verification can take an annoyingly long time, especially for larger ROMs.
2023-06-19 19:19:40 -05:00
Riley Testut
8bd6fe1e11 Replaces UIDocumentBrowserViewController with UIDocumentPickerViewController on iOS 17+
Prior to iOS 17, UIDocumentPickerViewController was too buggy to reliably use with iCloud Drive.
2023-06-19 19:19:40 -05:00
Riley Testut
4cf705f141 [ExpFeat] Fixes saving screenshots to Photos as JPEGs 2023-06-16 16:57:00 -05:00
Riley Testut
99417b418a [ExpFeat] Adds missing @unknown default to PHAuthorizationStatus switch statement 2023-06-16 16:46:35 -05:00
Riley Testut
648e9b8393 [ExpFeat] Fixes saving screenshots to Photos with .limited authorization 2023-06-16 16:44:24 -05:00
Riley Testut
69eff8fa28 Fixes displaying system name in dark text (again) when remapping inputs on iOS 17 2023-06-16 15:55:51 -05:00
Riley Testut
3e858c652f Fixes crash loading save states on iOS 17
Crashes due to GridCollectionViewLayout returning outdated cached layout information when inserting “Auto” save states section for the first time.

To fix this, we now clear the layout cache in invalidateLayout(with:) when relevant.
2023-06-16 15:25:49 -05:00
Riley Testut
21147969ea Fixes GridCollectionViewCell "Unable to simultaneously satisfy constraints" runtime error 2023-06-16 15:18:02 -05:00
Riley Testut
731de7023f Merge branch 'app_store' into develop 2023-06-16 15:14:39 -05:00
Riley Testut
810bc4572c Replaces app icon variants with single 1024x1024 version 2023-06-16 15:11:59 -05:00
Riley Testut
4e8580b8f5 Obfuscates private API usage to pass TestFlight App Review 2023-06-16 15:04:47 -05:00
Riley Testut
2e21141bc6 [Pods] Removes Fabric + Crashlytics 2023-05-10 13:36:44 -05:00
Riley Testut
08a40b3516 [Pods] Updates GoogleSignIn dependency to 5.0.2
* GoogleSignIn (4.4.0 -> 5.0.2)
* GTMSessionFetcher (1.5.0 -> 1.7.2)
* GoogleToolboxForMac (Removed)
2023-05-10 13:30:30 -05:00
Riley Testut
7b9ab2488e Fixes accidentally deleting Games directory during sync in rare circumstances
If a Game with empty filename happens to be deleted (e.g. during sync merge), it will accidentally delete the *entire* Games folder due to its fileURL being “Games/[empty]”.

To prevent this, we now explicitly check that a Game’s identifier isn’t empty AND that that Game.fileURL doesn’t point to a directory before deleting local files.
2023-05-02 14:29:23 -05:00
Riley Testut
00121bd31f [iPad] Fixes crash when resolving sync merge conflicts 2023-05-02 14:29:23 -05:00
Riley Testut
ea260cb8a6 Updates Settings to use large titles where appropriate
* SettingsViewController
* PreferredControllerSkinsViewController
2023-05-02 14:29:23 -05:00
Riley Testut
b0bd5ba906 Updates most Settings view controllers to use .insetGrouped style
Excludes PreferredControllerSkinsViewController and ControllerSkinsViewController
2023-04-28 17:22:20 -05:00
Riley Testut
726c4ab93b Resolves Settings.storyboard ambiguity warnings
Expands static UITableViewController subclasses to show all static cells.

Wow I wish I knew this years ago…
2023-04-28 16:54:21 -05:00
Riley Testut
5dc91c87c6 Updates app version to 1.5b2 2023-04-28 16:04:51 -05:00
Riley Testut
de7a812cbd [Contributors] Credits Chris Rittenhouse for experimental features
* Show Status Bar
* Game Screenshots
* Toast Notifications
2023-04-28 15:56:51 -05:00
Riley Testut
b1a3a5076f Merge branch 'experimental_features' into develop
# Conflicts:
#	Delta.xcodeproj/project.pbxproj
#	Delta/Emulation/GameViewController.swift
#	Delta/Pause Menu/PauseViewController.swift
2023-04-28 15:51:08 -05:00
Riley Testut
9470caf83a Sorts Delta’s Info.plist items alphabetically 2023-04-28 15:42:22 -05:00
Riley Testut
bdee5d17a5 Updates Xcode project file format
* Adds DEVELOPMENT_TEAM to Delta target
* Updates LD_RUNPATH_SEARCH_PATHS format
2023-04-28 15:42:22 -05:00
Riley Testut
85d53162c1 Fixes internal location of CharacterSet+Filename.swift in Xcode project 2023-04-28 15:42:22 -05:00
Riley Testut
75814ca04d [Experimental Feature] Adds VariableFastForward.allowUnrestrictedSpeeds @Option
When enabled, the user can choose any integer speed from 1x to 8x for their preferred Fast Forward speed, regardless of the system’s maximumFastForwardSpeed.
2023-04-28 15:42:22 -05:00
Riley Testut
2ead48ad40 [Features] Supports dynamic @Option values that may change over time
Uses @autoclosure to keep call site the same, but allows a single picker @Option to show different values depending on other factors (e.g. other @Options).
2023-04-28 15:42:11 -05:00
Riley Testut
6fd7f9e1d5 [Experimental Feature] Implements VariableFastForward feature 2023-04-28 15:42:10 -05:00
Riley Testut
7fceccc114 [Experimental Feature] Toast Notifications (#244)
commit c340cf842fbf5fea476a6637efe4928dbd734eba
Author: Chris Rittenhouse <dev@litritt.com>
Date:   Thu Apr 27 17:24:30 2023 -0400

    Addresses Riley's requested changes
    - Minor code structure change in extension
    - Minor changes to text and phrasing

commit 2a928dfa637dfb503e861dc863f6f85f5240941a
Author: Chris Rittenhouse <dev@litritt.com>
Date:   Wed Apr 26 20:11:38 2023 -0400

    Adds implementation for Toast Notifications Experimental Feature

commit 4fa7d139669994eff888c41bf7af9ac0b6cd2a75
Author: Chris Rittenhouse <dev@litritt.com>
Date:   Wed Apr 26 20:11:04 2023 -0400

    Adds @Feature and @Options for Toast Notifications Experimental Feature

Co-authored-by: Chris Rittenhouse <dev@litritt.com>
2023-04-28 15:32:13 -05:00
Chris Rittenhouse
6bdc05f640
[Experimental Feature] Game Screenshots (#242)
* Adds extension to handle interactions with Photos app
- isAuthorized to check/ask for authorization
- saveUIImage to save a UIImage to Photos

* Adds a Pause Menu button for taking game screenshots

* Adds @Feature and @Options for Game Screenshots

* Implements Game Screenshots feature in GameViewController

* Updates project settings

* Passes call to save to Photos as a closure into authorization prompt
- Ensures that the screenshot is saved when the user is first prompted for access to Photos
- More elegant extension code

---------

Co-authored-by: Riley Testut <riley@rileytestut.com>
2023-04-28 14:35:26 -05:00
Chris Rittenhouse
05e94902b8
[Experimental Feature] Show Status Bar (#241)
* Adds @Feature for Show Status Bar
* Shows the Status Bar if the @Feature is enabled
* Forces light content on Status Bar
2023-04-28 14:26:57 -05:00
Riley Testut
233ef7d418 Removes accidental togglePreviewsEnabled: action selector from isAltJITEnabled switch 2023-04-27 16:39:13 -05:00
Riley Testut
5cef975a9e Fixes incorrect controller skin appearance after rotating device 2023-04-27 15:20:14 -05:00
Riley Testut
39522fda58 [Experimental Feature] Supports AirPlay controller skins
Allows users to customize controller skin when AirPlaying games to an external display.
2023-04-27 14:58:51 -05:00
Riley Testut
1137189b57 Adds Nintendo DS AirPlay settings to customize how games appear on TV
* Top Screen Only = Shows just the top screen on TV
* Layout Horizontally = Places screens side-by-side on TV (instead of stacked vertically)
2023-04-26 16:07:17 -05:00
Riley Testut
32e7c1f93e Supports AirPlaying games to external displays 2023-04-26 14:56:45 -05:00
Riley Testut
7ad5df1949 [Features] Fixes potential crash when handling .settingsDidChange Notification
Handling .settingsDidChange notification in the same run loop as changing an @Option’s value can result in exclusive memory access violation due to accessing Option.settingsKey. As a workaround, we delay posting the notification until a later run loop via Task.
2023-04-25 15:14:16 -05:00
Riley Testut
77b26210ab [Experimental Features] Adds (unimplemented) VariableFastForward feature 2023-04-21 14:51:45 -05:00
Riley Testut
20749c5419 Adds “Experimental Features” section to SettingsViewController 2023-04-21 14:51:45 -05:00
Riley Testut
14adb41ff8 [Previews] Adds example “Random Dancing” experimental feature 2023-04-21 14:51:45 -05:00
Riley Testut
8efefd19a0 [Previews] Adds example “Custom Tint Color” feature to preview ExperimentalFeaturesView 2023-04-21 14:51:45 -05:00
Riley Testut
bd0c72e847 Adds ExperimentalFeaturesView to browse and configure experimental features 2023-04-21 14:03:10 -05:00
Riley Testut
5b4f9ea593 [Features] Removes redundant public access modifier from SettingsUserInfoKey constants 2023-04-21 13:43:24 -05:00
Riley Testut
ef47d78c64 [Features] Adds displayInline(_:) View modifier
Controls whether @Option detailView will be displayed inline in the Feature detail view in Delta’s settings, or as a full-screen view that’s pushed onto navigation stack when tapped.
2023-04-14 18:23:34 -05:00
Riley Testut
9f40223e6c [Features] Provides default toggle view for Bool @Options 2023-04-14 18:15:26 -05:00
Riley Testut
6d95924145 [Features] Provides default picker view for @Options with pre-set values
To use, pass in a collection of values to `values` parameter in @Option initializer.
2023-04-14 18:10:55 -05:00
Riley Testut
240b74de94 [Features] Supports user-facing @Options with custom SwiftUI views
@Options with non-nil names will be exposed in Delta’s settings and can be configured by users via provided SwiftUI view.
2023-04-14 16:41:16 -05:00
Riley Testut
4d30ef2929 [Features] Supports @Feature-specific settings via @Option property wrapper 2023-04-14 16:17:31 -05:00
Riley Testut
415450a943 [Features] Adds UserDefaults+OptionValues to read/write arbitrary OptionValues 2023-04-14 15:38:19 -05:00
Riley Testut
80a9132ff5 [Features] Adds OptionValue + LocalizedOptionValue 2023-04-13 17:22:18 -05:00
Riley Testut
d613cc9ad7 [Features] Adds @Feature property wrapper
Simplifies defining feature flags that can enabled + disabled via UserDefaults.
2023-04-13 16:20:54 -05:00
Riley Testut
3fb7e8b4b7 [Features] Moves Notification.Name.settingsDidChange to DeltaFeatures
Re-exports as Settings.didChangeNotification to allow accessing without explicitly importing DeltaFeatures.
2023-04-13 15:57:25 -05:00
Riley Testut
9406cfe6cc [Features] Moves Settings.Name + Settings.NotificationUserInfoKey to DeltaFeatures
Also refactors from enums to RawRepresentable structs to support arbitrary values.
2023-04-13 15:43:43 -05:00
Riley Testut
c3af9f7209 [Features] Adds DeltaFeatures target 2023-04-13 15:08:17 -05:00
Riley Testut
af0d239de1 [Previews] Links target with DeltaCore and Roxas pods 2023-04-13 15:00:33 -05:00
Riley Testut
ff65b15277 [Pods] Updates Cocoapods to 1.12 2023-04-13 14:59:24 -05:00
Riley Testut
91d40cbeea [Previews] Adds DeltaPreviews target for previewing SwiftUI views
The main Delta target cannot use SwiftUI Previews due to linker errors with Systems.framework, so we’ve made a separate target specifically for previewing SwiftUI views.
2023-04-13 14:58:58 -05:00
Riley Testut
21f628fd1e [Contributors] Credits Ian Clawson for Local Multiplayer 2023-02-28 19:00:51 -06:00
Riley Testut
f5b124b175 Updates app version to 1.5b1 2023-02-28 18:52:51 -06:00
Riley Testut
10af836105 Supports local multiplayer (up to 4 players)
Heavily based on Ian Clawson’s PRs, including #128
2023-02-28 18:49:32 -06:00
Riley Testut
5a019e5950 Optimizes default input mappings for Joy-Cons, Switch Online, and other non-MFi controllers
* Uses Home button as Menu button when possible
* Supports “Start/Select” buttons for Switch NES controllers
* Rotates single JoyCon face buttons 90º
* Re-maps single JoyCon control stick from dPad to leftAnalogStick
* Re-maps L1/R1/L2/R2 buttons for N64 core
2023-02-28 18:12:14 -06:00
Riley Testut
c8860c6aaa Raises deployment target to iOS 14.0 2023-02-28 17:31:28 -06:00
Riley Testut
fb0975f0d6 Updates app version to 1.4 2023-02-28 17:31:28 -06:00
Riley Testut
dd314a12af Automatically disables AltJIT for non-BETA builds 2023-02-27 12:51:14 -06:00
Riley Testut
68ad2185dd Hides AltJIT setting for non-BETA builds 2023-02-27 12:46:04 -06:00
Riley Testut
66e5258368 Updates Chris Rittenhouse credits
Replaces GitHub link with personal website, and fixes incorrect Twitter handle.
2023-02-06 15:35:05 -06:00
Riley Testut
fcc19ae830 Updates app version to 1.4rc 2023-02-06 15:15:58 -06:00
Riley Testut
6a683be907 Replaces cheatbase.sqlite with trimmed cheatbase.zip
CheatBase now only contains two tables, CHEATS and CHEAT_CATEGORIES.

Delta unzips SQLite database from cheatbase.zip on launch whenever CheatBase.cheatsVersion changes.
2023-02-06 15:11:27 -06:00
Riley Testut
d79002ea6b Changes cheat activation alert title to “How to Activate" 2023-02-06 14:47:06 -06:00
Riley Testut
913cb788a2 Improves CheatBase error messages 2023-02-06 14:45:48 -06:00
Riley Testut
45665138b2 Adds “Contributors” section to Credits
Lists everyone who has contributed to Delta in some way besides the core team, as well as what they contributed.

Moves Grant Gliner and Chris Rittenhouse to Contributors from main Credits.
2023-02-06 14:35:43 -06:00
Riley Testut
d1c45c9ad0 Adds Shane Gill to Credits 2023-02-06 13:53:15 -06:00
Riley Testut
d31229001f Fixed incorrect font size for Credits section in Settings
All labels should use 17pt system font, but Credits used 16pt by accident
2023-02-06 13:41:48 -06:00
Riley Testut
c3a83d7542 Updates app version to 1.4b5 2023-01-31 14:36:38 -06:00
Riley Testut
5bc2f08084 Limits “Search CheatBase” option to DS games
CheatBase currently only contains cheats for DS games, so no use showing option for other systems (yet).
2023-01-31 14:36:38 -06:00
Riley Testut
041cce64b0 Prefetches CheatBase cheats + disables “Search CheatBase” option if there aren’t any 2023-01-31 14:36:38 -06:00
Riley Testut
74ccf1a246 Shrinks cheatbase.sqlite file size by removing unused columns 2023-01-31 14:36:38 -06:00
Riley Testut
a135ea236d Integrates CheatBase to browse and easily add cheats for recognized games
Limited to DS games right now.
2023-01-31 14:36:37 -06:00
Riley Testut
d204ea35bd Adds CheatBase dependency 2023-01-31 14:36:11 -06:00
Riley Testut
77983e73dd Adds “Respect Silent Mode” setting to configure whether Delta plays game audio in Silent Mode
Delta will also now automatically mute game audio if another app is playing audio.

[Missed] Mutes game audio correctly
2023-01-31 14:34:32 -06:00
Riley Testut
6aee8c84cd Updates app version to 1.4b4 2022-11-04 14:33:37 -05:00
Riley Testut
45757878d8 Fixes app freezing when using Hold Button 2022-11-04 14:30:39 -05:00
Riley Testut
8ccd86de0f Fixes re-activating sustained inputs 2022-11-04 14:28:14 -05:00
Riley Testut
ceb66d46be Updates Swift Packages 2022-10-24 15:25:45 -05:00
Riley Testut
0f2792fdbc Fixes “Hold Button” for continuous inputs mapped to discrete inputs 2022-10-24 15:19:42 -05:00
Riley Testut
eeae27cc24 Fixes using analog sticks as dpad inputs with Xbox controllers 2022-10-20 14:12:00 -05:00
Riley Testut
7677421ef4 Updates app version to 1.4b3 2022-10-19 17:22:46 -05:00
Riley Testut
d17a1f3d8f Fixes remapping continuous inputs 2022-10-19 17:14:22 -05:00
Riley Testut
7d93470738 Fixes taps sometimes not dismissing active callout view when remapping inputs 2022-10-19 17:07:56 -05:00
Riley Testut
a2b6771715 [iPad] Fixes software keyboard controller bugs on iPadOS 16.1 RC 2022-10-19 17:02:19 -05:00
Riley Testut
aa7ef0041f Enables parallelized compiling for Delta target
No longer uses “Manual” Build Order to determine compilation order.
2022-09-20 13:58:34 -05:00
Riley Testut
ee536f9ce5 [README] Updates compilation instructions
* Adds instructions for updating Systems.xcworkspace
* Asks user to install Git LFS
2022-08-17 10:51:47 -05:00
Riley Testut
b2ab33bcd1 Fixes “Revision.h file not found” compiler error 2022-08-17 10:51:15 -05:00
Riley Testut
48be35cbf2 Fixes displaying system name in dark text when remapping inputs on iOS 16 2022-08-15 16:18:04 -05:00
Riley Testut
fd2cf223dc Fixes missing controller skin when remapping inputs 2022-08-15 16:11:44 -05:00
Riley Testut
8155bc5ca8 Updates app version to 1.4b2 2022-08-15 11:43:46 -05:00
Riley Testut
cd6de86fb1 Fixes compiling with Xcode 13.x 2022-08-15 11:42:46 -05:00
Riley Testut
b2568efeb1 Updates dependencies 2022-08-14 11:10:04 -05:00
Riley Testut
5805b859f5 [iPad] Dismisses keyboard controller when pausing 2022-08-12 19:38:28 -05:00
Riley Testut
d061f56951 [iPad] Fixes keyboard controller not appearing when selecting buttons to hold 2022-08-12 19:37:26 -05:00
Riley Testut
c3e9cfe526 [iPad] Fixes keyboard controller sometimes using incorrect skin orientation 2022-08-12 19:35:37 -05:00
Riley Testut
3cf87afa2d [iPad] Initial support for Split View / Stage Manager controller skins
When Delta is in Split View, Slide Over, or windowed with Stage Manager, it will now present a special full-width “keyboard controller”, allowing for much easier gameplay than being constrained to the app window.

Each system’s controller skin has been updated to support Split View, which means every system can now be played without a game controller/keyboard when not in full screen.
2022-08-12 19:33:02 -05:00
Riley Testut
ea871c7520 Removes unnecessary ControllerSkin.inputs(for:at:)
ControllerSkinProtocol no longer requires this method, so we can just remove it.
2022-08-12 19:26:13 -05:00
Riley Testut
e1ee540d27 [iPad] Fixes automatically pausing + resuming emulation with Stage Manager on iOS 16 beta 5
We use DeltaCore’s new UIWindow subclass GameWindow in SceneDelegate to fix issues that were introduced with iOS 16 beta 5.

Also removes touchControllerSkin.layoutGuide = self.view.safeAreaLayoutGuide assignment which no longer compiles due to TouchControllerSkin refactoring.
2022-08-12 19:23:23 -05:00
Riley Testut
bb812c7f02 [iPad] Uses window size to determine TouchControllerSkin axis, not interface orientation
iPads in landscape orientation should only place DS screens side-by-side if the app window is wider than it is tall. Otherwise, it should use the default vertical screen layout.
2022-08-12 19:11:58 -05:00
Riley Testut
6ba648ed17 [iPad] Initial support for Split View / Stage Manager
Requires game controller (or hardware keyboard) to play games because we still need to update controller skins to support Split View.
2022-08-12 19:05:02 -05:00
Riley Testut
05a66a140e Fixes not detecting keyboard presses when remapping inputs 2022-08-11 17:42:36 -05:00
Riley Testut
52a68e28dd Switches to UIScene-based app lifecycle 2022-07-25 17:24:57 -05:00
Riley Testut
4829b393c5 Updates app version to 1.4b1 2022-06-01 10:00:06 -07:00
Riley Testut
b11766c973 [iPad] Updates standard controller skins to support iPad 2022-05-31 18:08:49 -07:00
Riley Testut
c09bfead65 Updates Cocoapods to 1.11.2 2022-05-31 18:05:30 -07:00
Riley Testut
7b1db2614f Fixes ControllerInputsViewController’s system picker UI 2022-05-31 18:03:26 -07:00
Riley Testut
973238e1a4 Disables AltJIT by default
melonDS save states created with JIT enabled cannot be loaded later without JIT, which made it very easy to accidentally replace a save state with one that could only be loaded when JIT is available.

We plan to remove AltJIT eventually for this reason, but for now we’ll just disable it by default.
2022-05-31 17:59:01 -07:00
Riley Testut
d4e22942b8 [Harmony] Fixes potential Core Data threading violation 2022-05-31 17:55:23 -07:00
Riley Testut
7c934cebe1 Merge branch 'ipad' into develop
# Conflicts:
#	Delta.xcodeproj/project.pbxproj
2022-05-31 17:51:31 -07:00
Riley Testut
b134c73301 Merge branch 'altjit' into develop
# Conflicts:
#	Delta.xcodeproj/project.pbxproj
2022-05-31 17:41:37 -07:00
Riley Testut
fce4fc1bec [iPad] Temporarily disables split view support 2022-05-16 14:54:42 -07:00
Riley Testut
977f3d8005 Fixes previous game controller remaining selected when changing controller 2022-05-09 16:44:29 -07:00
Riley Testut
edab6ea432 Fixes sharing games + exporting save files
* Makes temporary copy instead of symbolic link when exporting game
* Sanitizes game name before exporting game
* Fixes prematurely deleting temporary game
* Exported save file names now match exported game names
2022-04-28 18:13:25 -07:00
Riley Testut
9e437797d9 Improves CopyDeepLinkActivity
* Uses SF Symbol instead of bundled image
* Actually calls activityDidFinish(_:)
2022-04-28 16:57:06 -07:00
Riley Testut
25afda3b60 Replaces UIAlertController with UIMenu for importing games 2022-04-28 16:54:18 -07:00
Riley Testut
836297718b [iPad] Fixes game context menu actions 2022-04-28 16:38:14 -07:00
Riley Testut
da8415f4aa Improves SaveStatesViewController UI on iPad 2022-04-28 16:15:48 -07:00
Riley Testut
68c1b05313 Improves GameCollectionViewController UI on iPad 2022-04-28 15:47:05 -07:00
Riley Testut
2a4dbabae5 Fixes crash when presenting ImportController on iPad 2022-04-28 12:49:16 -07:00
Riley Testut
aafe673811 Fixes invisible navigation bar + toolbar on iPadOS 15 2022-04-27 17:57:55 -07:00
Riley Testut
adccf8fca5 Fixes ControllerInputsViewController UI on iPad 2022-04-27 16:15:09 -07:00
Riley Testut
d333672b95 Enables basic iPad support 2022-04-26 12:23:04 -07:00
Riley Testut
0df313188d Fixes keyboard support on iOS 15+ 2022-04-26 12:21:59 -07:00
Riley Testut
fd0427d2ad Fixes BIOSError compilation error when compiling with Xcode 13
Xcode 12 and older incorrectly let us declare BIOSError.incorrectSize as @available(iOS 13), but Swift doesn't support @available for enum cases with associated values. Xcode 13 now properly enforces this restriction, so we instead mark the enum itself as @available(iOS 13).
2022-04-19 14:20:40 -07:00
Riley Testut
394030ad43 Ensures melonDS is preferred DS core for release versions 2021-12-02 13:36:37 -08:00
Riley Testut
5f2e021560 Updates app version to 1.3.1 2021-12-02 13:14:39 -08:00
Riley Testut
17e1bf710b Dynamically maps outdated artwork URLs to correct URLs 2021-12-02 13:12:07 -08:00
Riley Testut
baf895939d Updates OpenVGDB to latest version
Fixes broken OpenVGDB cover art URLs due to host moving from gamefaqs1.cbsistatic.com to gamefaqs.gamespot.com.
2021-12-02 13:12:01 -08:00
Riley Testut
2705849cf2 Ensures melonDS is preferred DS core for release versions 2021-11-17 11:43:00 -08:00
Riley Testut
79bf977904 Updates app version to 1.3.1b 2021-11-16 16:39:30 -08:00
Riley Testut
11041ef1e9 Dynamically maps outdated artwork URLs to correct URLs 2021-11-16 16:31:58 -08:00
Riley Testut
08e870c94c Updates OpenVGDB to latest version
Fixes broken OpenVGDB cover art URLs due to host moving from gamefaqs1.cbsistatic.com to gamefaqs.gamespot.com.
2021-11-16 16:27:09 -08:00
Riley Testut
9492f3165e Hides AltJIT setting on unsupported devices 2021-11-16 15:04:19 -08:00
Riley Testut
f14bb0f890 Supports AltJIT
Automatically enables JIT for MelonDS core when on the same WiFi as AltServer.
2021-11-16 14:39:48 -08:00
Riley Testut
829d127269 Fixes BIOSError compilation error when compiling with Xcode 13
Xcode 12 and older incorrectly let us declare BIOSError.incorrectSize as @available(iOS 13), but Swift doesn't support @available for enum cases with associated values. Xcode 13 now properly enforces this restriction, so we instead mark the enum itself as @available(iOS 13).
2021-08-09 16:55:19 -07:00
Riley Testut
f1f75d8cc0 Updates app version to 1.3 2021-04-21 12:43:39 -07:00
Riley Testut
3128783105 Fixes melonDS graphical bugs when fast forwarding with JIT disabled 2021-04-21 12:41:35 -07:00
Riley Testut
abd7338a08 Replaces placeholder README with detailed README
Adds following sections to README:

* Intro
* Supported Systems
* Features
* Installation Instructions
* Project Requirements
* Licensing
* Contact Me
2021-04-21 12:41:16 -07:00
Riley Testut
c396698ca0 Fixes visible grid lines on DS controller skin buttons 2021-03-22 12:31:20 -07:00
Riley Testut
cd87438fb4 Updates app version to 1.3b5 2021-03-11 15:54:35 -06:00
Riley Testut
eafabb7f43 Enables melonDS core for public versions 2021-03-11 14:39:31 -06:00
Riley Testut
de315eacf5 Updates Licenses screen to include melonDS and Genesis Plus GX 2021-03-11 14:39:30 -06:00
Riley Testut
af6b92a441 Fixes erroneous sync error when syncing deleted files that were never uploaded 2021-03-11 13:49:15 -06:00
Riley Testut
d2215ed91e Updates DS controller skin 2021-03-11 13:27:24 -06:00
Riley Testut
20a470ddd1 Fixes crash playing DS games when another app is using microphone 2021-03-11 13:26:23 -06:00
Riley Testut
9f58aac350 Removes “(Beta)” from DS’s short name 2021-03-11 12:21:35 -06:00
Riley Testut
931a16c544 Hides Genesis skin credit for public versions 2021-03-10 14:33:57 -06:00
Riley Testut
8f7e7280f9 Removes support for 128KB DS firmwares
128KB DS firmwares come from DSi/3DS and aren’t bootable, so we consider them unsupported.
2021-03-10 14:32:09 -06:00
Riley Testut
8888b72d29 Limits DSi support to beta versions 2021-03-10 14:27:56 -06:00
Riley Testut
6cfca53dc8 Fixes game artwork not updating immediately when changed 2021-03-10 14:19:26 -06:00
Riley Testut
1bfe030dd9 Deletes temporary image file after changing artwork 2021-03-10 14:16:02 -06:00
Riley Testut
98ed657f8a Improves support for transparent and/or rotated custom artwork 2021-03-10 14:14:14 -06:00
Riley Testut
c22518ce23 Updates app version to 1.3b4 2021-02-24 13:29:43 -06:00
Riley Testut
58921cfb7f Fixes legacy plist format for GBA Game and Delta Skin UTIs 2021-02-24 13:25:07 -06:00
Riley Testut
dae3164d53 Credits Chris Rittenhouse (@litritt_z) for Genesis skin 2021-02-24 13:19:21 -06:00
Riley Testut
5ccf209af7 Changes Genesis placeholder skin to temporary one by @litritt_z 2021-02-24 13:17:25 -06:00
Riley Testut
bb6fbfea37 Replaces placeholder DS Home Screen image 2021-02-24 13:02:21 -06:00
Riley Testut
edb2af4dd5 Compares DSi BIOS files against unsupported files
DSi BIOS files can have various hashes, so rather than compare them against an expected hash, we now compare them against unsupported hashes and throw an error if it matches one.
2021-02-18 17:16:44 -06:00
Riley Testut
7c3b67fbfb Adds “Import Controller Skin” button to ControllerSkinsViewController 2021-02-18 14:07:39 -06:00
Riley Testut
bf2461fae1 Enables syncing DS(i) BIOS files
Excludes DSi NAND for now, since it is ~240MB and Harmony can’t selectively download files (yet).
2021-02-18 13:45:49 -06:00
Riley Testut
f316e7cca0 Fixes erroneous microphone indicator after returning from background 2021-02-12 14:57:57 -06:00
Riley Testut
3f70300afb Fixes incorrectly previewing DS home screen instead of DSi home screen 2021-02-12 13:18:55 -06:00
Riley Testut
2c52821e72 Fixes treating DS & DSi Home Screens as the same game 2021-02-12 13:14:33 -06:00
Riley Testut
4ed4b8ba06 Fixes misaligned ControllerInputsViewController callout views on iOS 14.5 2021-02-12 13:03:47 -06:00
Riley Testut
c3c6fb32cc Fixes ControllerInputsViewController DS layout
Presents ControllerInputsViewController full screen so DS portrait skin is not distorted.
2021-02-12 13:01:58 -06:00
Riley Testut
6f3a7501af Unlinks Cocoapods’ DeltaCore
Fixes runtime conflicts with Systems’ own DeltaCore.
2021-02-11 14:25:43 -06:00
Riley Testut
1b859ce769 [Systems] Fixes bitcode error when archiving 2021-02-10 12:31:27 -06:00
Riley Testut
46cb7db897 Disables cheats for Genesis games 2021-02-10 12:29:40 -06:00
Riley Testut
a29e4e61eb Emulates Sega Genesis games 2021-02-09 17:31:42 -06:00
Riley Testut
6a01a74608 Adds GPGXDeltaCore dependency
Links indirectly via new Systems.framework to avoid conflicts between Cocoapods and SwiftPM.
2021-02-09 16:51:47 -06:00
Riley Testut
945ffb1ed7 Enables new Xcode build system 2021-02-09 14:13:37 -06:00
Riley Testut
0a4c927277 Supports new Xcode build system 2021-02-09 14:13:13 -06:00
Riley Testut
c67b72068a Adds missing imports 2021-02-09 12:26:16 -06:00
Riley Testut
5127d5a65a Updates app version to 1.3b3 2021-01-21 15:18:36 -06:00
Riley Testut
c97611482e Fixes crash when previewing DS games without BIOS files 2021-01-19 14:41:55 -06:00
Riley Testut
f81f6cbf3d Hides DS “Home Screen” until BIOS files have been imported 2021-01-19 14:31:16 -06:00
Riley Testut
a079e68713 Changes default DS core from DeSmuME to melonDS 2021-01-19 14:20:00 -06:00
Riley Testut
66cfc272c1 Lowers MelonDSDeltaCore non-JIT maximum Fast Forward speed to 1.5x
Without JIT, the processor throttles very quickly at 2x. 1.5x allows at least a couple minutes before throttling kicks in.
2021-01-19 14:13:53 -06:00
Riley Testut
1871f69aca Disables JIT on iOS 14.4
JIT no longer works on iOS 14.4 beta 2, so disable for now until it (hopefully) works again.
2021-01-19 13:04:27 -06:00
Riley Testut
7034b1dd8a Fixes missing Fast Forward option for MelonDSDeltaCore 2021-01-19 12:49:55 -06:00
Riley Testut
3e0a983048 Validates melonDS BIOS files
Compares file sizes and MD5 hashes (when relevant) to ensure BIOS files are correct.
2021-01-19 12:44:54 -06:00
Riley Testut
826cf7b0b1 Fixes performance issues when previewing games + save states 2021-01-13 14:41:27 -06:00
Riley Testut
1b874ce9c1 Displays preview save state image even if context menu previews are disabled 2021-01-13 14:41:27 -06:00
Riley Testut
0ad0e752f8 Lowers N64DeltaCore maximum Fast Forward speed to 1.5x for some older devices 2021-01-13 14:41:03 -06:00
Riley Testut
d0823b1acb Raises MelonDSDeltaCore maximum Fast Forward speed to 3x 2021-01-11 14:14:02 -06:00
Riley Testut
474cae0a5b Updates MelonDSDeltaCore to 0.9.1 2021-01-11 14:05:10 -06:00
Riley Testut
0d5e7e97cc Adds “Context Menu Previews” setting
Controls whether Delta previews games and save states when using context menus. Enabled by default.
2021-01-04 14:30:56 -06:00
Riley Testut
a58d201b1b Fixes crash launching home screen shortcut when AppIconShortcutsViewController is visible 2021-01-04 14:22:44 -06:00
Riley Testut
58346140a8 Unhides “Home Screen Shortcuts” setting on devices without 3D Touch
All devices on iOS 13 or later support either 3D Touch or Haptic Touch, which means all devices now support home screen shortcuts.
2021-01-04 14:20:25 -06:00
Riley Testut
cb77be106a Renames “App Icon Shortcuts” setting to “Home Screen Shortcuts” 2021-01-04 14:19:42 -06:00
Riley Testut
ced5d6099e Fixes crash when customizing app icon shortcuts 2021-01-04 14:01:05 -06:00
Riley Testut
9f2f1bad77 Updates pods 2020-12-22 13:03:45 -06:00
Riley Testut
c7329136ac Supports touch inputs when external controller is connected 2020-12-10 15:34:16 -06:00
Riley Testut
a63de5db4d Updates MelonDSDeltaCore pod 2020-12-10 15:28:56 -06:00
Riley Testut
543e271563 Updates app version to 1.3b2 2020-12-02 12:09:43 -06:00
Riley Testut
ee79b6c8c9 Fixes garbled audio during emulation 2020-11-30 16:31:23 -06:00
Riley Testut
1f3f9d4bd1 Fixes MelonDS crash on devices without JIT support 2020-11-30 14:41:42 -06:00
Riley Testut
ffb6d7b02a Merge branch 'melonDS_0.9' into develop
# Conflicts:
#	Delta/Base.lproj/Settings.storyboard
2020-11-24 13:24:46 -06:00
Riley Testut
64df7b97ab Supports MelonDS JIT 2020-11-24 13:11:02 -06:00
Riley Testut
a1f80e74b5 Emulates DS microphone 2020-11-24 13:08:38 -06:00
Riley Testut
358accbcb7 Adds DSi support
Requires DSi BIOS files.
2020-11-24 13:06:29 -06:00
Riley Testut
8a273b31d2
Merge pull request #82 from ericlewis/support-dark-mode
feat: support iOS 13+ dark mode
2020-11-06 11:59:30 -08:00
Eric Lewis
15cab0bca8 feat: support iOS 13+ dark mode 2020-11-06 11:45:25 -05:00
633 changed files with 40142 additions and 18674 deletions

1
.gitignore vendored
View File

@ -8,6 +8,7 @@
## Build generated ## Build generated
build/ build/
DerivedData DerivedData
.build
## Various settings ## Various settings
*.pbxuser *.pbxuser

6
.gitmodules vendored
View File

@ -28,3 +28,9 @@
[submodule "Cores/MelonDSDeltaCore"] [submodule "Cores/MelonDSDeltaCore"]
path = Cores/MelonDSDeltaCore path = Cores/MelonDSDeltaCore
url = https://github.com/rileytestut/MelonDSDeltaCore.git url = https://github.com/rileytestut/MelonDSDeltaCore.git
[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 b59ca197068be7cf8ca66ff52b24e686154a1c15 Subproject commit 6c84366b3a76045782905293c9616e33f5da1a35

@ -1 +1 @@
Subproject commit 212cb618d7e3b90fd978c52c84e9c8c233982e37 Subproject commit c1db5f51cd455a7033801cc19dc3dbfcb6f2b42c

@ -1 +1 @@
Subproject commit e9327e44dde45e8eef15d28895e50e69ce031f4b Subproject commit 8ea36dff87bc1f787765de45fa8ccbcc1256a0e3

@ -1 +1 @@
Subproject commit 4313fa6670ab534e70d13532c2504761f849c432 Subproject commit 81f8ffba56823637706689fb5c6bc634ee4d9b32

1
Cores/GPGXDeltaCore Submodule

@ -0,0 +1 @@
Subproject commit 18c595887a12ef23e0d54c63f83c91c99e7f4827

@ -1 +1 @@
Subproject commit 677d1e35da42855c29afa7c92882eeb051372d94 Subproject commit 697ba731981824f53460f6e0193f159f71f22ba2

@ -1 +1 @@
Subproject commit d92746745d52ea255579c707e005cfd57c4c85b7 Subproject commit c8816c51f82210a9c4cc62b1a7c53fa21bc705ee

@ -1 +1 @@
Subproject commit 78fa7db707655962a1077f4681c35fcf81510060 Subproject commit 78a092d4e795f83153e98749b5cbeb66cf812d7e

@ -1 +1 @@
Subproject commit e16b8aa94593df3ee5e65ad6b748de25d59f3a60 Subproject commit d5717291325578f64d519822aeb2be81217c67f3

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@
LastUpgradeVersion = "1020" LastUpgradeVersion = "1020"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "NO" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
@ -29,7 +29,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "BFFA71D61AAC406100EE9DD1" BlueprintIdentifier = "BFFA71D61AAC406100EE9DD1"
BuildableName = "Delta.app" BuildableName = "Retro Game Emulator.app"
BlueprintName = "Delta" BlueprintName = "Delta"
ReferencedContainer = "container:Delta.xcodeproj"> ReferencedContainer = "container:Delta.xcodeproj">
</BuildableReference> </BuildableReference>
@ -45,7 +45,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "BFFA71D61AAC406100EE9DD1" BlueprintIdentifier = "BFFA71D61AAC406100EE9DD1"
BuildableName = "Delta.app" BuildableName = "Retro Game Emulator.app"
BlueprintName = "Delta" BlueprintName = "Delta"
ReferencedContainer = "container:Delta.xcodeproj"> ReferencedContainer = "container:Delta.xcodeproj">
</BuildableReference> </BuildableReference>
@ -54,7 +54,7 @@
</Testables> </Testables>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
enableASanStackUseAfterReturn = "YES" enableASanStackUseAfterReturn = "YES"
@ -69,7 +69,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "BFFA71D61AAC406100EE9DD1" BlueprintIdentifier = "BFFA71D61AAC406100EE9DD1"
BuildableName = "Delta.app" BuildableName = "Retro Game Emulator.app"
BlueprintName = "Delta" BlueprintName = "Delta"
ReferencedContainer = "container:Delta.xcodeproj"> ReferencedContainer = "container:Delta.xcodeproj">
</BuildableReference> </BuildableReference>
@ -107,7 +107,7 @@
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "BFFA71D61AAC406100EE9DD1" BlueprintIdentifier = "BFFA71D61AAC406100EE9DD1"
BuildableName = "Delta.app" BuildableName = "Retro Game Emulator.app"
BlueprintName = "Delta" BlueprintName = "Delta"
ReferencedContainer = "container:Delta.xcodeproj"> ReferencedContainer = "container:Delta.xcodeproj">
</BuildableReference> </BuildableReference>

View File

@ -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>

View 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 = "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>

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1250"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BF6E70B925D2187800E41CD1"
BuildableName = "Systems"
BlueprintName = "Systems"
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 = "BF6E70B925D2187800E41CD1"
BuildableName = "Systems"
BlueprintName = "Systems"
ReferencedContainer = "container:Delta.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View 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>

View File

@ -28,6 +28,9 @@
<FileRef <FileRef
location = "group:Cores/DSDeltaCore/DSDeltaCore.xcodeproj"> location = "group:Cores/DSDeltaCore/DSDeltaCore.xcodeproj">
</FileRef> </FileRef>
<FileRef
location = "group:Cores/GPGXDeltaCore">
</FileRef>
<FileRef <FileRef
location = "group:External/Harmony/Harmony.xcodeproj"> location = "group:External/Harmony/Harmony.xcodeproj">
</FileRef> </FileRef>

View File

@ -2,9 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>BuildSystemType</key>
<string>Original</string>
<key>IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded</key> <key>IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded</key>
<false/> <false/>
<key>PreviewsEnabled</key>
<false/>
</dict> </dict>
</plist> </plist>

View File

@ -0,0 +1,34 @@
{
"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": "ios14",
"revision": "74d2a7a6e36035cb5730d0b0cf2456cbeb6faf0c",
"version": null
}
},
{
"package": "ZIPFoundation",
"repositoryURL": "https://github.com/weichsel/ZIPFoundation.git",
"state": {
"branch": null,
"revision": "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0",
"version": "0.9.19"
}
}
]
},
"version": 1
}

View File

@ -10,9 +10,7 @@ import UIKit
import DeltaCore import DeltaCore
import Harmony import Harmony
import AltKit
import Fabric
import Crashlytics
private extension CFNotificationName private extension CFNotificationName
{ {
@ -39,30 +37,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate
Settings.registerDefaults() Settings.registerDefaults()
self.registerCores() 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() self.configureAppearance()
// Controllers // Controllers
ExternalGameControllerManager.shared.startMonitoring() ExternalGameControllerManager.shared.startMonitoring()
// JIT
ServerManager.shared.prepare()
// Notifications // Notifications
let center = CFNotificationCenterGetDarwinNotifyCenter() let center = CFNotificationCenterGetDarwinNotifyCenter()
CFNotificationCenterAddObserver(center, nil, ReceivedApplicationState, CFNotificationName.altstoreRequestAppState.rawValue, nil, .deliverImmediately) 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 private extension AppDelegate
{ {
func registerCores() func registerCores()
@ -128,7 +138,7 @@ private extension AppDelegate
#if BETA #if BETA
System.allCases.forEach { Delta.register($0.deltaCore) } System.allCases.forEach { Delta.register($0.deltaCore) }
#else #else
System.allCases.filter { $0 != .ds }.forEach { Delta.register($0.deltaCore) } System.allCases.filter { $0 != .genesis }.forEach { Delta.register($0.deltaCore) }
#endif #endif
#endif #endif

View File

@ -1,13 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <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"> <device id="retina4_7" orientation="portrait" appearance="light"/>
<adaptation id="fullscreen"/>
</device>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13526"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22684"/>
<capability name="Aspect ratio constraints" minToolsVersion="5.1"/>
<capability name="Constraints with non-1.0 multipliers" minToolsVersion="5.1"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
<scenes> <scenes>
@ -23,21 +19,32 @@
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/> <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<imageView userInteractionEnabled="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="Delta" translatesAutoresizingMaskIntoConstraints="NO" id="plh-tL-LY0"> <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="94" y="250" width="187.5" height="167"/> <rect key="frame" x="0.0" y="50" width="375" height="378"/>
<constraints>
<constraint firstAttribute="width" secondItem="plh-tL-LY0" secondAttribute="height" multiplier="64:57" id="8qM-L2-ASa"/>
</constraints>
</imageView> </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> </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> <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="5XD-I3-tLg" firstAttribute="top" secondItem="qMb-3x-uIu" secondAttribute="bottom" constant="30" id="Aiv-ac-bYx"/>
<constraint firstItem="plh-tL-LY0" firstAttribute="centerY" secondItem="8Uu-wz-ps8" secondAttribute="centerY" id="COW-Co-NFK"/> <constraint firstItem="wWH-Lx-U9x" firstAttribute="centerX" secondItem="vhb-Xd-o6a" secondAttribute="centerX" id="DEu-U7-qVq"/>
<constraint firstItem="plh-tL-LY0" firstAttribute="height" secondItem="8Uu-wz-ps8" secondAttribute="height" multiplier="0.5" priority="900" id="G3L-7B-xVc"/> <constraint firstAttribute="trailing" secondItem="5XD-I3-tLg" secondAttribute="trailing" id="Gc5-y0-Vsy"/>
<constraint firstItem="plh-tL-LY0" firstAttribute="width" secondItem="8Uu-wz-ps8" secondAttribute="width" multiplier="0.5" priority="950" id="n3i-kS-7eQ"/> <constraint firstItem="vhb-Xd-o6a" firstAttribute="top" secondItem="5XD-I3-tLg" secondAttribute="bottom" constant="20" id="Ncn-Yh-ecr"/>
<constraint firstItem="plh-tL-LY0" firstAttribute="centerX" secondItem="8Uu-wz-ps8" secondAttribute="centerX" id="sp5-Kf-N7G"/> <constraint firstItem="5XD-I3-tLg" firstAttribute="leading" secondItem="8Uu-wz-ps8" secondAttribute="leading" id="SSl-CS-XOC"/>
<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="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> </constraints>
</view> </view>
</viewController> </viewController>
@ -47,6 +54,6 @@
</scene> </scene>
</scenes> </scenes>
<resources> <resources>
<image name="Delta" width="1342" height="1196"/> <image name="LaunchViewC" width="375" height="472.5"/>
</resources> </resources>
</document> </document>

View File

@ -1,16 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"/> <device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <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"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
<scenes> <scenes>
<!--Games--> <!--Games View Controller-->
<scene sceneID="Cd2-Pf-cua"> <scene sceneID="Cd2-Pf-cua">
<objects> <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> <layoutGuides>
<viewControllerLayoutGuide type="top" id="WoX-O4-qy5"/> <viewControllerLayoutGuide type="top" id="WoX-O4-qy5"/>
<viewControllerLayoutGuide type="bottom" id="0om-QB-N5a"/> <viewControllerLayoutGuide type="bottom" id="0om-QB-N5a"/>
@ -25,48 +25,80 @@
<segue destination="tpK-ou-yEA" kind="embed" identifier="embedPageViewController" id="cjU-nW-cHY"/> <segue destination="tpK-ou-yEA" kind="embed" identifier="embedPageViewController" id="cjU-nW-cHY"/>
</connections> </connections>
</containerView> </containerView>
<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> </subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints> <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 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 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 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="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> </constraints>
</view> </view>
<navigationItem key="navigationItem" title="Games" id="pFk-as-3k4"> <navigationItem key="navigationItem" id="pFk-as-3k4">
<barButtonItem key="leftBarButtonItem" image="SettingsButton" id="2gg-lC-FhX"> <barButtonItem key="leftBarButtonItem" image="home" id="2gg-lC-FhX"/>
<connections> <barButtonItem key="rightBarButtonItem" style="plain" id="FeA-O5-xd2">
<segue destination="xMK-Cs-fAS" kind="presentation" identifier="showSettings" id="uN5-PN-7FK"/>
</connections>
</barButtonItem>
<barButtonItem key="rightBarButtonItem" systemItem="add" id="FeA-O5-xd2">
<connections> <connections>
<action selector="importFiles" destination="jeE-WD-wXO" id="A1s-kE-NkM"/> <action selector="importFiles" destination="jeE-WD-wXO" id="A1s-kE-NkM"/>
</connections> </connections>
</barButtonItem> </barButtonItem>
</navigationItem> </navigationItem>
<connections>
<outlet property="importButton" destination="FeA-O5-xd2" id="A44-3S-Okz"/>
</connections>
</viewController> </viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="JYx-xE-nis" userLabel="First Responder" sceneMemberID="firstResponder"/> <placeholder placeholderIdentifier="IBFirstResponder" id="JYx-xE-nis" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects> </objects>
<point key="canvasLocation" x="1036" y="1002"/> <point key="canvasLocation" x="1036" y="1001.649175412294"/>
</scene> </scene>
<!--Game Collection View Controller--> <!--Game Collection View Controller-->
<scene sceneID="qNA-NP-TiF"> <scene sceneID="qNA-NP-TiF">
<objects> <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"> <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"/> <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <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"/> <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="itemSize" width="100" height="100"/>
<size key="headerReferenceSize" width="0.0" height="0.0"/> <size key="headerReferenceSize" width="0.0" height="0.0"/>
<size key="footerReferenceSize" 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"/> <inset key="sectionInset" minX="0.0" minY="20" maxX="0.0" maxY="20"/>
</collectionViewFlowLayout> </collectionViewFlowLayout>
<cells> <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"/> <rect key="frame" x="0.0" y="20" width="100" height="100"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center"> <view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center">
@ -82,7 +114,7 @@
</collectionView> </collectionView>
<connections> <connections>
<segue destination="X2o-q6-XD5" kind="unwind" identifier="unwindFromGames" unwindAction="unwindFromGamesViewControllerWith:" id="k8C-Xn-maU"/> <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="qdE-gb-V2e" kind="presentation" identifier="preferredControllerSkins" id="i6y-cP-3WM"/>
<segue destination="V2x-v0-jWm" kind="presentation" identifier="showDSSettings" id="kuV-tY-Y0B"/> <segue destination="V2x-v0-jWm" kind="presentation" identifier="showDSSettings" id="kuV-tY-Y0B"/>
</connections> </connections>
@ -95,7 +127,7 @@
<!--Launch View Controller--> <!--Launch View Controller-->
<scene sceneID="p7y-IT-nlb"> <scene sceneID="p7y-IT-nlb">
<objects> <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> <layoutGuides>
<viewControllerLayoutGuide type="top" id="Qap-U8-zpQ"/> <viewControllerLayoutGuide type="top" id="Qap-U8-zpQ"/>
<viewControllerLayoutGuide type="bottom" id="dca-QO-wba"/> <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"> <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"/> <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <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> <connections>
<segue destination="ibA-aC-X3M" kind="embed" id="fsv-uf-AOE"/> <segue destination="ibA-aC-X3M" kind="embed" id="fsv-uf-AOE"/>
</connections> </connections>
</containerView> </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> </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> </view>
<connections> <connections>
<outlet property="gameViewContainerView" destination="oBZ-xU-jeC" id="jMI-iF-JlU"/> <outlet property="gameViewContainerView" destination="oBZ-xU-jeC" id="jMI-iF-JlU"/>
@ -140,7 +198,7 @@
<!--Game View Controller--> <!--Game View Controller-->
<scene sceneID="ASV-Uk-0aP"> <scene sceneID="ASV-Uk-0aP">
<objects> <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> <layoutGuides>
<viewControllerLayoutGuide type="top" id="ItC-Bu-WRI"/> <viewControllerLayoutGuide type="top" id="ItC-Bu-WRI"/>
<viewControllerLayoutGuide type="bottom" id="g58-HO-6L5"/> <viewControllerLayoutGuide type="bottom" id="g58-HO-6L5"/>
@ -148,12 +206,23 @@
<view key="view" contentMode="scaleToFill" id="skW-1S-YD4"> <view key="view" contentMode="scaleToFill" id="skW-1S-YD4">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/> <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <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> </view>
<connections> <connections>
<segue destination="Yrw-9v-Pcr" kind="presentation" identifier="pause" customClass="PauseStoryboardSegue" customModule="Delta" customModuleProvider="target" id="FLq-Zt-HDv"/> <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="Delta" customModuleProvider="target" id="Tey-6Z-UHp"/> <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="Delta" customModuleProvider="target" id="eR2-0c-0Rv"/> <segue destination="wKV-3d-NIY" kind="presentation" identifier="showInitialGamesViewController" customClass="InitialGamesStoryboardSegue" customModule="Retro_Game_Emulator" customModuleProvider="target" id="eR2-0c-0Rv"/>
</connections> </connections>
</viewController> </viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="gxI-00-NlJ" userLabel="First Responder" sceneMemberID="firstResponder"/> <placeholder placeholderIdentifier="IBFirstResponder" id="gxI-00-NlJ" userLabel="First Responder" sceneMemberID="firstResponder"/>
@ -171,10 +240,12 @@
<!--Settings--> <!--Settings-->
<scene sceneID="L3X-MM-hJL"> <scene sceneID="L3X-MM-hJL">
<objects> <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"/> <placeholder placeholderIdentifier="IBFirstResponder" id="2N5-3k-beA" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects> </objects>
<point key="canvasLocation" x="1036" y="605"/> <point key="canvasLocation" x="1578" y="774"/>
</scene> </scene>
<!--Navigation Controller--> <!--Navigation Controller-->
<scene sceneID="zJI-sC-1sm"> <scene sceneID="zJI-sC-1sm">
@ -185,6 +256,8 @@
<navigationBar key="navigationBar" contentMode="scaleToFill" id="wj9-1e-eev"> <navigationBar key="navigationBar" contentMode="scaleToFill" id="wj9-1e-eev">
<rect key="frame" x="0.0" y="0.0" width="375" height="56"/> <rect key="frame" x="0.0" y="0.0" width="375" height="56"/>
<autoresizingMask key="autoresizingMask"/> <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> </navigationBar>
<nil name="viewControllers"/> <nil name="viewControllers"/>
<toolbar key="toolbar" opaque="NO" clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="M4r-sO-G4H"> <toolbar key="toolbar" opaque="NO" clearsContextBeforeDrawing="NO" contentMode="scaleToFill" id="M4r-sO-G4H">
@ -219,7 +292,7 @@
<nil name="viewControllers"/> <nil name="viewControllers"/>
<connections> <connections>
<segue destination="Eae-Qk-9MI" kind="relationship" relationship="rootViewController" id="1Jh-Zf-ntp"/> <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> </connections>
</navigationController> </navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="htj-tq-2KP" userLabel="First Responder" sceneMemberID="firstResponder"/> <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"/> <rect key="frame" x="0.0" y="0.0" width="375" height="56"/>
<autoresizingMask key="autoresizingMask"/> <autoresizingMask key="autoresizingMask"/>
</navigationBar> </navigationBar>
<connections>
<segue destination="cFV-KV-B18" kind="relationship" relationship="rootViewController" id="VBP-fg-oNH"/>
</connections>
</navigationController> </navigationController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Jo9-gl-p5p" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/> <placeholder placeholderIdentifier="IBFirstResponder" id="Jo9-gl-p5p" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects> </objects>
<point key="canvasLocation" x="2652" y="3085"/> <point key="canvasLocation" x="2652" y="3085"/>
</scene> </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> </scenes>
<inferredMetricsTieBreakers> <inferredMetricsTieBreakers>
<segue reference="Tey-6Z-UHp"/> <segue reference="Tey-6Z-UHp"/>
</inferredMetricsTieBreakers> </inferredMetricsTieBreakers>
<resources> <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> </resources>
</document> </document>

View File

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15400" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="Dt0-nV-isV"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="Dt0-nV-isV">
<device id="retina4_7" orientation="portrait" appearance="light"/> <device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15404"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17504.1"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/> <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
<scenes> <scenes>
@ -167,7 +168,7 @@
</connections> </connections>
</barButtonItem> </barButtonItem>
<barButtonItem style="plain" id="has-I3-HDZ"> <barButtonItem style="plain" id="has-I3-HDZ">
<button key="customView" opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" id="y2a-9f-EFz"> <button key="customView" opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" id="y2a-9f-EFz">
<rect key="frame" x="288.5" y="13" width="30" height="30"/> <rect key="frame" x="288.5" y="13" width="30" height="30"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/> <autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<state key="normal" title="▼"/> <state key="normal" title="▼"/>
@ -206,7 +207,7 @@
<visualEffectView opaque="NO" contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="emc-gw-KkJ" userLabel="Selected Background View"> <visualEffectView opaque="NO" contentMode="scaleToFill" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="emc-gw-KkJ" userLabel="Selected Background View">
<rect key="frame" x="0.0" y="0.0" width="375" height="45"/> <rect key="frame" x="0.0" y="0.0" width="375" height="45"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" ambiguous="YES" id="9bA-Tg-Bko"> <view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" id="9bA-Tg-Bko">
<rect key="frame" x="0.0" y="0.0" width="375" height="45"/> <rect key="frame" x="0.0" y="0.0" width="375" height="45"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/> <color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
@ -266,7 +267,7 @@
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" keyboardDismissMode="interactive" dataMode="static" style="grouped" separatorStyle="default" rowHeight="44" sectionHeaderHeight="18" sectionFooterHeight="18" id="BV9-ff-x83"> <tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" keyboardDismissMode="interactive" dataMode="static" style="grouped" separatorStyle="default" rowHeight="44" sectionHeaderHeight="18" sectionFooterHeight="18" id="BV9-ff-x83">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/> <rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="0.93725490199999995" green="0.93725490199999995" blue="0.95686274510000002" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="backgroundColor" red="0.94901960784313721" green="0.94901960784313721" blue="0.96862745098039216" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<sections> <sections>
<tableViewSection headerTitle="Name" id="QT6-DZ-g70"> <tableViewSection headerTitle="Name" id="QT6-DZ-g70">
<cells> <cells>
@ -337,7 +338,7 @@
<subviews> <subviews>
<textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" text="XXXXXXXX YYYYYYYY" translatesAutoresizingMaskIntoConstraints="NO" id="a17-LB-QXD" customClass="CheatTextView" customModule="Delta" customModuleProvider="target"> <textView clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" text="XXXXXXXX YYYYYYYY" translatesAutoresizingMaskIntoConstraints="NO" id="a17-LB-QXD" customClass="CheatTextView" customModule="Delta" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="375" height="210"/> <rect key="frame" x="0.0" y="0.0" width="375" height="210"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="textColor" systemColor="labelColor"/>
<fontDescription key="fontDescription" name="Menlo-Regular" family="Menlo" pointSize="26"/> <fontDescription key="fontDescription" name="Menlo-Regular" family="Menlo" pointSize="26"/>
<textInputTraits key="textInputTraits" autocapitalizationType="allCharacters" autocorrectionType="no" spellCheckingType="no" returnKeyType="done"/> <textInputTraits key="textInputTraits" autocapitalizationType="allCharacters" autocorrectionType="no" spellCheckingType="no" returnKeyType="done"/>
<connections> <connections>
@ -398,4 +399,9 @@
<point key="canvasLocation" x="2385" y="1377"/> <point key="canvasLocation" x="2385" y="1377"/>
</scene> </scene>
</scenes> </scenes>
<resources>
<systemColor name="labelColor">
<color white="0.0" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document> </document>

File diff suppressed because it is too large Load Diff

View File

@ -39,7 +39,7 @@ class GridCollectionViewCell: UICollectionViewCell
} }
} }
var maximumImageSize: CGSize = CGSize(width: 100, height: 100) { var maximumImageSize: CGSize = CGSize(width: 150, height: 120) {
didSet { didSet {
self.updateMaximumImageSize() self.updateMaximumImageSize()
} }
@ -109,13 +109,15 @@ class GridCollectionViewCell: UICollectionViewCell
// Image View // Image View
self.imageView.translatesAutoresizingMaskIntoConstraints = false self.imageView.translatesAutoresizingMaskIntoConstraints = false
self.imageView.topAnchor.constraint(equalTo: self.contentView.topAnchor).isActive = true self.imageView.topAnchor.constraint(equalTo: self.vibrancyView.topAnchor).isActive = true
self.imageView.centerXAnchor.constraint(equalTo: self.contentView.centerXAnchor).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.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 self.imageViewHeightConstraint.isActive = true
@ -181,10 +183,15 @@ private extension GridCollectionViewCell
{ {
func updateMaximumImageSize() func updateMaximumImageSize()
{ {
self.imageViewWidthConstraint.constant = self.maximumImageSize.width self.imageViewWidthConstraint.constant = 150
self.imageViewHeightConstraint.constant = self.maximumImageSize.height self.imageViewHeightConstraint.constant = 120
self.textLabelVerticalSpacingConstraint.constant = 8 self.textLabelVerticalSpacingConstraint.constant = 8
self.textLabelFocusedVerticalSpacingConstraint?.constant = self.maximumImageSize.height / 10.0 self.textLabelFocusedVerticalSpacingConstraint?.constant = self.maximumImageSize.height / 10.0
// textLabel 30
// let textLabelFixedHeight: CGFloat = 30
//
// self.textLabelVerticalSpacingConstraint.constant = textLabelFixedHeight
// self.textLabelFocusedVerticalSpacingConstraint?.constant = textLabelFixedHeight / 50
} }
} }

View File

@ -50,6 +50,8 @@ class GridCollectionViewLayout: UICollectionViewFlowLayout
return interitemSpacing return interitemSpacing
} }
private var cachedCellLayoutAttributes = [IndexPath: UICollectionViewLayoutAttributes]()
override var estimatedItemSize: CGSize { override var estimatedItemSize: CGSize {
didSet { didSet {
fatalError("GridCollectionViewLayout does not support self-sizing cells.") fatalError("GridCollectionViewLayout does not support self-sizing cells.")
@ -64,6 +66,18 @@ class GridCollectionViewLayout: UICollectionViewFlowLayout
self.sectionInset.right = self.interitemSpacing + self.contentInset.right 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]? override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]?
{ {
let layoutAttributes = super.layoutAttributesForElements(in: rect)?.map({ $0.copy() }) as! [UICollectionViewLayoutAttributes] let layoutAttributes = super.layoutAttributesForElements(in: rect)?.map({ $0.copy() }) as! [UICollectionViewLayoutAttributes]
@ -137,9 +151,24 @@ class GridCollectionViewLayout: UICollectionViewFlowLayout
} }
} }
for attributes in layoutAttributes where attributes.representedElementCategory == .cell
{
// Update cached attributes for layoutAttributesForItem(at:)
self.cachedCellLayoutAttributes[attributes.indexPath] = attributes
}
return layoutAttributes return layoutAttributes
} }
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes?
{
if let cachedAttributes = self.cachedCellLayoutAttributes[indexPath]
{
return cachedAttributes
}
return super.layoutAttributesForItem(at: indexPath)
}
} }
private extension GridCollectionViewLayout private extension GridCollectionViewLayout

View File

@ -21,7 +21,8 @@ class ListMenuViewController: UITableViewController
override var preferredContentSize: CGSize { override var preferredContentSize: CGSize {
get { get {
let navigationBarHeight = self.navigationController?.navigationBar.bounds.height ?? 0.0 // Don't include navigation bar height in calculation (as of iOS 13).
let navigationBarHeight = 0.0 // self.navigationController?.navigationBar.bounds.height ?? 0.0
return CGSize(width: 0, height: (self.tableView.rowHeight * CGFloat(self.items.count)) + navigationBarHeight) return CGSize(width: 0, height: (self.tableView.rowHeight * CGFloat(self.items.count)) + navigationBarHeight)
} }
set {} set {}

View File

@ -17,7 +17,7 @@ extension UINavigationBar
} }
// Make "copy" of self. // 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 navigationBar.barStyle = self.barStyle
// Set item with title so we can retrieve default text attributes. // Set item with title so we can retrieve default text attributes.
@ -38,11 +38,33 @@ extension UINavigationBar
private var _defaultTitleTextAttributes: [NSAttributedString.Key: Any]? { private var _defaultTitleTextAttributes: [NSAttributedString.Key: Any]? {
guard self.titleTextAttributes == nil else { return self.titleTextAttributes } guard self.titleTextAttributes == nil else { return self.titleTextAttributes }
guard guard let contentView = self.subviews.first(where: { NSStringFromClass(type(of: $0)).contains("ContentView") || NSStringFromClass(type(of: $0)).contains("ItemView") })
let contentView = self.subviews.first(where: { NSStringFromClass(type(of: $0)).contains("ContentView") || NSStringFromClass(type(of: $0)).contains("ItemView") }),
let titleLabel = contentView.subviews.first(where: { $0 is UILabel }) as? UILabel
else { return nil } else { return nil }
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
}
guard let titleLabel = containerView.subviews.first(where: { $0 is UILabel }) as? UILabel else { return nil }
let textAttributes = titleLabel.attributedText?.attributes(at: 0, effectiveRange: nil) let textAttributes = titleLabel.attributedText?.attributes(at: 0, effectiveRange: nil)
return textAttributes return textAttributes
} }
@ -63,6 +85,8 @@ class PopoverMenuButton: UIControl
private let arrowLabel: UILabel private let arrowLabel: UILabel
private let stackView: UIStackView private let stackView: UIStackView
private var _didLayoutSubviews = false
private var parentNavigationBar: UINavigationBar? { private var parentNavigationBar: UINavigationBar? {
guard let navigationController = self.parentViewController as? UINavigationController ?? self.parentViewController?.navigationController else { return nil } guard let navigationController = self.parentViewController as? UINavigationController ?? self.parentViewController?.navigationController else { return nil }
guard self.isDescendant(of: navigationController.navigationBar) else { return nil } guard self.isDescendant(of: navigationController.navigationBar) else { return nil }
@ -104,6 +128,21 @@ class PopoverMenuButton: UIControl
{ {
self.updateTextAttributes() self.updateTextAttributes()
} }
override func layoutSubviews()
{
super.layoutSubviews()
if !_didLayoutSubviews
{
_didLayoutSubviews = true
// didMoveToSuperview() can be too early to accurately
// update text attributes, so ensure we also update
// during first layoutSubviews() call.
self.updateTextAttributes()
}
}
} }
private extension PopoverMenuButton private extension PopoverMenuButton

View 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
}
}

View 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)
}
}

View 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
}
}

View 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
}
}

View 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)
}
}

View File

@ -111,42 +111,90 @@ extension DatabaseManager
switch system switch system
{ {
case .ds where core == MelonDS.core: case .ds where core == MelonDS.core:
let predicate = NSPredicate(format: "%K == %@", #keyPath(Game.identifier), Game.melonDSBIOSIdentifier)
if let _ = Game.instancesWithPredicate(predicate, inManagedObjectContext: context, type: Game.self).first // Returns nil if game already exists.
func makeBIOS(name: String, identifier: String) -> Game?
{ {
// Game already exists, so don't do anything. let predicate = NSPredicate(format: "%K == %@", #keyPath(Game.identifier), identifier)
break if let _ = Game.instancesWithPredicate(predicate, inManagedObjectContext: context, type: Game.self).first
{
// BIOS already exists, so don't do anything.
return nil
}
let filename: String
switch identifier
{
case Game.melonDSBIOSIdentifier:
guard
FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.bios7URL.path) &&
FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.bios9URL.path) &&
FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.firmwareURL.path)
else { return nil }
filename = "nds.bios"
case Game.melonDSDSiBIOSIdentifier:
#if BETA
guard
FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.dsiBIOS7URL.path) &&
FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.dsiBIOS9URL.path) &&
FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.dsiFirmwareURL.path) &&
FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.dsiNANDURL.path)
else { return nil }
filename = "dsi.bios"
#else
return nil
#endif
default: filename = "system.bios"
}
let bios = Game(context: context)
bios.name = name
bios.identifier = identifier
bios.type = .ds
bios.filename = filename
if let artwork = UIImage(named: "DS Home Screen"), let artworkData = artwork.pngData()
{
do
{
let destinationURL = DatabaseManager.artworkURL(for: bios)
try artworkData.write(to: destinationURL, options: .atomic)
bios.artworkURL = destinationURL
}
catch
{
print("Failed to copy default DS home screen artwork.", error)
}
}
return bios
} }
let game = Game(context: context) let insertedGames = [
game.identifier = Game.melonDSBIOSIdentifier (name: NSLocalizedString("Home Screen", comment: ""), identifier: Game.melonDSBIOSIdentifier),
game.type = .ds (name: NSLocalizedString("Home Screen (DSi)", comment: ""), identifier: Game.melonDSDSiBIOSIdentifier)
game.filename = "melonDS-BIOS" ].compactMap(makeBIOS)
game.name = NSLocalizedString("Home Screen", comment: "") // Break if we didn't create any new Games.
guard !insertedGames.isEmpty else { break }
if let sourceURL = Bundle.main.url(forResource: "DS", withExtension: "png")
{
do
{
let destinationURL = DatabaseManager.artworkURL(for: game)
try FileManager.default.copyItem(at: sourceURL, to: destinationURL, shouldReplace: true)
game.artworkURL = destinationURL
}
catch
{
print("Failed to copy default DS home screen artwork.", error)
}
}
let gameCollection = GameCollection(context: context) let gameCollection = GameCollection(context: context)
gameCollection.identifier = GameType.ds.rawValue gameCollection.identifier = GameType.ds.rawValue
gameCollection.index = Int16(System.ds.year) gameCollection.index = Int16(System.ds.year)
gameCollection.games.insert(game) gameCollection.games.formUnion(insertedGames)
case .ds: case .ds:
let predicate = NSPredicate(format: "%K == %@", #keyPath(Game.identifier), Game.melonDSBIOSIdentifier) let predicate = NSPredicate(format: "%K IN %@", #keyPath(Game.identifier), [Game.melonDSBIOSIdentifier, Game.melonDSDSiBIOSIdentifier])
if let game = Game.instancesWithPredicate(predicate, inManagedObjectContext: context, type: Game.self).first
let games = Game.instancesWithPredicate(predicate, inManagedObjectContext: context, type: Game.self)
for game in games
{ {
context.delete(game) context.delete(game)
} }
@ -207,10 +255,27 @@ private extension DatabaseManager
do do
{ {
if !FileManager.default.fileExists(atPath: DatabaseManager.gamesDatabaseURL.path) if !FileManager.default.fileExists(atPath: DatabaseManager.gamesDatabaseURL.path) || GamesDatabase.version != GamesDatabase.previousVersion
{ {
guard let bundleURL = Bundle.main.url(forResource: "openvgdb", withExtension: "sqlite") else { throw GamesDatabase.Error.doesNotExist } guard let bundleURL = Bundle.main.url(forResource: "openvgdb", withExtension: "sqlite") else { throw GamesDatabase.Error.doesNotExist }
try FileManager.default.copyItem(at: bundleURL, to: DatabaseManager.gamesDatabaseURL) 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() self.gamesDatabase = try GamesDatabase()
@ -480,7 +545,7 @@ extension DatabaseManager
try FileManager.default.removeItem(at: outputURL) try FileManager.default.removeItem(at: outputURL)
} }
_ = try archive.extract(entry, to: outputURL) _ = try archive.extract(entry, to: outputURL, skipCRC32: true)
outputURLs.insert(outputURL) outputURLs.insert(outputURL)
} }
@ -571,6 +636,12 @@ extension DatabaseManager
return gamesDatabaseURL return gamesDatabaseURL
} }
class var cheatBaseURL: URL
{
let gamesDatabaseURL = self.defaultDirectoryURL().appendingPathComponent("cheatbase.sqlite")
return gamesDatabaseURL
}
class var gamesDirectoryURL: URL class var gamesDirectoryURL: URL
{ {
let gamesDirectoryURL = DatabaseManager.defaultDirectoryURL().appendingPathComponent("Games") let gamesDirectoryURL = DatabaseManager.defaultDirectoryURL().appendingPathComponent("Games")
@ -615,7 +686,7 @@ extension DatabaseManager
{ {
let gameURL = game.fileURL let gameURL = game.fileURL
let artworkURL = gameURL.deletingPathExtension().appendingPathExtension("jpg") let artworkURL = gameURL.deletingPathExtension().appendingPathExtension("png")
return artworkURL return artworkURL
} }
} }

View File

@ -3,6 +3,6 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>_XCCurrentVersionName</key> <key>_XCCurrentVersionName</key>
<string>Delta 6.xcdatamodel</string> <string>Delta 7.xcdatamodel</string>
</dict> </dict>
</plist> </plist>

View File

@ -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>

View File

@ -53,4 +53,9 @@ extension Cheat: Syncable
public var syncableLocalizedName: String? { public var syncableLocalizedName: String? {
return self.name return self.name
} }
public func resolveConflict(_ record: AnyRecord) -> ConflictResolution
{
return .newest
}
} }

View File

@ -13,16 +13,27 @@ import Harmony
extension ControllerSkinConfigurations 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 (.iphone, .standard, .portrait): self = .iphoneStandardPortrait
case (.standard, .landscape): self = .standardLandscape case (.iphone, .standard, .landscape): self = .iphoneStandardLandscape
case (.edgeToEdge, .portrait): self = .edgeToEdgePortrait case (.iphone, .edgeToEdge, .portrait): self = .iphoneEdgeToEdgePortrait
case (.edgeToEdge, .landscape): self = .edgeToEdgeLandscape case (.iphone, .edgeToEdge, .landscape): self = .iphoneEdgeToEdgeLandscape
case (.splitView, .portrait): self = .splitViewPortrait case (.iphone, .splitView, _): return nil
case (.splitView, .landscape): self = .splitViewLandscape
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) 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]? public func items(for traits: DeltaCore.ControllerSkin.Traits) -> [DeltaCore.ControllerSkin.Item]?
{ {
return self.controllerSkin?.items(for: traits) return self.controllerSkin?.items(for: traits)
@ -86,11 +92,6 @@ extension ControllerSkin: ControllerSkinProtocol
return self.controllerSkin?.isTranslucent(for: traits) return self.controllerSkin?.isTranslucent(for: traits)
} }
public func gameScreenFrame(for traits: DeltaCore.ControllerSkin.Traits) -> CGRect?
{
return self.controllerSkin?.gameScreenFrame(for: traits)
}
public func screens(for traits: DeltaCore.ControllerSkin.Traits) -> [DeltaCore.ControllerSkin.Screen]? public func screens(for traits: DeltaCore.ControllerSkin.Traits) -> [DeltaCore.ControllerSkin.Screen]?
{ {
return self.controllerSkin?.screens(for: traits) return self.controllerSkin?.screens(for: traits)
@ -100,6 +101,11 @@ extension ControllerSkin: ControllerSkinProtocol
{ {
return self.controllerSkin?.aspectRatio(for: traits) return self.controllerSkin?.aspectRatio(for: traits)
} }
public func contentSize(for traits: DeltaCore.ControllerSkin.Traits) -> CGSize?
{
return self.controllerSkin?.contentSize(for: traits)
}
} }
extension ControllerSkin: Syncable extension ControllerSkin: Syncable
@ -123,4 +129,9 @@ extension ControllerSkin: Syncable
public var syncableLocalizedName: String? { public var syncableLocalizedName: String? {
return self.name return self.name
} }
public func resolveConflict(_ record: AnyRecord) -> ConflictResolution
{
return .newest
}
} }

View File

@ -9,11 +9,14 @@
import Foundation import Foundation
import DeltaCore import DeltaCore
import MelonDSDeltaCore
import Harmony import Harmony
public extension Game public extension Game
{ {
static let melonDSBIOSIdentifier = "com.rileytestut.MelonDSDeltaCore.BIOS" static let melonDSBIOSIdentifier = "com.rileytestut.MelonDSDeltaCore.BIOS"
static let melonDSDSiBIOSIdentifier = "com.rileytestut.MelonDSDeltaCore.DSiBIOS"
} }
@objc(Game) @objc(Game)
@ -21,7 +24,7 @@ public class Game: _Game, GameProtocol
{ {
public var fileURL: URL { public var fileURL: URL {
var fileURL: URL! var fileURL: URL!
// self URL
self.managedObjectContext?.performAndWait { self.managedObjectContext?.performAndWait {
fileURL = DatabaseManager.gamesDirectoryURL.appendingPathComponent(self.filename) fileURL = DatabaseManager.gamesDirectoryURL.appendingPathComponent(self.filename)
} }
@ -42,12 +45,16 @@ public class Game: _Game, GameProtocol
// Recreate the stored URL relative to current sandbox location. // Recreate the stored URL relative to current sandbox location.
artworkURL = URL(fileURLWithPath: unwrappedArtworkURL.relativePath, relativeTo: DatabaseManager.gamesDirectoryURL) artworkURL = URL(fileURLWithPath: unwrappedArtworkURL.relativePath, relativeTo: DatabaseManager.gamesDirectoryURL)
} }
else if unwrappedArtworkURL.host?.lowercased() == "img.gamefaqs.net", var components = URLComponents(url: unwrappedArtworkURL, resolvingAgainstBaseURL: false) else if let host = unwrappedArtworkURL.host?.lowercased(), host == "img.gamefaqs.net" || host == "gamefaqs1.cbsistatic.com",
var components = URLComponents(url: unwrappedArtworkURL, resolvingAgainstBaseURL: false)
{ {
// Quick fix for broken album artwork URLs due to host change. // Quick fix for broken album artwork URLs due to host change.
components.host = "gamefaqs1.cbsistatic.com" components.host = "gamefaqs.gamespot.com"
components.scheme = "https" components.scheme = "https"
let updatedPath = "/a" + components.path
components.path = updatedPath
if let url = components.url if let url = components.url
{ {
artworkURL = url artworkURL = url
@ -95,11 +102,17 @@ extension Game
guard let managedObjectContext = self.managedObjectContext else { return } 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 // 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 // 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 !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 do
{ {
@ -141,12 +154,42 @@ extension Game: Syncable
} }
public var syncableFiles: Set<File> { public var syncableFiles: Set<File> {
let gameFile = File(identifier: "game", fileURL: self.fileURL) let artworkURL: URL
if let fileURL = self.artworkURL, fileURL.isFileURL
{
artworkURL = fileURL
}
else
{
artworkURL = DatabaseManager.artworkURL(for: self)
}
let artworkURL = DatabaseManager.artworkURL(for: self)
let artworkFile = File(identifier: "artwork", fileURL: artworkURL) let artworkFile = File(identifier: "artwork", fileURL: artworkURL)
return [gameFile, artworkFile] switch self.identifier
{
case Game.melonDSBIOSIdentifier:
let bios7File = File(identifier: "bios7", fileURL: MelonDSEmulatorBridge.shared.bios7URL)
let bios9File = File(identifier: "bios9", fileURL: MelonDSEmulatorBridge.shared.bios9URL)
let firmwareFile = File(identifier: "firmware", fileURL: MelonDSEmulatorBridge.shared.firmwareURL)
return [artworkFile, bios7File, bios9File, firmwareFile]
case Game.melonDSDSiBIOSIdentifier:
let bios7File = File(identifier: "bios7", fileURL: MelonDSEmulatorBridge.shared.dsiBIOS7URL)
let bios9File = File(identifier: "bios9", fileURL: MelonDSEmulatorBridge.shared.dsiBIOS9URL)
let firmwareFile = File(identifier: "firmware", fileURL: MelonDSEmulatorBridge.shared.dsiFirmwareURL)
// DSi NAND is ~240MB, so don't sync for now until Harmony can selectively download files.
// let nandFile = File(identifier: "nand", fileURL: MelonDSEmulatorBridge.shared.dsiNANDURL)
return [artworkFile, bios7File, bios9File, firmwareFile]
default:
let gameFile = File(identifier: "game", fileURL: self.fileURL)
return [artworkFile, gameFile]
}
} }
public var syncableRelationships: Set<AnyKeyPath> { public var syncableRelationships: Set<AnyKeyPath> {
@ -157,7 +200,13 @@ extension Game: Syncable
return self.name return self.name
} }
public var isSyncingEnabled: Bool { public func awakeFromSync(_ record: AnyRecord) throws
return self.identifier != Game.melonDSBIOSIdentifier {
guard let gameCollection = self.gameCollection else { throw SyncValidationError.incorrectGameCollection(nil) }
if gameCollection.identifier != self.type.rawValue
{
throw SyncValidationError.incorrectGameCollection(gameCollection.name)
}
} }
} }

View File

@ -8,6 +8,8 @@
import Foundation import Foundation
import GBCDeltaCore
import Harmony import Harmony
@objc(GameSave) @objc(GameSave)
@ -28,7 +30,7 @@ extension GameSave: Syncable
} }
public var syncableKeys: Set<AnyKeyPath> { public var syncableKeys: Set<AnyKeyPath> {
return [\GameSave.modifiedDate] return [\GameSave.modifiedDate, \GameSave.sha1]
} }
public var syncableRelationships: Set<AnyKeyPath> { public var syncableRelationships: Set<AnyKeyPath> {
@ -51,7 +53,9 @@ extension GameSave: Syncable
public var syncableMetadata: [HarmonyMetadataKey : String] { public var syncableMetadata: [HarmonyMetadataKey : String] {
guard let game = self.game else { return [:] } 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? { public var syncableLocalizedName: String? {
@ -62,6 +66,51 @@ extension GameSave: Syncable
// self.game may be nil if being downloaded, so don't enforce it. // self.game may be nil if being downloaded, so don't enforce it.
// guard let identifier = self.game?.identifier else { return false } // guard let identifier = self.game?.identifier else { return false }
return self.game?.identifier != Game.melonDSBIOSIdentifier 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)])
}
}
} }
} }

View File

@ -128,36 +128,66 @@ extension SaveState: Syncable
// self.game may be nil if being downloaded, so don't enforce it. // self.game may be nil if being downloaded, so don't enforce it.
// guard let identifier = self.game?.identifier else { return false } // guard let identifier = self.game?.identifier else { return false }
let isSyncingEnabled = (self.type != .auto && self.type != .quick) && (self.game?.identifier != Game.melonDSBIOSIdentifier) let isSyncingEnabled = (self.type != .auto && self.type != .quick) && (self.game?.identifier != Game.melonDSBIOSIdentifier && self.game?.identifier != Game.melonDSDSiBIOSIdentifier)
return isSyncingEnabled return isSyncingEnabled
} }
public var syncableMetadata: [HarmonyMetadataKey : String] { public var syncableMetadata: [HarmonyMetadataKey : String] {
guard let game = self.game else { return [:] } 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? { public var syncableLocalizedName: String? {
return self.localizedName return self.localizedName
} }
public func awakeFromSync(_ record: AnyRecord) public func awakeFromSync(_ record: AnyRecord) throws
{ {
guard self.coreIdentifier == nil else { return } let verifiedGameID = record.remoteMetadata?[.verifiedGameID]
guard let game = self.game, let system = System(gameType: game.type) else { return }
if let coreIdentifier = record.remoteMetadata?[.coreID] do
{ {
// SaveState was synced to older version of Delta and lost its coreIdentifier, guard let game = self.game else { return }
// but it remains in the remote metadata so we can reassign it.
self.coreIdentifier = coreIdentifier if let system = System(gameType: game.type), self.coreIdentifier == nil
}
else
{
switch system
{ {
case .ds: self.coreIdentifier = DS.core.identifier // Assume DS save state with nil coreIdentifier is from DeSmuME core. if let coreIdentifier = record.remoteMetadata?[.coreID]
default: self.coreIdentifier = system.deltaCore.identifier {
// SaveState was synced to older version of Delta and lost its coreIdentifier,
// but it remains in the remote metadata so we can reassign it.
self.coreIdentifier = coreIdentifier
}
else
{
switch system
{
case .ds: self.coreIdentifier = DS.core.identifier // Assume DS save state with nil coreIdentifier is from DeSmuME core.
default: self.coreIdentifier = system.deltaCore.identifier
}
}
}
if let verifiedGameID, verifiedGameID != game.identifier
{
// Game does not match verified game ID, which most likely means
// this SaveState was reviewed + fixed on another device, but not uploaded.
throw SyncValidationError.incorrectGame(game.name)
}
}
catch let error as SyncValidationError
{
guard let verifiedGameID, SyncManager.shared.ignoredCorruptedRecordIDs.contains(record.recordID) else { throw error }
let fetchRequest = Game.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "%K == %@", #keyPath(Game.identifier), verifiedGameID)
if let correctGame = try self.managedObjectContext?.fetch(fetchRequest).first
{
self.game = correctGame
}
else
{
throw ValidationError.nilRelationshipObjects(keys: [#keyPath(GameSave.game)])
} }
} }
} }

View File

@ -18,6 +18,8 @@ public class _GameSave: NSManagedObject
@NSManaged public var modifiedDate: Date @NSManaged public var modifiedDate: Date
@NSManaged public var sha1: String?
// MARK: - Relationships // MARK: - Relationships
@NSManaged public var game: Game? @NSManaged public var game: Game?

File diff suppressed because one or more lines are too long

View File

@ -9,16 +9,35 @@
#ifndef ControllerSkinConfigurations_h #ifndef ControllerSkinConfigurations_h
#define ControllerSkinConfigurations_h #define ControllerSkinConfigurations_h
// Every possible (supported) combination of traits.
typedef NS_OPTIONS(int16_t, ControllerSkinConfigurations) typedef NS_OPTIONS(int16_t, ControllerSkinConfigurations)
{ {
ControllerSkinConfigurationStandardPortrait = 1 << 0, /* iPhone */
ControllerSkinConfigurationStandardLandscape = 1 << 1, ControllerSkinConfigurationiPhoneStandardPortrait NS_SWIFT_NAME(iphoneStandardPortrait) = 1 << 0,
ControllerSkinConfigurationiPhoneStandardLandscape NS_SWIFT_NAME(iphoneStandardLandscape) = 1 << 1,
ControllerSkinConfigurationSplitViewPortrait = 1 << 2, // iPhone doesn't support Split View
ControllerSkinConfigurationSplitViewLandscape = 1 << 3, // ControllerSkinConfigurationiPhoneSplitViewPortrait = 1 << 2,
// ControllerSkinConfigurationiPhoneSplitViewLandscape = 1 << 3,
ControllerSkinConfigurationEdgeToEdgePortrait = 1 << 4, ControllerSkinConfigurationiPhoneEdgeToEdgePortrait NS_SWIFT_NAME(iphoneEdgeToEdgePortrait) = 1 << 4,
ControllerSkinConfigurationEdgeToEdgeLandscape = 1 << 5, 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 */ #endif /* ControllerSkinConfigurations_h */

View File

@ -11,15 +11,17 @@ import Foundation
// Must be an NSObject subclass so it can be used with RSTCellContentDataSource. // Must be an NSObject subclass so it can be used with RSTCellContentDataSource.
class GameMetadata: NSObject class GameMetadata: NSObject
{ {
let identifier: Int let releaseID: Int
let romID: Int
let name: String? let name: String?
let artworkURL: URL? 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.name = name
self.identifier = identifier
self.artworkURL = artworkURL self.artworkURL = artworkURL
} }
} }
@ -27,13 +29,13 @@ class GameMetadata: NSObject
extension GameMetadata extension GameMetadata
{ {
override var hash: Int { override var hash: Int {
return self.identifier.hashValue return self.releaseID.hashValue ^ self.romID.hashValue
} }
override func isEqual(_ object: Any?) -> Bool override func isEqual(_ object: Any?) -> Bool
{ {
guard let metadata = object as? GameMetadata else { return false } guard let metadata = object as? GameMetadata else { return false }
return self.identifier == metadata.identifier return self.releaseID == metadata.releaseID && self.romID == metadata.romID
} }
} }

View File

@ -57,16 +57,26 @@ extension VirtualTable
extension GamesDatabase extension GamesDatabase
{ {
enum Error: Swift.Error enum Error: LocalizedError
{ {
case doesNotExist 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 class GamesDatabase
{ {
static let version = -1 static let version = 3
static var previousVersion: Int? {
return UserDefaults.standard.previousGamesDatabaseVersion
}
private let connection: Connection private let connection: Connection
@ -80,7 +90,7 @@ class GamesDatabase
} }
catch catch
{ {
throw Error.connection(error) throw error
} }
self.invalidateVirtualTableIfNeeded() self.invalidateVirtualTableIfNeeded()
@ -89,10 +99,11 @@ class GamesDatabase
func metadataResults(forGameName gameName: String) -> [GameMetadata] func metadataResults(forGameName gameName: String) -> [GameMetadata]
{ {
let releaseID = Expression<Any>.releaseID let releaseID = Expression<Any>.releaseID
let romID = Expression<Any>.romID
let name = Expression<Any>.name let name = Expression<Any>.name
let artworkAddress = Expression<Any>.artworkAddress 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 do
{ {
@ -111,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 return metadata
} }
@ -145,7 +156,7 @@ class GamesDatabase
let romID = Expression<Any>.romID let romID = Expression<Any>.romID
let gameHash = game.identifier.uppercased() 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 do
{ {
@ -161,7 +172,7 @@ class GamesDatabase
artworkURL = nil 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 return metadata
} }
} }
@ -197,12 +208,13 @@ private extension GamesDatabase
let name = Expression<Any>.name let name = Expression<Any>.name
let artworkAddress = Expression<Any>.artworkAddress let artworkAddress = Expression<Any>.artworkAddress
let releaseID = Expression<Any>.releaseID let releaseID = Expression<Any>.releaseID
let romID = Expression<Any>.romID
do 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) _ = try self.connection.run(update)
} }
catch catch

View 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)
}
}

View 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)
}
}
}
}

View 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: "")
}
}

View File

@ -15,6 +15,8 @@ extension UIActivity.ActivityType
class CopyDeepLinkActivity: UIActivity class CopyDeepLinkActivity: UIActivity
{ {
private var deepLink: URL?
override class var activityCategory: UIActivity.Category { override class var activityCategory: UIActivity.Category {
return .action return .action
} }
@ -28,7 +30,7 @@ class CopyDeepLinkActivity: UIActivity
} }
override var activityImage: UIImage? { override var activityImage: UIImage? {
return UIImage(named: "Link") return UIImage(symbolNameIfAvailable: "link") ?? UIImage(named: "Link")
} }
override func canPerform(withActivityItems activityItems: [Any]) -> Bool 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 } 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))
UIPasteboard.general.url = deepLink }
override func perform()
{
if let deepLink = self.deepLink
{
UIPasteboard.general.url = deepLink
self.activityDidFinish(true)
}
else
{
self.activityDidFinish(false)
}
} }
} }

View File

@ -23,8 +23,16 @@ extension UIViewController
struct DeepLinkController struct DeepLinkController
{ {
private var window: UIWindow? { private var window: UIWindow? {
guard let delegate = UIApplication.shared.delegate, let window = delegate.window else { return nil } if #available(iOS 13, *)
return window {
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? { private var topViewController: UIViewController? {

View File

@ -7,16 +7,29 @@
// //
import UIKit import UIKit
import Photos
import DeltaCore import DeltaCore
import GBADeltaCore import GBADeltaCore
import MelonDSDeltaCore
import Systems
import struct DSDeltaCore.DS import struct DSDeltaCore.DS
import Roxas import Roxas
import AltKit
private var kvoContext = 0 private var kvoContext = 0
private extension DeltaCore.ControllerSkin
{
func hasTouchScreen(for traits: DeltaCore.ControllerSkin.Traits) -> Bool
{
let hasTouchScreen = self.items(for: traits)?.contains(where: { $0.kind == .touchScreen }) ?? false
return hasTouchScreen
}
}
private extension GameViewController private extension GameViewController
{ {
struct PausedSaveState: SaveStateProtocol struct PausedSaveState: SaveStateProtocol
@ -49,6 +62,7 @@ private extension GameViewController
} }
// Only intercept controller skin inputs. // Only intercept controller skin inputs.
//
guard controllerInput.type == .controller(.controllerSkin) else { return nil } guard controllerInput.type == .controller(.controllerSkin) else { return nil }
let actionInput = ActionInput(stringValue: controllerInput.stringValue) let actionInput = ActionInput(stringValue: controllerInput.stringValue)
@ -79,6 +93,7 @@ private extension GameViewController
class GameViewController: DeltaCore.GameViewController class GameViewController: DeltaCore.GameViewController
{ {
/// Assumed to be Delta.Game instance /// Assumed to be Delta.Game instance
/// /// Delta.Game
override var game: GameProtocol? { override var game: GameProtocol? {
willSet { willSet {
self.emulatorCore?.removeObserver(self, forKeyPath: #keyPath(EmulatorCore.state), context: &kvoContext) self.emulatorCore?.removeObserver(self, forKeyPath: #keyPath(EmulatorCore.state), context: &kvoContext)
@ -99,8 +114,8 @@ class GameViewController: DeltaCore.GameViewController
self.shouldResetSustainedInputs = true self.shouldResetSustainedInputs = true
} }
self.updateControllerSkin()
self.updateControllers() self.updateControllers()
self.updateAudio()
self.presentedGyroAlert = false self.presentedGyroAlert = false
} }
@ -158,6 +173,8 @@ class GameViewController: DeltaCore.GameViewController
private var isGyroActive = false private var isGyroActive = false
private var presentedGyroAlert = false private var presentedGyroAlert = false
private var presentedJITAlert = false
override var shouldAutorotate: Bool { override var shouldAutorotate: Bool {
return !self.isGyroActive return !self.isGyroActive
} }
@ -166,6 +183,14 @@ class GameViewController: DeltaCore.GameViewController
return .all return .all
} }
override var prefersStatusBarHidden: Bool {
return !ExperimentalFeatures.shared.showStatusBar.isEnabled
}
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
required init() required init()
{ {
super.init() super.init()
@ -189,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.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.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.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.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.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 deinit
@ -307,7 +337,6 @@ extension GameViewController
self.sustainButtonsContentView.topAnchor.constraint(equalTo: self.gameView.topAnchor).isActive = true self.sustainButtonsContentView.topAnchor.constraint(equalTo: self.gameView.topAnchor).isActive = true
self.sustainButtonsContentView.bottomAnchor.constraint(equalTo: self.gameView.bottomAnchor).isActive = true self.sustainButtonsContentView.bottomAnchor.constraint(equalTo: self.gameView.bottomAnchor).isActive = true
self.updateControllerSkin()
self.updateControllers() self.updateControllers()
} }
@ -322,12 +351,18 @@ extension GameViewController
UserDefaults.standard.desmumeDeprecatedAlertCount += 1 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) override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator)
{ {
super.viewWillTransition(to: size, with: coordinator) super.viewWillTransition(to: size, with: coordinator)
guard UIApplication.shared.applicationState != .background else { return }
coordinator.animate(alongsideTransition: { (context) in coordinator.animate(alongsideTransition: { (context) in
self.updateControllerSkin() self.updateControllerSkin()
}, completion: nil) }, completion: nil)
@ -383,6 +418,9 @@ extension GameViewController
pauseViewController.fastForwardItem?.action = { [unowned self] item in pauseViewController.fastForwardItem?.action = { [unowned self] item in
self.performFastForwardAction(activate: item.isSelected) self.performFastForwardAction(activate: item.isSelected)
} }
pauseViewController.screenshotItem?.action = { [unowned self] item in
self.performScreenshotAction()
}
pauseViewController.sustainButtonsItem?.isSelected = gameController.sustainedInputs.count > 0 pauseViewController.sustainButtonsItem?.isSelected = gameController.sustainedInputs.count > 0
pauseViewController.sustainButtonsItem?.action = { [unowned self, unowned pauseViewController] item in pauseViewController.sustainButtonsItem?.action = { [unowned self, unowned pauseViewController] item in
@ -402,19 +440,20 @@ extension GameViewController
self.pausingGameController = gameController self.pausingGameController = gameController
} }
if self.emulatorCore?.deltaCore.supportedRates.upperBound == 1
{
pauseViewController.fastForwardItem = nil
}
switch self.game?.type switch self.game?.type
{ {
case .n64? where !UIDevice.current.hasA9ProcessorOrBetter:
// A8 processors and earlier aren't powerful enough to run N64 games faster than 1x speed.
pauseViewController.fastForwardItem = nil
case .ds? where self.emulatorCore?.deltaCore == DS.core: case .ds? where self.emulatorCore?.deltaCore == DS.core:
// Cheats are not supported by DeSmuME core. // Cheats are not supported by DeSmuME core.
pauseViewController.cheatCodesItem = nil pauseViewController.cheatCodesItem = nil
case .ds? where !UIDevice.current.hasA9ProcessorOrBetter: case .genesis?:
// A8 processors and earlier aren't powerful enough to run DS games faster than 1x speed. // GPGX core does not support cheats yet.
pauseViewController.fastForwardItem = nil pauseViewController.cheatCodesItem = nil
default: break default: break
} }
@ -465,6 +504,13 @@ extension GameViewController
} }
self._isLoadingSaveState = false self._isLoadingSaveState = false
if self.emulatorCore?.deltaCore == MelonDS.core, ProcessInfo.processInfo.isJITAvailable
{
self.transitionCoordinator?.animate(alongsideTransition: nil, completion: { (context) in
self.showJITEnabledAlert()
})
}
} }
case "unwindToGames": case "unwindToGames":
@ -544,15 +590,26 @@ private extension GameViewController
} }
// If Settings.localControllerPlayerIndex is non-nil, and there isn't a connected controller with same playerIndex, show controller view. // If Settings.localControllerPlayerIndex is non-nil, and there isn't a connected controller with same playerIndex, show controller view.
if let index = Settings.localControllerPlayerIndex, !ExternalGameControllerManager.shared.connectedControllers.contains { $0.playerIndex == index } if let index = Settings.localControllerPlayerIndex, !ExternalGameControllerManager.shared.connectedControllers.contains(where: { $0.playerIndex == index })
{ {
self.controllerView.playerIndex = index self.controllerView.playerIndex = index
self.controllerView.isHidden = false self.controllerView.isHidden = false
} }
else else
{ {
self.controllerView.playerIndex = nil if let game = self.game,
self.controllerView.isHidden = true let traits = self.controllerView.controllerSkinTraits,
let controllerSkin = DeltaCore.ControllerSkin.standardControllerSkin(for: game.type),
controllerSkin.hasTouchScreen(for: traits)
{
self.controllerView.isHidden = false
self.controllerView.playerIndex = 0
}
else
{
self.controllerView.isHidden = true
self.controllerView.playerIndex = nil
}
Settings.localControllerPlayerIndex = nil Settings.localControllerPlayerIndex = nil
} }
@ -608,6 +665,8 @@ private extension GameViewController
self.controllerView.isButtonHapticFeedbackEnabled = Settings.isButtonHapticFeedbackEnabled self.controllerView.isButtonHapticFeedbackEnabled = Settings.isButtonHapticFeedbackEnabled
self.controllerView.isThumbstickHapticFeedbackEnabled = Settings.isThumbstickHapticFeedbackEnabled self.controllerView.isThumbstickHapticFeedbackEnabled = Settings.isThumbstickHapticFeedbackEnabled
self.updateControllerSkin()
} }
func updateControllerSkin() func updateControllerSkin()
@ -616,11 +675,72 @@ private extension GameViewController
let traits = DeltaCore.ControllerSkin.Traits.defaults(for: window) let traits = DeltaCore.ControllerSkin.Traits.defaults(for: window)
let controllerSkin = Settings.preferredControllerSkin(for: game, traits: traits) if Settings.localControllerPlayerIndex != nil
self.controllerView.controllerSkin = controllerSkin {
let controllerSkin = Settings.preferredControllerSkin(for: game, traits: traits)
self.controllerView.controllerSkin = controllerSkin
}
else if let controllerSkin = DeltaCore.ControllerSkin.standardControllerSkin(for: game.type), controllerSkin.hasTouchScreen(for: traits)
{
var touchControllerSkin = TouchControllerSkin(controllerSkin: controllerSkin)
if UIApplication.shared.isExternalDisplayConnected
{
// Only show touch screen if external display is connected.
touchControllerSkin.screenPredicate = { $0.isTouchScreen }
}
if self.view.bounds.width > self.view.bounds.height
{
touchControllerSkin.screenLayoutAxis = .horizontal
}
else
{
touchControllerSkin.screenLayoutAxis = .vertical
}
self.controllerView.controllerSkin = touchControllerSkin
}
self.updateExternalDisplay()
self.view.setNeedsLayout() 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 - //MARK: - Game Saves -
@ -637,23 +757,30 @@ private extension GameViewController
let game = context.object(with: game.objectID) as! Game let game = context.object(with: game.objectID) as! Game
let hash = try RSTHasher.sha1HashOfFile(at: game.gameSaveURL) 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 } guard hash != previousHash else { return }
if let gameSave = game.gameSave if let gameSave = game.gameSave
{ {
gameSave.modifiedDate = Date() gameSave.modifiedDate = Date()
gameSave.sha1 = hash
} }
else else
{ {
let gameSave = GameSave(context: context) let gameSave = GameSave(context: context)
gameSave.identifier = game.identifier gameSave.identifier = game.identifier
gameSave.sha1 = hash
game.gameSave = gameSave game.gameSave = gameSave
} }
try context.save() 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 catch CocoaError.fileNoSuchFile
{ {
@ -772,6 +899,12 @@ extension GameViewController: SaveStatesViewControllerDelegate
saveState.modifiedDate = Date() saveState.modifiedDate = Date()
saveState.coreIdentifier = self.emulatorCore?.deltaCore.identifier saveState.coreIdentifier = self.emulatorCore?.deltaCore.identifier
if ExperimentalFeatures.shared.toastNotifications.stateSaveEnabled,
saveState.type != .auto
{
self.presentExperimentalToastView(NSLocalizedString("Saved Save State", comment: ""))
}
if isRunning if isRunning
{ {
self.resumeEmulation() self.resumeEmulation()
@ -819,6 +952,11 @@ extension GameViewController: SaveStatesViewControllerDelegate
{ {
try self.emulatorCore?.load(saveState) try self.emulatorCore?.load(saveState)
} }
if ExperimentalFeatures.shared.toastNotifications.stateLoadEnabled
{
self.presentExperimentalToastView(NSLocalizedString("Loaded Save State", comment: ""))
}
} }
catch EmulatorCore.SaveStateError.doesNotExist catch EmulatorCore.SaveStateError.doesNotExist
{ {
@ -876,6 +1014,16 @@ extension GameViewController: CheatsViewControllerDelegate
} }
} }
//MARK: - Audio -
/// Audio
private extension GameViewController
{
func updateAudio()
{
self.emulatorCore?.audioManager.respectsSilentMode = Settings.respectSilentMode
}
}
//MARK: - Sustain Buttons - //MARK: - Sustain Buttons -
private extension GameViewController private extension GameViewController
{ {
@ -896,6 +1044,8 @@ private extension GameViewController
UIView.animate(withDuration: 0.4) { UIView.animate(withDuration: 0.4) {
self.sustainButtonsBlurView.effect = blurEffect self.sustainButtonsBlurView.effect = blurEffect
self.sustainButtonsBackgroundView.alpha = 1.0 self.sustainButtonsBackgroundView.alpha = 1.0
} completion: { _ in
self.controllerView.becomeFirstResponder()
} }
} }
@ -991,13 +1141,190 @@ extension GameViewController
if activate if activate
{ {
emulatorCore.rate = emulatorCore.deltaCore.supportedRates.upperBound if ExperimentalFeatures.shared.variableFastForward.isEnabled,
let preferredSpeed = ExperimentalFeatures.shared.variableFastForward[emulatorCore.game.type],
(preferredSpeed.rawValue <= emulatorCore.deltaCore.supportedRates.upperBound || ExperimentalFeatures.shared.variableFastForward.allowUnrestrictedSpeeds)
{
emulatorCore.rate = preferredSpeed.rawValue
}
else
{
emulatorCore.rate = emulatorCore.deltaCore.supportedRates.upperBound
}
if ExperimentalFeatures.shared.toastNotifications.fastForwardEnabled
{
self.presentExperimentalToastView(NSLocalizedString("Fast Forward Enabled", comment: ""))
}
} }
else else
{ {
emulatorCore.rate = emulatorCore.deltaCore.supportedRates.lowerBound 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 - //MARK: - GameViewControllerDelegate -
@ -1006,6 +1333,8 @@ extension GameViewController: GameViewControllerDelegate
{ {
func gameViewController(_ gameViewController: DeltaCore.GameViewController, handleMenuInputFrom gameController: GameController) func gameViewController(_ gameViewController: DeltaCore.GameViewController, handleMenuInputFrom gameController: GameController)
{ {
guard gameViewController == self else { return }
if let pausingGameController = self.pausingGameController if let pausingGameController = self.pausingGameController
{ {
guard pausingGameController == gameController else { return } guard pausingGameController == gameController else { return }
@ -1023,12 +1352,16 @@ extension GameViewController: GameViewControllerDelegate
else if self.presentedViewController == nil else if self.presentedViewController == nil
{ {
self.pauseEmulation() self.pauseEmulation()
self.controllerView.resignFirstResponder()
self.performSegue(withIdentifier: "pause", sender: gameController) self.performSegue(withIdentifier: "pause", sender: gameController)
} }
} }
func gameViewControllerShouldResumeEmulation(_ gameViewController: DeltaCore.GameViewController) -> Bool func gameViewControllerShouldResumeEmulation(_ gameViewController: DeltaCore.GameViewController) -> Bool
{ {
guard gameViewController == self else { return false }
var result = false var result = false
rst_dispatch_sync_on_main_thread { rst_dispatch_sync_on_main_thread {
@ -1037,6 +1370,20 @@ extension GameViewController: GameViewControllerDelegate
return result 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 private extension GameViewController
@ -1047,6 +1394,48 @@ private extension GameViewController
toastView.presentationEdge = .top toastView.presentationEdge = .top
toastView.show(in: self.view, duration: duration) 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 - //MARK: - Notifications -
@ -1089,9 +1478,24 @@ private extension GameViewController
self.updateControllerSkin() 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
} }
} }
@ -1169,6 +1573,60 @@ private extension GameViewController
self.isGyroActive = false 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) @objc func emulationDidQuit(with notification: Notification)
{ {
DispatchQueue.main.async { DispatchQueue.main.async {
@ -1188,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 private extension UserDefaults
{ {
@NSManaged var desmumeDeprecatedAlertCount: Int @NSManaged var desmumeDeprecatedAlertCount: Int
@NSManaged var jitEnabledAlertCount: Int
} }

View File

@ -15,18 +15,23 @@ private var kvoContext = 0
class PreviewGameViewController: DeltaCore.GameViewController class PreviewGameViewController: DeltaCore.GameViewController
{ {
// If non-nil, will override the default preview action items returned in previewActionItems() // If non-nil, will override the default preview action items returned in previewActionItems()
// nilpreviewActionItems()
var overridePreviewActionItems: [UIPreviewActionItem]? var overridePreviewActionItems: [UIPreviewActionItem]?
// Save state to be loaded upon preview // Save state to be loaded upon preview
//
var previewSaveState: SaveStateProtocol? var previewSaveState: SaveStateProtocol?
// Initial image to be shown while loading // Initial image to be shown while loading
//
var previewImage: UIImage? { var previewImage: UIImage? {
didSet { didSet {
self.updatePreviewImage() self.updatePreviewImage()
} }
} }
var isLivePreview: Bool = true
private var emulatorCoreQueue = DispatchQueue(label: "com.rileytestut.Delta.PreviewGameViewController.emulatorCoreQueue", qos: .userInitiated) private var emulatorCoreQueue = DispatchQueue(label: "com.rileytestut.Delta.PreviewGameViewController.emulatorCoreQueue", qos: .userInitiated)
private var copiedSaveFiles = [(originalURL: URL, copyURL: URL)]() private var copiedSaveFiles = [(originalURL: URL, copyURL: URL)]()
@ -58,10 +63,26 @@ class PreviewGameViewController: DeltaCore.GameViewController
return previewActionItems return previewActionItems
} }
public required init()
{
super.init()
self.delegate = self
}
public required init?(coder aDecoder: NSCoder)
{
super.init(coder: aDecoder)
self.delegate = self
}
deinit deinit
{ {
// Explicitly stop emulatorCore _before_ we remove ourselves as observer // Explicitly stop emulatorCore _before_ we remove ourselves as observer
// so we can wait until stopped before restoring save files (again). // so we can wait until stopped before restoring save files (again).
// emulatorCore
//
self.emulatorCore?.stop() self.emulatorCore?.stop()
self.emulatorCore?.removeObserver(self, forKeyPath: #keyPath(EmulatorCore.state), context: &kvoContext) self.emulatorCore?.removeObserver(self, forKeyPath: #keyPath(EmulatorCore.state), context: &kvoContext)
@ -77,8 +98,10 @@ extension PreviewGameViewController
super.viewDidLoad() super.viewDidLoad()
self.controllerView.isHidden = true self.controllerView.isHidden = true
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 // Temporarily prevent emulatorCore from updating gameView to prevent flicker of black, or other visual glitches
// emulatorCore gameView
self.emulatorCore?.remove(self.gameView) self.emulatorCore?.remove(self.gameView)
} }
@ -94,7 +117,7 @@ extension PreviewGameViewController
super.viewDidAppear(animated) super.viewDidAppear(animated)
self.emulatorCoreQueue.async { self.emulatorCoreQueue.async {
self.emulatorCore?.start() self.startEmulation()
} }
} }
@ -103,6 +126,7 @@ extension PreviewGameViewController
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
// Pause in viewWillDisappear and not viewDidDisappear like DeltaCore.GameViewController so the audio cuts off earlier if being dismissed // 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() self.emulatorCore?.pause()
} }
@ -111,10 +135,13 @@ extension PreviewGameViewController
super.viewDidDisappear(animated) super.viewDidDisappear(animated)
// Already stopped = we've already restored save files and removed directory. // Already stopped = we've already restored save files and removed directory.
// =
if self.emulatorCore?.state != .stopped if self.emulatorCore?.state != .stopped
{ {
// Pre-emptively restore save files in case something goes wrong while stopping emulation. // 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. // This also ensures if the core is never stopped (for some reason), saves are still restored.
// 仿
//
self.restoreSaveFiles(removeCopyDirectory: false) self.restoreSaveFiles(removeCopyDirectory: false)
} }
} }
@ -124,6 +151,8 @@ extension PreviewGameViewController
super.viewDidLayoutSubviews() super.viewDidLayoutSubviews()
// Need to update in viewDidLayoutSubviews() to ensure bounds of gameView are not CGRect.zero // Need to update in viewDidLayoutSubviews() to ensure bounds of gameView are not CGRect.zero
// viewDidLayoutSubviews() gameView CGRect.zero
self.updatePreviewImage() self.updatePreviewImage()
} }
@ -131,6 +160,7 @@ extension PreviewGameViewController
{ {
super.didReceiveMemoryWarning() super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated. // Dispose of any resources that can be recreated.
//
} }
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?)
@ -148,6 +178,7 @@ extension PreviewGameViewController
case .running where previousState == .stopped: case .running where previousState == .stopped:
self.emulatorCoreQueue.async { self.emulatorCoreQueue.async {
// Pause to prevent it from starting before visible (in case user peeked slowly) // Pause to prevent it from starting before visible (in case user peeked slowly)
//
self.emulatorCore?.pause() self.emulatorCore?.pause()
self.preparePreview() self.preparePreview()
} }
@ -155,6 +186,8 @@ extension PreviewGameViewController
case .stopped: case .stopped:
// Emulation has stopped, so we can safely restore save files, // Emulation has stopped, so we can safely restore save files,
// and also remove the directory they were copied to. // and also remove the directory they were copied to.
//
//
self.restoreSaveFiles(removeCopyDirectory: true) self.restoreSaveFiles(removeCopyDirectory: true)
default: break default: break
@ -207,6 +240,7 @@ private extension PreviewGameViewController
self.emulatorCore?.updateCheats() self.emulatorCore?.updateCheats()
// Re-enable emulatorCore to update gameView again // Re-enable emulatorCore to update gameView again
// emulatorCoregameView
self.emulatorCore?.add(self.gameView) self.emulatorCore?.add(self.gameView)
self.emulatorCore?.resume() self.emulatorCore?.resume()
@ -264,3 +298,11 @@ private extension PreviewGameViewController
} }
} }
} }
extension PreviewGameViewController: GameViewControllerDelegate
{
func gameViewControllerShouldResumeEmulation(_ gameViewController: DeltaCore.GameViewController) -> Bool
{
return self.isLivePreview
}
}

View 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()
}
}

View 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()
}
}
}
}

View 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) }
}
}
}

View 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?
}

View File

@ -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...
}

View File

@ -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
}

View 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
}
}
}
}

View 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)
}
}
}

View 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
}
}

View File

@ -6,6 +6,8 @@
// Copyright © 2016 Riley Testut. All rights reserved. // Copyright © 2016 Riley Testut. All rights reserved.
// //
import UIKit
import DeltaCore import DeltaCore
extension ControllerSkin extension ControllerSkin
@ -32,21 +34,20 @@ extension ControllerSkin
var configurations = ControllerSkinConfigurations() var configurations = ControllerSkinConfigurations()
let device: DeltaCore.ControllerSkin.Device = (UIDevice.current.userInterfaceIdiom == .pad) ? .ipad : .iphone let allTraitCombinations = DeltaCore.ControllerSkin.Device.allCases.flatMap { device in
DeltaCore.ControllerSkin.DisplayType.allCases.flatMap { displayType in
let traitCollections: [(displayType: DeltaCore.ControllerSkin.DisplayType, orientation: DeltaCore.ControllerSkin.Orientation)] = DeltaCore.ControllerSkin.Orientation.allCases.map { orientation in
[(.standard, .portrait), (.standard, .landscape), (.edgeToEdge, .portrait), (.edgeToEdge, .landscape), (.splitView, .portrait), (.splitView, .landscape)] DeltaCore.ControllerSkin.Traits(device: device, displayType: displayType, orientation: orientation)
}
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)
} }
} }
for traits in allTraitCombinations
{
guard let configuration = ControllerSkinConfigurations(traits: traits), skin.supports(traits) else { continue }
configurations.formUnion(configuration)
}
self.supportedConfigurations = configurations self.supportedConfigurations = configurations
} }
} }

View 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)
}
}
}

View File

@ -12,6 +12,7 @@ extension HarmonyMetadataKey
{ {
static let gameID = HarmonyMetadataKey("gameID") static let gameID = HarmonyMetadataKey("gameID")
static let gameName = HarmonyMetadataKey("gameName") static let gameName = HarmonyMetadataKey("gameName")
static let verifiedGameID = HarmonyMetadataKey("verifiedGameID")
// Backwards compatibility // Backwards compatibility
static let coreID = HarmonyMetadataKey("coreID") static let coreID = HarmonyMetadataKey("coreID")

View File

@ -124,6 +124,8 @@ extension Input
case .leftTrigger: return NSLocalizedString("L2", comment: "") case .leftTrigger: return NSLocalizedString("L2", comment: "")
case .rightShoulder: return NSLocalizedString("R1", comment: "") case .rightShoulder: return NSLocalizedString("R1", comment: "")
case .rightTrigger: return NSLocalizedString("R2", comment: "") case .rightTrigger: return NSLocalizedString("R2", comment: "")
case .start: return NSLocalizedString("Start", comment: "")
case .select: return NSLocalizedString("Select", comment: "")
} }
case .controller(.keyboard): case .controller(.keyboard):

View File

@ -23,4 +23,29 @@ extension NSManagedObjectContext
print("Error saving NSManagedObjectContext: ", error, error.userInfo) print("Error saving NSManagedObjectContext: ", error, error.userInfo)
} }
} }
// MARK: - Perform -
func performAndWait<T>(_ block: @escaping () -> T) -> T
{
var result: T! = nil
self.performAndWait {
result = block()
}
return result
}
func performAndWait<T>(_ block: @escaping () throws -> T) throws -> T
{
var result: Result<T, Error>! = nil
self.performAndWait {
result = Result { try block() }
}
let value = try result.get()
return value
}
} }

View 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: "")
}
}
}

View 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")")
}
})
}
}

View 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
}
}

View 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()
}
}
}

View File

@ -10,6 +10,6 @@ import UIKit
extension UIColor extension UIColor
{ {
static let deltaPurple = UIColor(named: "Purple")! static let deltaPurple = UIColor.purple
static let deltaDarkGray = UIColor(named: "DarkGray")! static let deltaDarkGray = #colorLiteral(red: 0.07139974087, green: 0.08217515796, blue: 0.1083263531, alpha: 1)
} }

View File

@ -8,12 +8,28 @@
import UIKit import UIKit
import ARKit import ARKit
import Metal
extension UIDevice extension UIDevice
{ {
private static var mtlDevice: MTLDevice? = MTLCreateSystemDefaultDevice()
var hasA9ProcessorOrBetter: Bool { var hasA9ProcessorOrBetter: Bool {
// ARKit is only supported by devices with an A9 processor or better, according to the documentation. // ARKit is only supported by devices with an A9 processor or better, according to the documentation.
// https://developer.apple.com/documentation/arkit/arconfiguration/2923553-issupported // https://developer.apple.com/documentation/arkit/arconfiguration/2923553-issupported
return ARConfiguration.isSupported return ARConfiguration.isSupported
} }
var hasA11ProcessorOrBetter: Bool {
guard let mtlDevice = UIDevice.mtlDevice else { return false }
return mtlDevice.supportsFeatureSet(.iOS_GPUFamily4_v1) // iOS GPU Family 4 = A11 GPU
}
var supportsJIT: Bool {
guard #available(iOS 14.0, *) else { return false }
// 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
}
} }

View File

@ -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
}
}

View 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
}

View File

@ -43,6 +43,8 @@ class GameCollectionViewController: UICollectionViewController
// Calling reloadData sometimes will not update the cells correctly if an insertion/deletion animation is in progress // 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 // As a workaround, we manually iterate over and configure each cell ourselves
// / reloadData
//
for cell in self.collectionView?.visibleCells ?? [] for cell in self.collectionView?.visibleCells ?? []
{ {
if let indexPath = self.collectionView?.indexPath(for: cell) if let indexPath = self.collectionView?.indexPath(for: cell)
@ -66,6 +68,8 @@ class GameCollectionViewController: UICollectionViewController
private weak var _previewTransitionViewController: PreviewGameViewController? private weak var _previewTransitionViewController: PreviewGameViewController?
private weak var _previewTransitionDestinationViewController: UIViewController? private weak var _previewTransitionDestinationViewController: UIViewController?
private weak var _popoverSourceView: UIView?
private var _renameAction: UIAlertAction? private var _renameAction: UIAlertAction?
private var _changingArtworkGame: Game? private var _changingArtworkGame: Game?
private var _importingSaveFileGame: Game? private var _importingSaveFileGame: Game?
@ -93,10 +97,6 @@ extension GameCollectionViewController
self.collectionView?.prefetchDataSource = self.dataSource self.collectionView?.prefetchDataSource = self.dataSource
self.collectionView?.delegate = self self.collectionView?.delegate = self
let layout = self.collectionViewLayout as! GridCollectionViewLayout
layout.itemWidth = 90
layout.minimumInteritemSpacing = 12
if #available(iOS 13, *) {} if #available(iOS 13, *) {}
else else
{ {
@ -105,6 +105,8 @@ extension GameCollectionViewController
let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(GameCollectionViewController.handleLongPressGesture(_:))) let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(GameCollectionViewController.handleLongPressGesture(_:)))
self.collectionView?.addGestureRecognizer(longPressGestureRecognizer) self.collectionView?.addGestureRecognizer(longPressGestureRecognizer)
} }
self.update()
} }
override func viewWillDisappear(_ animated: Bool) override func viewWillDisappear(_ animated: Bool)
@ -131,6 +133,13 @@ extension GameCollectionViewController
super.didReceiveMemoryWarning() super.didReceiveMemoryWarning()
// Dispose of any resources that can be recreated. // Dispose of any resources that can be recreated.
} }
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?)
{
super.traitCollectionDidChange(previousTraitCollection)
self.update()
}
} }
//MARK: - Segues - //MARK: - Segues -
@ -167,9 +176,28 @@ extension GameCollectionViewController
destinationViewController.game = game destinationViewController.game = game
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
{
emulatorBridge.systemType = .dsi
}
else
{
emulatorBridge.systemType = .ds
}
emulatorBridge.isJITEnabled = ProcessInfo.processInfo.isJITAvailable
}
if let saveState = self.activeSaveState if let saveState = self.activeSaveState
{ {
// Must be synchronous or else there will be a flash of black // Must be synchronous or else there will be a flash of black
//
destinationViewController.emulatorCore?.start() destinationViewController.emulatorCore?.start()
destinationViewController.emulatorCore?.pause() destinationViewController.emulatorCore?.pause()
@ -208,6 +236,27 @@ extension GameCollectionViewController
//MARK: - Private Methods - //MARK: - Private Methods -
private extension GameCollectionViewController 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 //MARK: - Data Source
func prepareDataSource() func prepareDataSource()
{ {
@ -266,9 +315,21 @@ private extension GameCollectionViewController
cell.isImageViewVibrancyEnabled = true 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.text = game.name
cell.textLabel.textColor = UIColor.gray cell.textLabel.textColor = UIColor.gray
cell.tintColor = cell.textLabel.textColor cell.tintColor = cell.textLabel.textColor
@ -314,11 +375,13 @@ private extension GameCollectionViewController
} }
// Disable videoManager to prevent flash of black // Disable videoManager to prevent flash of black
// videoManager
self.activeEmulatorCore?.videoManager.isEnabled = false self.activeEmulatorCore?.videoManager.isEnabled = false
launchGame(ignoringErrors: [LaunchError.alreadyRunning]) 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 // The game hasn't changed, so the activeEmulatorCore is the same as before, so we need to enable videoManager it again
// activeEmulatorCorevideoManager
self.activeEmulatorCore?.videoManager.isEnabled = true self.activeEmulatorCore?.videoManager.isEnabled = true
})) }))
alertController.addAction(UIAlertAction(title: NSLocalizedString("Restart", comment: ""), style: .destructive, handler: { (action) in alertController.addAction(UIAlertAction(title: NSLocalizedString("Restart", comment: ""), style: .destructive, handler: { (action) in
@ -358,7 +421,7 @@ private extension GameCollectionViewController
launchGame(ignoringErrors: []) launchGame(ignoringErrors: [])
} }
} }
//
func validateLaunchingGame(_ game: Game, ignoringErrors ignoredErrors: [Error]) throws func validateLaunchingGame(_ game: Game, ignoringErrors ignoredErrors: [Error]) throws
{ {
let ignoredErrors = ignoredErrors.map { $0 as NSError } let ignoredErrors = ignoredErrors.map { $0 as NSError }
@ -395,11 +458,23 @@ private extension GameCollectionViewController
if game.type == .ds && Settings.preferredCore(for: .ds) == MelonDS.core if game.type == .ds && Settings.preferredCore(for: .ds) == MelonDS.core
{ {
guard if game.identifier == Game.melonDSDSiBIOSIdentifier
FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.bios7URL.path) && {
FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.bios9URL.path) && guard
FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.firmwareURL.path) FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.dsiBIOS7URL.path) &&
else { throw LaunchError.biosNotFound } FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.dsiBIOS9URL.path) &&
FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.dsiFirmwareURL.path) &&
FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.dsiNANDURL.path)
else { throw LaunchError.biosNotFound }
}
else
{
guard
FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.bios7URL.path) &&
FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.bios9URL.path) &&
FileManager.default.fileExists(atPath: MelonDSEmulatorBridge.shared.firmwareURL.path)
else { throw LaunchError.biosNotFound }
}
} }
} }
} }
@ -445,15 +520,20 @@ private extension GameCollectionViewController
switch game.type switch game.type
{ {
case GameType.unknown: return [cancelAction, renameAction, changeArtworkAction, shareAction, deleteAction] case GameType.unknown:
case .ds where game.identifier == Game.melonDSBIOSIdentifier: return [cancelAction, renameAction, changeArtworkAction, changeControllerSkinAction, saveStatesAction] return [cancelAction, renameAction, changeArtworkAction, shareAction, deleteAction]
default: return [cancelAction, renameAction, changeArtworkAction, changeControllerSkinAction, shareAction, saveStatesAction, importSaveFile, exportSaveFile, deleteAction] case .ds where game.identifier == Game.melonDSBIOSIdentifier || game.identifier == Game.melonDSDSiBIOSIdentifier:
return [cancelAction, renameAction, changeArtworkAction, changeControllerSkinAction, saveStatesAction]
default:
return [cancelAction, renameAction, changeArtworkAction, changeControllerSkinAction, shareAction, saveStatesAction, importSaveFile, exportSaveFile, deleteAction]
} }
} }
func delete(_ game: Game) 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 confirmationAlertController.addAction(UIAlertAction(title: NSLocalizedString("Delete Game", comment: ""), style: .destructive, handler: { action in
DatabaseManager.shared.performBackgroundTask { (context) in DatabaseManager.shared.performBackgroundTask { (context) in
@ -523,11 +603,19 @@ private extension GameCollectionViewController
let importController = ImportController(documentTypes: [kUTTypeImage as String]) let importController = ImportController(documentTypes: [kUTTypeImage as String])
importController.delegate = self importController.delegate = self
importController.importOptions = [clipboardImportOption, photoLibraryImportOption, gamesDatabaseImportOption] importController.importOptions = [clipboardImportOption, photoLibraryImportOption, gamesDatabaseImportOption]
importController.sourceView = self._popoverSourceView
self.present(importController, animated: true, completion: nil) self.present(importController, animated: true, completion: nil)
} }
func changeArtwork(for game: Game, toImageAt url: URL?, errors: [Error]) func changeArtwork(for game: Game, toImageAt url: URL?, errors: [Error])
{ {
defer {
if let temporaryImageURL = url
{
try? FileManager.default.removeItem(at: temporaryImageURL)
}
}
var errors = errors var errors = errors
var imageURL: URL? var imageURL: URL?
@ -543,7 +631,8 @@ private extension GameCollectionViewController
if if
let image = UIImage(data: imageData), let image = UIImage(data: imageData),
let resizedImage = image.resizing(toFit: CGSize(width: 300, height: 300)), let resizedImage = image.resizing(toFit: CGSize(width: 300, height: 300)),
let resizedData = resizedImage.jpegData(compressionQuality: 0.85) let rotatedImage = resizedImage.rotatedToIntrinsicOrientation(), // in case image was imported directly from Files
let resizedData = rotatedImage.pngData()
{ {
let destinationURL = DatabaseManager.artworkURL(for: game) let destinationURL = DatabaseManager.artworkURL(for: game)
try resizedData.write(to: destinationURL, options: .atomic) try resizedData.write(to: destinationURL, options: .atomic)
@ -569,6 +658,14 @@ private extension GameCollectionViewController
if let imageURL = imageURL if let imageURL = imageURL
{ {
self.dataSource.prefetchItemCache.removeObject(forKey: game)
if let cacheManager = SDWebImageManager.shared()
{
let cacheKey = cacheManager.cacheKey(for: imageURL)
cacheManager.imageCache.removeImage(forKey: cacheKey)
}
DatabaseManager.shared.performBackgroundTask { (context) in DatabaseManager.shared.performBackgroundTask { (context) in
let temporaryGame = context.object(with: game.objectID) as! Game let temporaryGame = context.object(with: game.objectID) as! Game
temporaryGame.artworkURL = imageURL temporaryGame.artworkURL = imageURL
@ -578,6 +675,15 @@ private extension GameCollectionViewController
SyncManager.shared.recordController?.updateRecord(for: temporaryGame) SyncManager.shared.recordController?.updateRecord(for: temporaryGame)
DispatchQueue.main.async { DispatchQueue.main.async {
if let indexPath = self.dataSource.fetchedResultsController.indexPath(forObject: game)
{
// 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])
}
self.presentedViewController?.dismiss(animated: true, completion: nil) self.presentedViewController?.dismiss(animated: true, completion: nil)
} }
} }
@ -609,26 +715,36 @@ private extension GameCollectionViewController
func share(_ game: Game) func share(_ game: Game)
{ {
let temporaryDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let temporaryDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
let symbolicURL = temporaryDirectory.appendingPathComponent(game.name + "." + game.fileURL.pathExtension)
let sanitizedName = game.name.components(separatedBy: .urlFilenameAllowed.inverted).joined()
let temporaryURL = temporaryDirectory.appendingPathComponent(sanitizedName + "." + game.fileURL.pathExtension, isDirectory: false)
do do
{ {
try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true, attributes: nil) 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. // 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 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 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 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 do
{ {
try FileManager.default.removeItem(at: temporaryDirectory) try FileManager.default.removeItem(at: temporaryDirectory)
@ -638,6 +754,7 @@ private extension GameCollectionViewController
print(error) print(error)
} }
} }
self.present(activityViewController, animated: true, completion: nil) self.present(activityViewController, animated: true, completion: nil)
} }
@ -693,8 +810,7 @@ private extension GameCollectionViewController
{ {
do do
{ {
let illegalCharacterSet = CharacterSet(charactersIn: "\"\\/?<>:*|") let sanitizedFilename = game.name.components(separatedBy: .urlFilenameAllowed.inverted).joined()
let sanitizedFilename = game.name.components(separatedBy: illegalCharacterSet).joined() + "." + game.gameSaveURL.pathExtension
let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(sanitizedFilename) let temporaryURL = FileManager.default.temporaryDirectory.appendingPathComponent(sanitizedFilename)
try FileManager.default.copyItem(at: game.gameSaveURL, to: temporaryURL, shouldReplace: true) try FileManager.default.copyItem(at: game.gameSaveURL, to: temporaryURL, shouldReplace: true)
@ -753,6 +869,9 @@ extension GameCollectionViewController: UIViewControllerPreviewingDelegate
previewingContext.sourceRect = layoutAttributes.frame previewingContext.sourceRect = layoutAttributes.frame
let cell = collectionView.cellForItem(at: indexPath)
self._popoverSourceView = cell
let game = self.dataSource.item(at: indexPath) let game = self.dataSource.item(at: indexPath)
let gameViewController = self.makePreviewGameViewController(for: game) let gameViewController = self.makePreviewGameViewController(for: game)
@ -776,6 +895,22 @@ extension GameCollectionViewController: UIViewControllerPreviewingDelegate
gameViewController.previewImage = UIImage(contentsOfFile: previewSaveState.imageFileURL.path) gameViewController.previewImage = UIImage(contentsOfFile: previewSaveState.imageFileURL.path)
} }
if let emulatorBridge = gameViewController.emulatorCore?.deltaCore.emulatorBridge as? MelonDSEmulatorBridge
{
//TODO: Update this to work with multiple processes by retrieving emulatorBridge directly from emulatorCore.
if game.identifier == Game.melonDSDSiBIOSIdentifier
{
emulatorBridge.systemType = .dsi
}
else
{
emulatorBridge.systemType = .ds
}
emulatorBridge.isJITEnabled = ProcessInfo.processInfo.isJITAvailable
}
let actions = self.actions(for: game).previewActions let actions = self.actions(for: game).previewActions
gameViewController.overridePreviewActionItems = actions gameViewController.overridePreviewActionItems = actions
@ -790,9 +925,16 @@ extension GameCollectionViewController: UIViewControllerPreviewingDelegate
gameViewController.pauseEmulation() gameViewController.pauseEmulation()
let indexPath = self.dataSource.fetchedResultsController.indexPath(forObject: game)! let indexPath = self.dataSource.fetchedResultsController.indexPath(forObject: game)!
let fileURL = FileManager.default.uniqueTemporaryURL() let fileURL = FileManager.default.uniqueTemporaryURL()
self.activeSaveState = gameViewController.emulatorCore?.saveSaveState(to: fileURL)
if gameViewController.isLivePreview
{
self.activeSaveState = gameViewController.emulatorCore?.saveSaveState(to: fileURL)
}
else
{
self.activeSaveState = gameViewController.previewSaveState
}
gameViewController.emulatorCore?.stop() gameViewController.emulatorCore?.stop()
@ -800,13 +942,16 @@ extension GameCollectionViewController: UIViewControllerPreviewingDelegate
self.launchGame(at: indexPath, clearScreen: true, ignoreAlreadyRunningError: true) self.launchGame(at: indexPath, clearScreen: true, ignoreAlreadyRunningError: true)
do if gameViewController.isLivePreview
{ {
try FileManager.default.removeItem(at: fileURL) do
} {
catch try FileManager.default.removeItem(at: fileURL)
{ }
print(error) catch
{
print(error)
}
} }
} }
} }
@ -873,6 +1018,7 @@ extension GameCollectionViewController: UICollectionViewDelegateFlowLayout
{ {
func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize
{ {
let collectionViewLayout = collectionView.collectionViewLayout as! GridCollectionViewLayout let collectionViewLayout = collectionView.collectionViewLayout as! GridCollectionViewLayout
let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionViewLayout.itemWidth) let widthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionViewLayout.itemWidth)
@ -882,7 +1028,9 @@ extension GameCollectionViewController: UICollectionViewDelegateFlowLayout
self.configure(self.prototypeCell, for: indexPath) self.configure(self.prototypeCell, for: indexPath)
let size = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) let size = self.prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
return size // return size
return CGSize(width: 150, height: 150)
} }
} }
@ -894,15 +1042,31 @@ extension GameCollectionViewController
let game = self.dataSource.item(at: indexPath) let game = self.dataSource.item(at: indexPath)
let actions = self.actions(for: game) 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 return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { [weak self] in
guard let self = self else { return nil } guard let self = self else { return nil }
do
{
try self.validateLaunchingGame(game, ignoringErrors: [LaunchError.alreadyRunning])
}
catch
{
print("Error trying to preview game:", error)
return nil
}
let previewViewController = self.makePreviewGameViewController(for: game) let previewViewController = self.makePreviewGameViewController(for: game)
previewViewController.isLivePreview = Settings.isPreviewsEnabled
guard previewViewController.isLivePreview || previewViewController.previewSaveState != nil else { return nil }
self._previewTransitionViewController = previewViewController self._previewTransitionViewController = previewViewController
return previewViewController return previewViewController
}) { suggestedActions in }) { suggestedActions in
return UIMenu(title: "", children: actions.menuActions) return UIMenu(title: game.name, children: actions.menuActions)
} }
} }

View File

@ -47,6 +47,7 @@ class GamesViewController: UIViewController
private let fetchedResultsController: NSFetchedResultsController<NSFetchRequestResult> private let fetchedResultsController: NSFetchedResultsController<NSFetchRequestResult>
private var searchController: RSTSearchController? private var searchController: RSTSearchController?
private lazy var importController: ImportController = self.makeImportController()
private var syncingToastView: RSTToastView? { private var syncingToastView: RSTToastView? {
didSet { didSet {
@ -58,6 +59,8 @@ class GamesViewController: UIViewController
} }
private var syncingProgressObservation: NSKeyValueObservation? private var syncingProgressObservation: NSKeyValueObservation?
@IBOutlet private var importButton: UIBarButtonItem!
override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
fatalError("initWithNibName: not implemented") 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.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.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) 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 - //MARK: - UIViewController -
@ -90,9 +100,16 @@ extension GamesViewController
self.placeholderView = RSTPlaceholderView(frame: self.view.bounds) self.placeholderView = RSTPlaceholderView(frame: self.view.bounds)
self.placeholderView.autoresizingMask = [.flexibleWidth, .flexibleHeight] self.placeholderView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
self.placeholderView.textLabel.text = NSLocalizedString("No Games", comment: "") self.placeholderView.textLabel.text = NSLocalizedString("", comment: "")
self.placeholderView.detailTextLabel.text = NSLocalizedString("You can import games by pressing the + button in the top right.", 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.view.insertSubview(self.placeholderView, at: 0)
self.placeholderView.addSubview(placeholderImagV)
self.pageControl = UIPageControl() self.pageControl = UIPageControl()
self.pageControl.translatesAutoresizingMaskIntoConstraints = false 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.centerXAnchor.constraint(equalTo: (self.navigationController?.toolbar.centerXAnchor)!, constant: 0).isActive = true
self.pageControl.centerYAnchor.constraint(equalTo: (self.navigationController?.toolbar.centerYAnchor)!, constant: 0).isActive = true self.pageControl.centerYAnchor.constraint(equalTo: (self.navigationController?.toolbar.centerYAnchor)!, constant: 0).isActive = true
if let navigationController = self.navigationController // 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
// }
// }
if #available(iOS 14, *)
{ {
if #available(iOS 13.0, *) self.importController.presentingViewController = self
{
navigationController.overrideUserInterfaceStyle = .dark
let navigationBarAppearance = navigationController.navigationBar.standardAppearance.copy() let importActions = self.importController.makeActions().menuActions
navigationBarAppearance.backgroundEffect = UIBlurEffect(style: .dark) let importMenu = UIMenu(title: NSLocalizedString("Import From…", comment: ""), image: UIImage(systemName: "square.and.arrow.down"), children: importActions)
navigationController.navigationBar.standardAppearance = navigationBarAppearance self.importButton.menu = importMenu
let toolbarAppearance = navigationController.toolbar.standardAppearance.copy() self.importButton.action = nil
toolbarAppearance.backgroundEffect = UIBlurEffect(style: .dark) self.importButton.target = nil
navigationController.toolbar.standardAppearance = toolbarAppearance }
} else
else {
{ self.importController.barButtonItem = self.importButton
navigationController.navigationBar.barStyle = .blackTranslucent
navigationController.toolbar.barStyle = .blackTranslucent
}
} }
self.prepareSearchController() self.prepareSearchController()
@ -227,6 +266,7 @@ private extension GamesViewController
return nil return nil
} }
self.searchController?.searchBar.barStyle = .black self.searchController?.searchBar.barStyle = .black
self.searchController?.searchBar.placeholder = "Game here"
self.navigationItem.searchController = self.searchController self.navigationItem.searchController = self.searchController
self.navigationItem.hidesSearchBarWhenScrolling = false self.navigationItem.hidesSearchBarWhenScrolling = false
@ -329,7 +369,8 @@ private extension GamesViewController
self.pageViewController.setViewControllers([viewController], direction: .forward, animated: false, completion: nil) self.pageViewController.setViewControllers([viewController], direction: .forward, animated: false, completion: nil)
self.title = viewController.title // self.title = viewController.title
self.title = ""
self.pageControl.currentPage = index self.pageControl.currentPage = index
} }
} }
@ -352,26 +393,40 @@ private extension GamesViewController
/// Importing /// Importing
extension GamesViewController: ImportControllerDelegate extension GamesViewController: ImportControllerDelegate
{ {
@IBAction private func importFiles() private func makeImportController() -> ImportController
{ {
var documentTypes = Set(System.registeredSystems.map { $0.gameType.rawValue }) var documentTypes = Set(System.registeredSystems.map { $0.gameType.rawValue })
documentTypes.insert(kUTTypeZipArchive as String) documentTypes.insert(kUTTypeZipArchive as String)
documentTypes.insert("com.rileytestut.delta.skin")
#if BETA
// .bin files (Genesis ROMs)
documentTypes.insert("com.apple.macbinary-archive")
#endif
// Add GBA4iOS's exported UTIs in case user has GBA4iOS installed (which may override Delta's UTI declarations) // Add GBA4iOS's exported UTIs in case user has GBA4iOS installed (which may override Delta's UTI declarations)
documentTypes.insert("com.rileytestut.gba") documentTypes.insert("com.rileytestut.gba")
documentTypes.insert("com.rileytestut.gbc") documentTypes.insert("com.rileytestut.gbc")
documentTypes.insert("com.rileytestut.gb") documentTypes.insert("com.rileytestut.gb")
documentTypes.insert("com.rileytestut.delta.skin") // let itunesImportOption = iTunesImportOption(presentingViewController: self)
let itunesImportOption = iTunesImportOption(presentingViewController: self)
let importController = ImportController(documentTypes: documentTypes) let importController = ImportController(documentTypes: documentTypes)
importController.delegate = self importController.delegate = self
importController.importOptions = [itunesImportOption] // importController.importOptions = [itunesImportOption]
self.present(importController, animated: true, completion: nil)
return importController
} }
@IBAction private func importFiles()
{
self.present(self.importController, animated: true, completion: nil)
}
func importController(_ importController: ImportController, didImportItemsAt urls: Set<URL>, errors: [Error]) func importController(_ importController: ImportController, didImportItemsAt urls: Set<URL>, errors: [Error])
{ {
for error in errors for error in errors
@ -421,6 +476,8 @@ private extension GamesViewController
SyncManager.shared.sync() SyncManager.shared.sync()
} }
func showSyncingToastViewIfNeeded() func showSyncingToastViewIfNeeded()
{ {
guard let coordinator = SyncManager.shared.coordinator, let syncProgress = SyncManager.shared.syncProgress, coordinator.isSyncing && self.syncingToastView == nil else { return } guard let coordinator = SyncManager.shared.coordinator, let syncProgress = SyncManager.shared.syncProgress, coordinator.isSyncing && self.syncingToastView == nil else { return }
@ -611,7 +668,7 @@ extension GamesViewController: NSFetchedResultsControllerDelegate
extension GamesViewController: UIAdaptivePresentationControllerDelegate extension GamesViewController: UIAdaptivePresentationControllerDelegate
{ {
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) func presentationControllerDidDismiss(_ presentationController: UIPresentationController)
{ {
self.sync() self.sync()
} }

View File

@ -20,7 +20,10 @@ struct ClipboardImportOption: ImportOption
{ {
guard UIPasteboard.general.hasImages else { return completionHandler([]) } guard UIPasteboard.general.hasImages else { return completionHandler([]) }
guard let data = UIPasteboard.general.data(forPasteboardType: kUTTypeImage as String) else { return completionHandler([]) } guard let image = UIPasteboard.general.image,
let rotatedImage = image.rotatedToIntrinsicOrientation(),
let data = rotatedImage.pngData()
else { return completionHandler([]) }
do do
{ {

View File

@ -42,7 +42,7 @@ extension PhotoLibraryImportOption: UIImagePickerControllerDelegate, UINavigatio
{ {
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any])
{ {
guard let image = info[.originalImage] as? UIImage, let data = image.jpegData(compressionQuality: 0.85) else { guard let image = info[.originalImage] as? UIImage, let rotatedImage = image.rotatedToIntrinsicOrientation(), let data = rotatedImage.pngData() else {
self.completionHandler?([]) self.completionHandler?([])
return return
} }

View File

@ -13,7 +13,7 @@ import DeltaCore
struct iTunesImportOption: ImportOption struct iTunesImportOption: ImportOption
{ {
let title = NSLocalizedString("iTunes", comment: "") let title = NSLocalizedString("iTunes", comment: "")
let image: UIImage? = nil let image: UIImage? = UIImage(symbolNameIfAvailable: "music.note")
private let presentingViewController: UIViewController private let presentingViewController: UIViewController

View File

@ -8,6 +8,7 @@
import UIKit import UIKit
import MobileCoreServices import MobileCoreServices
import UniformTypeIdentifiers
import ObjectiveC import ObjectiveC
import DeltaCore import DeltaCore
@ -37,7 +38,10 @@ class ImportController: NSObject
var delegate: ImportControllerDelegate? var delegate: ImportControllerDelegate?
var importOptions: [ImportOption]? 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. // 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. // Calling dismiss on presentingViewController in that case would dismiss presentingViewController, which is bad.
@ -61,26 +65,54 @@ class ImportController: NSObject
super.init() 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)?) fileprivate func presentImportController(from presentingViewController: UIViewController, animated: Bool, completionHandler: (() -> Void)?)
{ {
self.presentingViewController = presentingViewController self.presentingViewController = presentingViewController
let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) let actions = self.makeActions()
alertController.addAction(UIAlertAction.cancel)
if let importOptions = self.importOptions if actions.count > 1
{ {
for importOption in importOptions let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
alertController.addAction(UIAlertAction.cancel)
let alertActions = actions.map { UIAlertAction($0) }
for action in alertActions
{ {
alertController.add(importOption) { [unowned self] (urls) in alertController.addAction(action)
self.finish(with: urls, errors: [])
}
} }
let filesAction = UIAlertAction(title: NSLocalizedString("Files", comment: ""), style: .default) { (action) in if let sourceView = self.sourceView
self.presentDocumentBrowser() {
alertController.popoverPresentationController?.sourceView = sourceView.superview
alertController.popoverPresentationController?.sourceRect = sourceView.frame
}
else
{
alertController.popoverPresentationController?.barButtonItem = self.barButtonItem
} }
alertController.addAction(filesAction)
self.presentedViewController = alertController self.presentedViewController = alertController
self.presentingViewController?.present(alertController, animated: true, completion: nil) self.presentingViewController?.present(alertController, animated: true, completion: nil)
@ -116,18 +148,38 @@ class ImportController: NSObject
private func presentDocumentBrowser() 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
documentBrowserViewController.delegate = self
documentBrowserViewController.modalPresentationStyle = .fullScreen
documentBrowserViewController.browserUserInterfaceStyle = .dark
documentBrowserViewController.allowsPickingMultipleItems = true
documentBrowserViewController.allowsDocumentCreation = false
documentBrowserViewController.additionalTrailingNavigationBarButtonItems = [cancelButton]
self.presentedViewController = documentBrowserViewController if #available(iOS 17, *)
self.presentingViewController?.present(documentBrowserViewController, animated: true, completion: nil) {
// Prior to iOS 17, UIDocumentPickerViewController was too buggy to reliably use with iCloud Drive.
let documentPickerViewController = UIDocumentPickerViewController(forOpeningContentTypes: supportedTypes, asCopy: true)
documentPickerViewController.delegate = self
documentPickerViewController.overrideUserInterfaceStyle = .dark
documentPickerViewController.allowsMultipleSelection = true
presentedViewController = documentPickerViewController
}
else
{
let documentBrowserViewController = UIDocumentBrowserViewController(forOpening: supportedTypes)
documentBrowserViewController.delegate = self
documentBrowserViewController.modalPresentationStyle = .fullScreen
documentBrowserViewController.browserUserInterfaceStyle = .dark
documentBrowserViewController.allowsPickingMultipleItems = true
documentBrowserViewController.allowsDocumentCreation = false
let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(ImportController.cancel))
documentBrowserViewController.additionalTrailingNavigationBarButtonItems = [cancelButton]
presentedViewController = documentBrowserViewController
}
self.presentedViewController = presentedViewController
self.presentingViewController?.present(presentedViewController, animated: true, completion: nil)
} }
} }
@ -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 extension ImportController: UIDocumentBrowserViewControllerDelegate
{ {
func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentURLs documentURLs: [URL]) func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentsAt documentURLs: [URL])
{ {
var coordinatedURLs = Set<URL>() var coordinatedURLs = Set<URL>()
var errors = [Error]() var errors = [Error]()
@ -198,7 +263,7 @@ private var ImportControllerKey: UInt8 = 0
extension UIViewController extension UIViewController
{ {
fileprivate(set) var importController: ImportController? fileprivate var importController: ImportController?
{ {
set set
{ {

View File

@ -13,7 +13,9 @@ import Harmony
class LaunchViewController: RSTLaunchViewController class LaunchViewController: RSTLaunchViewController
{ {
@IBOutlet private var gameViewContainerView: UIView! @IBOutlet private var gameViewContainerView: UIView!
private var gameViewController: GameViewController! private var gameViewController: GameViewController!
private var presentedGameViewController: Bool = false 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) override func handleLaunchError(_ error: Error)

View File

@ -12,13 +12,26 @@ import DeltaCore
extension CheatValidator extension CheatValidator
{ {
enum Error: Swift.Error enum Error: LocalizedError
{ {
case invalidCode case invalidCode
case invalidName case invalidName
case invalidGame case invalidGame
case duplicateName case duplicateName
case duplicateCode 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: "")
}
}
} }
} }

View File

@ -8,8 +8,10 @@
import UIKit import UIKit
import CoreData import CoreData
import SwiftUI
import DeltaCore import DeltaCore
import MelonDSDeltaCore
import Roxas import Roxas
@ -30,6 +32,8 @@ class CheatsViewController: UITableViewController
weak var delegate: CheatsViewControllerDelegate? weak var delegate: CheatsViewControllerDelegate?
private let dataSource = RSTFetchedResultsTableViewDataSource<Cheat>(fetchedResultsController: NSFetchedResultsController()) private let dataSource = RSTFetchedResultsTableViewDataSource<Cheat>(fetchedResultsController: NSFetchedResultsController())
private var cheatBaseCheats: [CheatMetadata]?
} }
extension CheatsViewController extension CheatsViewController
@ -61,6 +65,21 @@ extension CheatsViewController
self.tableView.separatorEffect = vibrancyEffect self.tableView.separatorEffect = vibrancyEffect
self.registerForPreviewing(with: self, sourceView: self.tableView) 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() override func didReceiveMemoryWarning()
@ -91,6 +110,37 @@ private extension CheatsViewController
self.dataSource.fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: nil, cacheName: nil) 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 - //MARK: - Managing Cheats -
@ -103,6 +153,78 @@ private extension CheatsViewController
editCheatViewController.presentWithPresentingViewController(self) 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) func deleteCheat(_ cheat: Cheat)
{ {
self.delegate?.cheatsViewController(self, deactivateCheat: cheat) self.delegate?.cheatsViewController(self, deactivateCheat: cheat)

View File

@ -70,7 +70,7 @@ extension GridMenuViewController
collectionViewLayout.usesEqualHorizontalSpacingDistributionForSingleRow = true collectionViewLayout.usesEqualHorizontalSpacingDistributionForSingleRow = true
// Manually update prototype cell properties // 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) 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)
}
}

View File

@ -15,6 +15,8 @@ class MenuItem: NSObject
var image: UIImage? var image: UIImage?
var action: ((MenuItem) -> Void) var action: ((MenuItem) -> Void)
var menu: UIMenu?
@objc dynamic var isSelected = false @objc dynamic var isSelected = false
init(text: String, image: UIImage?, action: @escaping ((MenuItem) -> Void)) init(text: String, image: UIImage?, action: @escaping ((MenuItem) -> Void))

View File

@ -19,7 +19,7 @@ class PauseViewController: UIViewController, PauseInfoProviding
} }
var pauseItems: [MenuItem] { 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 /// Pause Items
@ -28,6 +28,7 @@ class PauseViewController: UIViewController, PauseInfoProviding
var cheatCodesItem: MenuItem? var cheatCodesItem: MenuItem?
var fastForwardItem: MenuItem? var fastForwardItem: MenuItem?
var sustainButtonsItem: MenuItem? var sustainButtonsItem: MenuItem?
var screenshotItem: MenuItem?
/// PauseInfoProviding /// PauseInfoProviding
var pauseText: String? var pauseText: String?
@ -160,8 +161,9 @@ private extension PauseViewController
self.cheatCodesItem = nil self.cheatCodesItem = nil
self.sustainButtonsItem = nil self.sustainButtonsItem = nil
self.fastForwardItem = 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.saveStateItem = MenuItem(text: NSLocalizedString("Save State", comment: ""), image: #imageLiteral(resourceName: "SaveSaveState"), action: { [unowned self] _ in
self.saveStatesViewControllerMode = .saving 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.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 }) 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() func updateSafeAreaInsets()
@ -194,4 +207,56 @@ private extension PauseViewController
self.additionalSafeAreaInsets.right = 0 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
}
} }

View File

@ -93,14 +93,6 @@ extension SaveStatesViewController
self.collectionView?.dataSource = self.dataSource self.collectionView?.dataSource = self.dataSource
self.collectionView?.prefetchDataSource = 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 switch self.mode
{ {
case .saving: case .saving:
@ -113,8 +105,7 @@ extension SaveStatesViewController
self.navigationItem.rightBarButtonItems?.removeFirst() self.navigationItem.rightBarButtonItems?.removeFirst()
} }
// Manually update prototype cell properties self.prototypeCellWidthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: 0)
self.prototypeCellWidthConstraint = self.prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionViewLayout.itemWidth)
self.prototypeCellWidthConstraint.isActive = true self.prototypeCellWidthConstraint.isActive = true
self.prepareEmulatorCoreSaveState() self.prepareEmulatorCoreSaveState()
@ -238,6 +229,26 @@ private extension SaveStatesViewController
} }
self.sortButton.transform = CGAffineTransform.identity.rotated(by: Settings.sortSaveStatesByOldestFirst ? 0 : .pi) 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 - //MARK: - Configure Views -
@ -769,14 +780,14 @@ extension SaveStatesViewController
guard let actions = self.actionsForSaveState(saveState) else { return nil } guard let actions = self.actionsForSaveState(saveState) else { return nil }
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { [weak self] in return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { [weak self] in
guard let self = self else { return nil } guard let self = self, Settings.isPreviewsEnabled else { return nil }
let previewGameViewController = self.makePreviewGameViewController(for: saveState) let previewGameViewController = self.makePreviewGameViewController(for: saveState)
self._previewTransitionViewController = previewGameViewController self._previewTransitionViewController = previewGameViewController
return previewGameViewController return previewGameViewController
}) { suggestedActions in }) { suggestedActions in
return UIMenu(title: "", children: actions.menuActions) return UIMenu(title: saveState.localizedName, children: actions.menuActions)
} }
} }

View 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.
}
}

View 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)
}
}

View 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.
}
*/
}

View 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>

View File

@ -14,7 +14,7 @@ import DeltaCore
import Roxas import Roxas
@objc(SwitchTableViewCell) @objc(SwitchTableViewCell)
private class SwitchTableViewCell: UITableViewCell class SwitchTableViewCell: UITableViewCell
{ {
@IBOutlet var switchView: UISwitch! @IBOutlet var switchView: UISwitch!
} }
@ -115,8 +115,8 @@ private extension AppIconShortcutsViewController
self.dataSource.rowAnimation = .fade self.dataSource.rowAnimation = .fade
let placeholderView = RSTPlaceholderView() let placeholderView = RSTPlaceholderView()
placeholderView.textLabel.text = NSLocalizedString("No App Icon Shortcuts", comment: "") placeholderView.textLabel.text = NSLocalizedString("No Home Screen Shortcuts", comment: "")
placeholderView.detailTextLabel.text = NSLocalizedString("You can customize the shortcuts that appear when 3D Touching the app icon once you've added some games.", comment: "") placeholderView.detailTextLabel.text = NSLocalizedString("You can customize the shortcuts that appear when long-pressing the app icon once you've added some games.", comment: "")
self.dataSource.placeholderView = placeholderView self.dataSource.placeholderView = placeholderView
} }
@ -131,8 +131,11 @@ private extension AppIconShortcutsViewController
func configureGameCell(_ cell: GameTableViewCell, with game: Game, for indexPath: IndexPath) func configureGameCell(_ cell: GameTableViewCell, with game: Game, for indexPath: IndexPath)
{ {
cell.nameLabel.textColor = .darkText if #available(iOS 13.0, *) {
cell.backgroundColor = .white cell.nameLabel?.textColor = .label
} else {
cell.nameLabel?.textColor = .darkText
}
cell.nameLabel.text = game.name cell.nameLabel.text = game.name
cell.artworkImageView.image = #imageLiteral(resourceName: "BoxArt") cell.artworkImageView.image = #imageLiteral(resourceName: "BoxArt")
@ -194,11 +197,9 @@ private extension AppIconShortcutsViewController
func addShortcut(for game: Game) func addShortcut(for game: Game)
{ {
guard self.shortcutsDataSource.items.count < 4 else { return } guard self.shortcutsDataSource.items.count < 4 else { return }
guard !self.shortcutsDataSource.items.contains(game) else { return } guard !self.shortcutsDataSource.items.contains(game) else { return }
// No need to adjust destinationIndexPath, since it forwards change directly to table view. let destinationIndexPath = IndexPath(row: self.shortcutsDataSource.items.count, section: 0)
let destinationIndexPath = IndexPath(row: self.shortcutsDataSource.items.count, section: 1)
let insertion = RSTCellContentChange(type: .insert, currentIndexPath: nil, destinationIndexPath: destinationIndexPath) let insertion = RSTCellContentChange(type: .insert, currentIndexPath: nil, destinationIndexPath: destinationIndexPath)
insertion.rowAnimation = .fade insertion.rowAnimation = .fade
@ -264,8 +265,8 @@ extension AppIconShortcutsViewController
switch (section, Settings.gameShortcutsMode) switch (section, Settings.gameShortcutsMode)
{ {
case (0, .recent): return NSLocalizedString("Your most recently played games will appear as shortcuts when 3D touching the app icon.", comment: "") case (0, .recent): return NSLocalizedString("Your most recently played games will appear as shortcuts when long-pressing the app icon.", comment: "")
case (0, .manual): return NSLocalizedString("The games you've selected below will appear as shortcuts when 3D touching the app icon.", comment: "") case (0, .manual): return NSLocalizedString("The games you've selected below will appear as shortcuts when long-pressing the app icon.", comment: "")
case (1, .recent) where self.shortcutsDataSource.itemCount == 0: return NSLocalizedString("You have no recently played games.", comment: "") case (1, .recent) where self.shortcutsDataSource.itemCount == 0: return NSLocalizedString("You have no recently played games.", comment: "")
case (1, .recent): return " " // Return non-empty string since empty string changes vertical offset of section for some reason. case (1, .recent): return " " // Return non-empty string since empty string changes vertical offset of section for some reason.
case (1, .manual): return NSLocalizedString("You may have up to 4 shortcuts.", comment: "") case (1, .manual): return NSLocalizedString("You may have up to 4 shortcuts.", comment: "")
@ -292,11 +293,13 @@ extension AppIconShortcutsViewController
{ {
case .none: break case .none: break
case .delete: case .delete:
let deletion = RSTCellContentChange(type: .delete, currentIndexPath: indexPath, destinationIndexPath: nil) let adjustedIndexPath = IndexPath(row: indexPath.row, section: 0)
let deletion = RSTCellContentChange(type: .delete, currentIndexPath: adjustedIndexPath, destinationIndexPath: nil)
deletion.rowAnimation = .fade deletion.rowAnimation = .fade
var shortcuts = self.shortcutsDataSource.items var shortcuts = self.shortcutsDataSource.items
shortcuts.remove(at: indexPath.row) // No need to adjust indexPath, since it forwards change directly to table view. shortcuts.remove(at: adjustedIndexPath.row)
self.shortcutsDataSource.setItems(shortcuts, with: [deletion]) self.shortcutsDataSource.setItems(shortcuts, with: [deletion])
case .insert: case .insert:

View 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?
}

View 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)
}
}

View File

@ -38,6 +38,8 @@ class ControllerSkinsViewController: UITableViewController
private let dataSource: RSTFetchedResultsTableViewPrefetchingDataSource<ControllerSkin, UIImage> private let dataSource: RSTFetchedResultsTableViewPrefetchingDataSource<ControllerSkin, UIImage>
@IBOutlet private var importControllerSkinButton: UIBarButtonItem!
required init?(coder aDecoder: NSCoder) required init?(coder aDecoder: NSCoder)
{ {
self.dataSource = RSTFetchedResultsTableViewPrefetchingDataSource<ControllerSkin, UIImage>(fetchedResultsController: NSFetchedResultsController()) self.dataSource = RSTFetchedResultsTableViewPrefetchingDataSource<ControllerSkin, UIImage>(fetchedResultsController: NSFetchedResultsController())
@ -57,9 +59,11 @@ extension ControllerSkinsViewController
self.tableView.dataSource = self.dataSource self.tableView.dataSource = self.dataSource
self.tableView.prefetchDataSource = self.dataSource self.tableView.prefetchDataSource = self.dataSource
self.importControllerSkinButton.accessibilityLabel = NSLocalizedString("Import Controller Skin", comment: "")
if !self.isResetButtonVisible if !self.isResetButtonVisible
{ {
self.navigationItem.rightBarButtonItem = nil self.navigationItem.rightBarButtonItems = [self.importControllerSkinButton]
} }
} }
@ -104,13 +108,13 @@ private extension ControllerSkinsViewController
{ {
guard let system = self.system, let traits = self.traits else { return } 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() let fetchRequest: NSFetchRequest<ControllerSkin> = ControllerSkin.fetchRequest()
if traits.device == .iphone && traits.displayType == .edgeToEdge 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. // Allow selecting skins that only support standard display types as well.
fetchRequest.predicate = NSPredicate(format: "%K == %@ AND ((%K & %d) != 0 OR (%K & %d) != 0)", fetchRequest.predicate = NSPredicate(format: "%K == %@ AND ((%K & %d) != 0 OR (%K & %d) != 0)",
@ -139,6 +143,13 @@ private extension ControllerSkinsViewController
})) }))
self.present(alertController, animated: true, completion: nil) self.present(alertController, animated: true, completion: nil)
} }
@IBAction private func importControllerSkin()
{
let importController = ImportController(documentTypes: ["com.rileytestut.delta.skin"])
importController.delegate = self
self.present(importController, animated: true, completion: nil)
}
} }
extension ControllerSkinsViewController extension ControllerSkinsViewController
@ -196,3 +207,41 @@ extension ControllerSkinsViewController
return height return height
} }
} }
extension ControllerSkinsViewController: ImportControllerDelegate
{
func importController(_ importController: ImportController, didImportItemsAt urls: Set<URL>, errors: [Error])
{
for error in errors
{
print(error)
}
if let error = errors.first
{
DispatchQueue.main.async {
self.transitionCoordinator?.animate(alongsideTransition: nil) { _ in
// Wait until ImportController is dismissed before presenting alert.
let alertController = UIAlertController(title: NSLocalizedString("Failed to Import Controller Skin", comment: ""), error: error)
self.present(alertController, animated: true, completion: nil)
}
}
return
}
let controllerSkinURLs = urls.filter { $0.pathExtension.lowercased() == "deltaskin" }
DatabaseManager.shared.importControllerSkins(at: Set(controllerSkinURLs)) { (controllerSkins, errors) in
if errors.count > 0
{
let alertController = UIAlertController.alertController(for: .controllerSkins, with: errors)
self.present(alertController, animated: true, completion: nil)
}
if controllerSkins.count > 0
{
print("Imported Controller Skins:", controllerSkins.map { $0.name })
}
}
}
}

View File

@ -40,6 +40,8 @@ class ControllerInputsViewController: UIViewController
private var activeCalloutView: InputCalloutView? private var activeCalloutView: InputCalloutView?
private var _didLayoutSubviews = false
@IBOutlet private var actionsMenuViewControllerHeightConstraint: NSLayoutConstraint! @IBOutlet private var actionsMenuViewControllerHeightConstraint: NSLayoutConstraint!
@IBOutlet private var cancelTapGestureRecognizer: UITapGestureRecognizer! @IBOutlet private var cancelTapGestureRecognizer: UITapGestureRecognizer!
@ -65,7 +67,15 @@ class ControllerInputsViewController: UIViewController
self.gameViewController.controllerView.addReceiver(self) self.gameViewController.controllerView.addReceiver(self)
self.navigationController?.navigationBar.barStyle = .black 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)]) 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 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) override func viewDidAppear(_ animated: Bool)
@ -91,6 +118,9 @@ class ControllerInputsViewController: UIViewController
{ {
self.prepareCallouts() self.prepareCallouts()
} }
// controllerView must be first responder to receive keyboard presses.
self.gameViewController.controllerView.becomeFirstResponder()
} }
} }
@ -183,6 +213,10 @@ private extension ControllerInputsViewController
listMenuViewController.title = NSLocalizedString("Game System", comment: "") listMenuViewController.title = NSLocalizedString("Game System", comment: "")
let navigationController = UINavigationController(rootViewController: listMenuViewController) let navigationController = UINavigationController(rootViewController: listMenuViewController)
if #available(iOS 13, *)
{
navigationController.navigationBar.scrollEdgeAppearance = navigationController.navigationBar.standardAppearance
}
let popoverMenuController = PopoverMenuController(popoverViewController: navigationController) let popoverMenuController = PopoverMenuController(popoverViewController: navigationController)
self.navigationItem.popoverMenuController = popoverMenuController self.navigationItem.popoverMenuController = popoverMenuController
@ -237,6 +271,8 @@ private extension ControllerInputsViewController
self.actionsMenuViewController.items = items self.actionsMenuViewController.items = items
self.actionsMenuViewController.isVibrancyEnabled = false self.actionsMenuViewController.isVibrancyEnabled = false
self.actionsMenuViewController.collectionView.backgroundColor = nil
} }
func prepareCallouts() func prepareCallouts()
@ -257,6 +293,8 @@ private extension ControllerInputsViewController
{ {
let calloutView = InputCalloutView() let calloutView = InputCalloutView()
calloutView.delegate = self calloutView.delegate = self
calloutView.permittedArrowDirection = .any
calloutView.constrainedInsets = self.view.safeAreaInsets
self.calloutViews[AnyInput(input)] = calloutView self.calloutViews[AnyInput(input)] = calloutView
} }
@ -399,6 +437,7 @@ private extension ControllerInputsViewController
} }
let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
alertController.popoverPresentationController?.barButtonItem = sender
alertController.addAction(.cancel) alertController.addAction(.cancel)
alertController.addAction(UIAlertAction(title: NSLocalizedString("Reset Controls to Defaults", comment: ""), style: .destructive, handler: { (action) in alertController.addAction(UIAlertAction(title: NSLocalizedString("Reset Controls to Defaults", comment: ""), style: .destructive, handler: { (action) in
reset() reset()
@ -414,6 +453,12 @@ extension ControllerInputsViewController: UIGestureRecognizerDelegate
return self.activeCalloutView != nil 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) @IBAction private func handleTapGesture(_ tapGestureRecognizer: UITapGestureRecognizer)
{ {
self.updateActiveCalloutView(with: nil) self.updateActiveCalloutView(with: nil)
@ -528,13 +573,19 @@ extension ControllerInputsViewController: GameControllerReceiver
{ {
func gameController(_ gameController: GameController, didActivate controllerInput: DeltaCore.Input, value: Double) 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 switch gameController
{ {
case self.gameViewController.controllerView: case self.gameViewController.controllerView:
if let calloutView = self.calloutViews[AnyInput(controllerInput)] 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) self.toggle(calloutView)
} }
@ -558,3 +609,15 @@ extension ControllerInputsViewController: SMCalloutViewDelegate
self.toggle(calloutView) 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
}
}
}

View File

@ -15,6 +15,7 @@ extension ControllersSettingsViewController
{ {
private enum Section: Int private enum Section: Int
{ {
case none
case localDevice case localDevice
case externalControllers case externalControllers
case customizeControls case customizeControls
@ -108,9 +109,18 @@ extension ControllersSettingsViewController
switch identifier switch identifier
{ {
case "controllerInputsSegue": 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 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 default: break
} }
@ -131,10 +141,27 @@ private extension ControllersSettingsViewController
{ {
cell.accessoryType = .none cell.accessoryType = .none
cell.detailTextLabel?.text = nil cell.detailTextLabel?.text = nil
cell.textLabel?.textColor = .darkText
if #available(iOS 13.0, *) {
cell.textLabel?.textColor = .label
} else {
cell.textLabel?.textColor = .darkText
}
switch Section(rawValue: indexPath.section)! 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: case .localDevice, .externalControllers:
let controller: GameController let controller: GameController
@ -243,18 +270,20 @@ extension ControllersSettingsViewController
{ {
override func numberOfSections(in tableView: UITableView) -> Int 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 override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int
{ {
switch Section(rawValue: section)! switch Section(rawValue: section)!
{ {
case .none where self.playerIndex == 0: return 0
case .none: return 1
case .localDevice: return 1 case .localDevice: return 1
case .externalControllers: return self.connectedControllers.isEmpty ? 1 : self.connectedControllers.count case .externalControllers: return self.connectedControllers.isEmpty ? 1 : self.connectedControllers.count
case .customizeControls: return 1 case .customizeControls: return 1
@ -280,19 +309,41 @@ extension ControllersSettingsViewController
{ {
switch Section(rawValue: section)! switch Section(rawValue: section)!
{ {
case .none: return nil
case .localDevice: return NSLocalizedString("This Device", comment: "") case .localDevice: return NSLocalizedString("This Device", comment: "")
case .externalControllers: return NSLocalizedString("Game Controllers", comment: "") case .externalControllers: return NSLocalizedString("Game Controllers", comment: "")
case .customizeControls: return nil 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 extension ControllersSettingsViewController
{ {
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath)
{ {
let previousGameController = self.gameController
switch Section(rawValue: indexPath.section)! switch Section(rawValue: indexPath.section)!
{ {
case .none: self.gameController = nil
case .localDevice: self.gameController = self.localDeviceController case .localDevice: self.gameController = self.localDeviceController
case .externalControllers where self.connectedControllers.isEmpty: return case .externalControllers where self.connectedControllers.isEmpty: return
case .externalControllers: self.gameController = self.connectedControllers[indexPath.row] case .externalControllers: self.gameController = self.connectedControllers[indexPath.row]
@ -305,7 +356,7 @@ extension ControllersSettingsViewController
let previousIndexPath: IndexPath? let previousIndexPath: IndexPath?
if let gameController = self.gameController if let gameController = previousGameController
{ {
if gameController == self.localDeviceController if gameController == self.localDeviceController
{ {
@ -322,7 +373,7 @@ extension ControllersSettingsViewController
} }
else else
{ {
previousIndexPath = nil previousIndexPath = IndexPath(row: 0, section: Section.none.rawValue)
} }
self.tableView.beginUpdates() self.tableView.beginUpdates()

View File

@ -9,6 +9,7 @@
import UIKit import UIKit
import SafariServices import SafariServices
import MobileCoreServices import MobileCoreServices
import CryptoKit
import DeltaCore import DeltaCore
import MelonDSDeltaCore import MelonDSDeltaCore
@ -22,30 +23,93 @@ private extension MelonDSCoreSettingsViewController
enum Section: Int enum Section: Int
{ {
case general case general
case bios case airPlay
case performance
case dsBIOS
case dsiBIOS
case changeCore case changeCore
} }
enum BIOS: Int enum AirPlayRow: Int, CaseIterable
{ {
case bios7 case topScreenOnly
case bios9 case layoutHorizontally
case firmware }
var fileURL: URL { @available(iOS 13, *)
enum BIOSError: LocalizedError
{
case unknownSize(URL)
case incorrectHash(URL, hash: String, expectedHash: String)
case unsupportedHash(URL, hash: String)
case incorrectSize(URL, size: Int, validSizes: Set<ClosedRange<Measurement<UnitInformationStorage>>>)
private static let byteFormatter: ByteCountFormatter = {
let formatter = ByteCountFormatter()
formatter.includesActualByteCount = true
formatter.countStyle = .binary
return formatter
}()
var errorDescription: String? {
switch self switch self
{ {
case .bios7: return MelonDSEmulatorBridge.shared.bios7URL case .unknownSize(let fileURL):
case .bios9: return MelonDSEmulatorBridge.shared.bios9URL return String(format: NSLocalizedString("%@s size could not be determined.", comment: ""), fileURL.lastPathComponent)
case .firmware: return MelonDSEmulatorBridge.shared.firmwareURL
case .incorrectHash(let fileURL, let md5Hash, let expectedHash):
return String(format: NSLocalizedString("%@s checksum does not match the expected checksum.\n\nChecksum:\n%@\n\nExpected:\n%@", comment: ""), fileURL.lastPathComponent, md5Hash, expectedHash)
case .unsupportedHash(let fileURL, let md5Hash):
return String(format: NSLocalizedString("%@ is not compatible with this version of Delta.\n\nChecksum:\n%@", comment: ""), fileURL.lastPathComponent, md5Hash)
case .incorrectSize(let fileURL, let size, let validSizes):
let actualSize = BIOSError.byteFormatter.string(fromByteCount: Int64(size))
if let range = validSizes.first, validSizes.count == 1
{
if range.lowerBound == range.upperBound
{
// Single value
let expectedSize = BIOSError.byteFormatter.string(fromByteCount: Int64(range.lowerBound.converted(to: .bytes).value))
return String(format: NSLocalizedString("%@ is %@, but expected size is %@.", comment: ""), fileURL.lastPathComponent, actualSize, expectedSize)
}
else
{
// Range
BIOSError.byteFormatter.includesActualByteCount = false
defer { BIOSError.byteFormatter.includesActualByteCount = true }
let lowerBound = BIOSError.byteFormatter.string(fromByteCount: Int64(range.lowerBound.converted(to: .bytes).value))
let upperBound = BIOSError.byteFormatter.string(fromByteCount: Int64(range.upperBound.converted(to: .bytes).value))
return String(format: NSLocalizedString("%@ is %@, but expected size is between %@ and %@.", comment: ""), fileURL.lastPathComponent, actualSize, lowerBound, upperBound)
}
}
else
{
var description = String(format: NSLocalizedString("%@ is %@, but expected sizes are:", comment: ""), fileURL.lastPathComponent, actualSize) + "\n"
let sortedRanges = validSizes.sorted(by: { $0.lowerBound < $1.lowerBound })
for range in sortedRanges
{
// Assume BIOS with multiple valid file sizes don't use (>1 count) ranges.
description += "\n" + BIOSError.byteFormatter.string(fromByteCount: Int64(range.lowerBound.converted(to: .bytes).value))
}
return description
}
} }
} }
var recoverySuggestion: String? {
return NSLocalizedString("Please choose a different BIOS file.", comment: "")
}
} }
} }
class MelonDSCoreSettingsViewController: UITableViewController class MelonDSCoreSettingsViewController: UITableViewController
{ {
private var importDestinationURL: URL? private var importingBIOS: SystemBIOS?
override func viewDidLoad() override func viewDidLoad()
{ {
@ -81,6 +145,43 @@ class MelonDSCoreSettingsViewController: UITableViewController
} }
} }
private extension MelonDSCoreSettingsViewController
{
func isSectionHidden(_ section: Section) -> Bool
{
#if BETA
let isBeta = true
#else
let isBeta = false
#endif
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
case .dsiBIOS where Settings.preferredCore(for: .ds) == DS.core || !isBeta:
// Using DeSmuME core, which doesn't require BIOS,
// or using public Delta version, which doesn't support DSi (yet).
return true
case .changeCore where !isBeta:
// Using public Delta version, which only supports melonDS core.
return true
default: return false
}
}
}
private extension MelonDSCoreSettingsViewController private extension MelonDSCoreSettingsViewController
{ {
func openMetadataURL(for key: DeltaCoreMetadata.Key) func openMetadataURL(for key: DeltaCoreMetadata.Key)
@ -102,9 +203,9 @@ private extension MelonDSCoreSettingsViewController
self.present(safariViewController, animated: true, completion: nil) self.present(safariViewController, animated: true, completion: nil)
} }
func locate(_ bios: BIOS) func locate<BIOS: SystemBIOS>(_ bios: BIOS)
{ {
self.importDestinationURL = bios.fileURL self.importingBIOS = bios
var supportedTypes = [kUTTypeItem as String, kUTTypeContent as String, "com.apple.macbinary-archive" /* System UTI for .bin */] var supportedTypes = [kUTTypeItem as String, kUTTypeContent as String, "com.apple.macbinary-archive" /* System UTI for .bin */]
@ -166,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) @objc func willEnterForeground(_ notification: Notification)
{ {
self.tableView.reloadData() self.tableView.reloadData()
@ -174,6 +306,18 @@ private extension MelonDSCoreSettingsViewController
extension MelonDSCoreSettingsViewController extension MelonDSCoreSettingsViewController
{ {
override func tableView(_ tableView: UITableView, numberOfRowsInSection sectionIndex: Int) -> Int
{
let section = Section(rawValue: sectionIndex)!
switch section
{
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)
}
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell
{ {
let cell = super.tableView(tableView, cellForRowAt: indexPath) let cell = super.tableView(tableView, cellForRowAt: indexPath)
@ -200,8 +344,40 @@ extension MelonDSCoreSettingsViewController
cell.contentView.isHidden = (item == nil) cell.contentView.isHidden = (item == nil)
case .bios: case .airPlay:
let bios = BIOS(rawValue: indexPath.row)! 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]
if FileManager.default.fileExists(atPath: bios.fileURL.path)
{
cell.accessoryType = .checkmark
cell.detailTextLabel?.text = nil
cell.detailTextLabel?.textColor = .gray
}
else
{
cell.accessoryType = .disclosureIndicator
cell.detailTextLabel?.text = NSLocalizedString("Required", comment: "")
cell.detailTextLabel?.textColor = .red
}
cell.selectionStyle = .default
case .dsiBIOS:
let bios = DSiBIOS.allCases[indexPath.row]
if FileManager.default.fileExists(atPath: bios.fileURL.path) if FileManager.default.fileExists(atPath: bios.fileURL.path)
{ {
@ -250,39 +426,51 @@ extension MelonDSCoreSettingsViewController
let key = DeltaCoreMetadata.Key.allCases[indexPath.row] let key = DeltaCoreMetadata.Key.allCases[indexPath.row]
self.openMetadataURL(for: key) self.openMetadataURL(for: key)
case .bios: case .dsBIOS:
let bios = BIOS(rawValue: indexPath.row)! let bios = DSBIOS.allCases[indexPath.row]
self.locate(bios)
case .dsiBIOS:
let bios = DSiBIOS.allCases[indexPath.row]
self.locate(bios) self.locate(bios)
case .changeCore: case .changeCore:
self.changeCore() self.changeCore()
case .airPlay, .performance: break
} }
} }
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String?
{ {
switch Section(rawValue: section)! let section = Section(rawValue: section)!
if isSectionHidden(section)
{ {
case .bios: return nil
guard Settings.preferredCore(for: .ds) == MelonDS.core else { return nil } }
else
default: break {
return super.tableView(tableView, titleForHeaderInSection: section.rawValue)
} }
return super.tableView(tableView, titleForHeaderInSection: section)
} }
override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? override func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String?
{ {
switch Section(rawValue: section)! let section = Section(rawValue: section)!
switch section
{ {
case .bios: case _ where isSectionHidden(section): return nil
guard Settings.preferredCore(for: .ds) == MelonDS.core else { return nil } case .airPlay:
switch (Settings.features.dsAirPlay.topScreenOnly, Settings.features.dsAirPlay.layoutAxis)
{
case (true, _): return NSLocalizedString("When AirPlaying DS games, only the top screen will appear on the external display.", comment: "")
case (false, .vertical): return NSLocalizedString("When AirPlaying DS games, both screens will be stacked vertically on the external display.", comment: "")
case (false, .horizontal): return NSLocalizedString("When AirPlaying DS games, both screens will be placed side-by-side on the external display.", comment: "")
}
default: break default: return super.tableView(tableView, titleForFooterInSection: section.rawValue)
} }
return super.tableView(tableView, titleForFooterInSection: section)
} }
override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat
@ -293,9 +481,6 @@ extension MelonDSCoreSettingsViewController
let key = DeltaCoreMetadata.Key.allCases[indexPath.row] let key = DeltaCoreMetadata.Key.allCases[indexPath.row]
guard Settings.preferredCore(for: .ds)?.metadata?[key] != nil else { return 0 } guard Settings.preferredCore(for: .ds)?.metadata?[key] != nil else { return 0 }
case .bios:
guard Settings.preferredCore(for: .ds) == MelonDS.core else { return 0 }
default: break default: break
} }
@ -304,28 +489,30 @@ extension MelonDSCoreSettingsViewController
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat
{ {
switch Section(rawValue: section)! let section = Section(rawValue: section)!
if isSectionHidden(section)
{ {
case .bios: return 1
guard Settings.preferredCore(for: .ds) == MelonDS.core else { return 1 } }
else
default: break {
return super.tableView(tableView, heightForHeaderInSection: section.rawValue)
} }
return super.tableView(tableView, heightForHeaderInSection: section)
} }
override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat
{ {
switch Section(rawValue: section)! let section = Section(rawValue: section)!
if isSectionHidden(section)
{ {
case .bios: return 1
guard Settings.preferredCore(for: .ds) == MelonDS.core else { return 1 } }
else
default: break {
return super.tableView(tableView, heightForFooterInSection: section.rawValue)
} }
return super.tableView(tableView, heightForFooterInSection: section)
} }
} }
@ -333,28 +520,64 @@ extension MelonDSCoreSettingsViewController: UIDocumentPickerDelegate
{ {
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController)
{ {
self.importDestinationURL = nil self.importingBIOS = nil
self.tableView.reloadData() // Reloading index path causes cell to disappear... self.tableView.reloadData() // Reloading index path causes cell to disappear...
} }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL])
{ {
defer { defer {
self.importDestinationURL = nil self.importingBIOS = nil
self.tableView.reloadData() // Reloading index path causes cell to disappear... self.tableView.reloadData() // Reloading index path causes cell to disappear...
} }
guard let fileURL = urls.first, let destinationURL = self.importDestinationURL else { return } guard let fileURL = urls.first, let bios = self.importingBIOS else { return }
defer { try? FileManager.default.removeItem(at: fileURL) }
do do
{ {
try FileManager.default.copyItem(at: fileURL, to: destinationURL, shouldReplace: true) if #available(iOS 13.0, *)
} {
catch // Validate file size first (since that's easiest for users to understand).
{
let title = String(format: NSLocalizedString("Could not import %@.", comment: ""), fileURL.lastPathComponent)
let alertController = UIAlertController(title: title, message: error.localizedDescription, preferredStyle: .alert) let attributes = try FileManager.default.attributesOfItem(atPath: fileURL.path)
guard let fileSize = attributes[.size] as? Int else { throw BIOSError.unknownSize(fileURL) }
let measurement = Measurement<UnitInformationStorage>(value: Double(fileSize), unit: .bytes)
guard bios.validFileSizes.contains(where: { $0.contains(measurement) }) else { throw BIOSError.incorrectSize(fileURL, size: fileSize, validSizes: bios.validFileSizes) }
if bios.expectedMD5Hash != nil || !bios.unsupportedMD5Hashes.isEmpty
{
// Only calculate hash if we need to.
let data = try Data(contentsOf: fileURL)
let md5Hash = Insecure.MD5.hash(data: data)
let hashString = md5Hash.compactMap { String(format: "%02x", $0) }.joined()
if let expectedMD5Hash = bios.expectedMD5Hash
{
guard hashString == expectedMD5Hash else { throw BIOSError.incorrectHash(fileURL, hash: hashString, expectedHash: expectedMD5Hash) }
}
guard !bios.unsupportedMD5Hashes.contains(hashString) else { throw BIOSError.unsupportedHash(fileURL, hash: hashString) }
}
}
try FileManager.default.copyItem(at: fileURL, to: bios.fileURL, shouldReplace: true)
}
catch let error as NSError
{
let title = String(format: NSLocalizedString("Could not import %@.", comment: ""), bios.filename)
var message = error.localizedDescription
if let recoverySuggestion = error.localizedRecoverySuggestion
{
message += "\n\n" + recoverySuggestion
}
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
alertController.addAction(.ok) alertController.addAction(.ok)
self.present(alertController, animated: true, completion: nil) self.present(alertController, animated: true, completion: nil)
} }

Some files were not shown because too many files have changed in this diff Show More