Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Delta.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
4F501EDE15C52E1E6230DB9E /* DeltaOperatorGameObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4327597BCB33E196164D3BB6 /* DeltaOperatorGameObserver.swift */; };
5FFDBC83E8777EB11777803A /* GamesViewController+Operator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B6FDC5A03AD5693BEFFE87C /* GamesViewController+Operator.swift */; };
6FF8B1728BDBAF892B397F5E /* DeltaOperatorWritebackToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = FCE7674E5643CDB43BC8ED32 /* DeltaOperatorWritebackToast.swift */; };
DE17A0A57E000000000000B2 /* DeltaOperatorToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE17A0A57E000000000000A1 /* DeltaOperatorToast.swift */; };
DE17A0A57E00000000000202 /* DeltaOperatorSaveReadToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE17A0A57E00000000000201 /* DeltaOperatorSaveReadToast.swift */; };
DE17A0A57E00000000000302 /* OperatorLaunchCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE17A0A57E00000000000301 /* OperatorLaunchCoordinator.swift */; };
DE17A0A57E000000000000F2 /* DeltaOperatorImportToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE17A0A57E000000000000E1 /* DeltaOperatorImportToast.swift */; };
70FF20879DA0DCEE53D8CAED /* OperatorKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 796FA895EF62DF6A509DCA55 /* OperatorKit.xcframework */; };
823D998F2E566EE40025F4CD /* GBCColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = 823D998E2E566EE40025F4CD /* GBCColorPalette.swift */; };
8279ED042E53BA4F008995FA /* GBACoreSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8279ED032E53BA4F008995FA /* GBACoreSettingsView.swift */; };
Expand Down Expand Up @@ -938,6 +942,10 @@
E6BD34AC2F587BF0001C9D78 /* AchievementGameBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementGameBanner.swift; sourceTree = "<group>"; };
E6EB10AE2F58B1C50001AAD2 /* AchievementNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementNotification.swift; sourceTree = "<group>"; };
FCE7674E5643CDB43BC8ED32 /* DeltaOperatorWritebackToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaOperatorWritebackToast.swift; sourceTree = "<group>"; };
DE17A0A57E000000000000A1 /* DeltaOperatorToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaOperatorToast.swift; sourceTree = "<group>"; };
DE17A0A57E00000000000201 /* DeltaOperatorSaveReadToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaOperatorSaveReadToast.swift; sourceTree = "<group>"; };
DE17A0A57E00000000000301 /* OperatorLaunchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatorLaunchCoordinator.swift; sourceTree = "<group>"; };
DE17A0A57E000000000000E1 /* DeltaOperatorImportToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaOperatorImportToast.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -998,7 +1006,11 @@
4327597BCB33E196164D3BB6 /* DeltaOperatorGameObserver.swift */,
DCBF2F877CA6072880A54F35 /* DeltaOperatorUtils.swift */,
FCE7674E5643CDB43BC8ED32 /* DeltaOperatorWritebackToast.swift */,
DE17A0A57E000000000000A1 /* DeltaOperatorToast.swift */,
DE17A0A57E000000000000E1 /* DeltaOperatorImportToast.swift */,
DE17A0A57E00000000000201 /* DeltaOperatorSaveReadToast.swift */,
0654CFCA3D2CB4D35CC99F89 /* OperatorSlotDataSource.swift */,
DE17A0A57E00000000000301 /* OperatorLaunchCoordinator.swift */,
7C31F852121CBEF08181BD23 /* OperatorStatusView.swift */,
C1C8C13CE90324CBBA76BC3E /* OperatorUICoordinators.swift */,
);
Expand Down Expand Up @@ -2698,6 +2710,10 @@
B8BF6056C00AB0507A06D39E /* OperatorStatusView.swift in Sources */,
28B653353E8323FA65C54A42 /* OperatorUICoordinators.swift in Sources */,
6FF8B1728BDBAF892B397F5E /* DeltaOperatorWritebackToast.swift in Sources */,
DE17A0A57E000000000000B2 /* DeltaOperatorToast.swift in Sources */,
DE17A0A57E000000000000F2 /* DeltaOperatorImportToast.swift in Sources */,
DE17A0A57E00000000000202 /* DeltaOperatorSaveReadToast.swift in Sources */,
DE17A0A57E00000000000302 /* OperatorLaunchCoordinator.swift in Sources */,
18FC614F90FE76140AAECE67 /* DeltaOperatorUtils.swift in Sources */,
89DAEB1EDE294B3A7C857765 /* DeltaOperatorFacade.swift in Sources */,
CA0001192E60000100000019 /* CoresListView.swift in Sources */,
Expand Down
4 changes: 3 additions & 1 deletion Delta/Extensions/AppDelegate+Operator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ extension AppDelegate
func handleOperatorDatabaseReady()
{
guard ExperimentalFeatures.shared.operatorDevice.isEnabled else { return }
self.operatorFacade.onDatabaseReady()
DispatchQueue.main.async {
self.operatorFacade.onDatabaseReady()
}
}
}

Expand Down
75 changes: 75 additions & 0 deletions Delta/Extensions/GameCollectionViewController+Operator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@
// Copyright © 2026 Epilogue. All rights reserved.
//

import Combine
import UIKit
import ObjectiveC.runtime

import DeltaFeatures
import OperatorKit

private var operatorCoordinatorKey: UInt8 = 0
private var operatorLaunchCoordinatorKey: UInt8 = 0
private var pendingLaunchGameKey: UInt8 = 0
private var slotStateCancellableKey: UInt8 = 0

extension GameCollectionViewController
{
Expand Down Expand Up @@ -58,6 +62,30 @@ extension GameCollectionViewController
{
return OperatorKitController.shared.importedGameIdentifier == game.identifier
}

func isOperatorImportInProgress(for game: Game) -> Bool
{
let controller = OperatorKitController.shared
guard controller.importedGameIdentifier == game.identifier else { return false }
if case .imported = controller.slotState { return false }
return true
}

func deferLaunchIfOperatorImporting(_ game: Game) -> Bool
{
guard ExperimentalFeatures.shared.operatorDevice.isEnabled else { return false }

if self.isOperatorImportInProgress(for: game)
{
self.pendingOperatorLaunchGame = game
self.startOperatorLaunchObserver()
return true
}
self.pendingOperatorLaunchGame = nil

guard self.isOperatorImportedGame(game) else { return false }
return self.operatorLaunchCoordinator.deferLaunchIfSaveUnverified(of: game)
}
}

private extension GameCollectionViewController
Expand All @@ -74,4 +102,51 @@ private extension GameCollectionViewController
return coordinator
}
}

var pendingOperatorLaunchGame: Game? {
get { objc_getAssociatedObject(self, &pendingLaunchGameKey) as? Game }
set { objc_setAssociatedObject(self, &pendingLaunchGameKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}

var operatorLaunchCancellable: AnyCancellable? {
get { objc_getAssociatedObject(self, &slotStateCancellableKey) as? AnyCancellable }
set { objc_setAssociatedObject(self, &slotStateCancellableKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}

var operatorLaunchCoordinator: OperatorLaunchCoordinator {
get {
if let coordinator = objc_getAssociatedObject(self, &operatorLaunchCoordinatorKey) as? OperatorLaunchCoordinator
{
return coordinator
}

let coordinator = OperatorLaunchCoordinator()
coordinator.start(collectionViewController: self)
objc_setAssociatedObject(self, &operatorLaunchCoordinatorKey, coordinator, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
return coordinator
}
}

func startOperatorLaunchObserver()
{
guard self.operatorLaunchCancellable == nil else { return }
self.operatorLaunchCancellable = OperatorKitController.shared.$slotState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in self?.launchPendingOperatorGameIfReady(state) }
}

func launchPendingOperatorGameIfReady(_ state: OperatorSlotState)
{
guard case .imported(let id) = state,
let game = self.pendingOperatorLaunchGame, game.identifier == id
else { return }
self.pendingOperatorLaunchGame = nil

DispatchQueue.main.async { [weak self] in
guard let self, let collectionView = self.collectionView,
let indexPath = self.dataSource.fetchedResultsController.indexPath(forObject: game)
else { return }
self.collectionView(collectionView, didSelectItemAt: indexPath)
}
}
}
70 changes: 68 additions & 2 deletions Delta/Extensions/GamesViewController+Operator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@
// Copyright © 2026 Epilogue. All rights reserved.
//

import Combine
import CoreData
import UIKit
import ObjectiveC.runtime

import DeltaCore
import DeltaFeatures
import OperatorKit

private var operatorOverlayKey: UInt8 = 0
private var operatorSlotObserverKey: UInt8 = 0

extension GamesViewController
{
Expand All @@ -25,13 +30,21 @@ extension GamesViewController
func configureOperatorOverlay(placeholderStackView: UIStackView)
{
guard ExperimentalFeatures.shared.operatorDevice.isEnabled else { return }
self.startOperatorImportObserver()
self.operatorOverlay.install(in: self.view, placeholderStackView: placeholderStackView)
}

func updateOperatorPlaceholderVisibility(sectionCount: Int)
func updateOperatorPlaceholderVisibility(sectionCount: Int, isLibraryHidden: Bool)
{
guard ExperimentalFeatures.shared.operatorDevice.isEnabled else { return }
self.operatorOverlay.isPlaceholderVisible = (sectionCount == 0)
self.operatorOverlay.isPlaceholderVisible = (sectionCount == 0) || (self.isOperatorImportTransferring && isLibraryHidden)
}

var isOperatorImportTransferring: Bool
{
guard ExperimentalFeatures.shared.operatorDevice.isEnabled else { return false }
if case .transferring = OperatorKitController.shared.slotState { return true }
return false
}
}

Expand All @@ -49,4 +62,57 @@ private extension GamesViewController
return overlay
}
}

var operatorSlotCancellable: AnyCancellable? {
get { objc_getAssociatedObject(self, &operatorSlotObserverKey) as? AnyCancellable }
set { objc_setAssociatedObject(self, &operatorSlotObserverKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
}

func startOperatorImportObserver()
{
guard self.operatorSlotCancellable == nil else { return }
self.operatorSlotCancellable = OperatorKitController.shared.$slotState
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard case .imported = state else { return }
self?.updateSections(animated: false)
self?.switchToImportedOperatorCollection()
}
}

func switchToImportedOperatorCollection()
{
guard let signature = OperatorKitController.shared.publishedSignature, !signature.isEmpty,
let identifier = GameType(fileExtension: signature.romExtension)?.rawValue,
let pageViewController = self.children.first(where: { $0 is UIPageViewController }) as? UIPageViewController
else { return }

let currentViewController = pageViewController.viewControllers?.first as? GameCollectionViewController
guard currentViewController?.gameCollection?.identifier != identifier else { return }

let fetchRequest = GameCollection.fetchRequest() as NSFetchRequest<GameCollection>
let collectionCount = (try? DatabaseManager.shared.viewContext.count(for: fetchRequest)) ?? 0

var target: (index: Int, viewController: GameCollectionViewController)?
for index in 0..<(collectionCount + 2)
{
guard let viewController = self.viewControllerForIndex(index) else { break }
guard viewController.gameCollection?.identifier == identifier else { continue }
target = (index, viewController)
break
}
guard let target else { return }

let currentIndex = currentViewController.flatMap { self.indexForViewController($0) } ?? 0
let direction: UIPageViewController.NavigationDirection = target.index > currentIndex ? .forward : .reverse

pageViewController.setViewControllers([target.viewController], direction: direction, animated: true) { [weak self] _ in
self?.updateSections(animated: false)
}
self.title = target.viewController.title
if let collection = target.viewController.gameCollection
{
Settings.previousGameCollection = collection
}
}
}
1 change: 1 addition & 0 deletions Delta/Game Selection/GameCollectionViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,7 @@ extension GameCollectionViewController
// Operator: ignore taps on device status cell
guard !self.isOperatorSlotIndexPath(indexPath) else { return }
guard self.gameCollection?.identifier != GameType.unknown.rawValue else { return }
guard !self.deferLaunchIfOperatorImporting(self.dataSource.item(at: indexPath)) else { return }

// Check for iOS 26, setting toggled on, and stage manager enabled, then open game in new window
if #available(iOS 26, *),
Expand Down
8 changes: 4 additions & 4 deletions Delta/Game Selection/GamesViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ private extension GamesViewController
}

// MARK: - Helper Methods -
private extension GamesViewController
internal extension GamesViewController
{
func viewControllerForIndex(_ index: Int) -> GameCollectionViewController?
{
Expand Down Expand Up @@ -476,12 +476,12 @@ private extension GamesViewController
self.pageControl.setHidden(sections < 2, animated: false)

// Operator: show or hide cartridge status on empty placeholder
self.updateOperatorPlaceholderVisibility(sectionCount: sections)
self.updateOperatorPlaceholderVisibility(sectionCount: sections, isLibraryHidden: self.pageViewController.view.isHidden)

if sections > 0
{
// Reset page view controller if currently hidden or current child should view controller no longer exists
if self.pageViewController.view.isHidden || resetPageViewController
if (self.pageViewController.view.isHidden || resetPageViewController), !self.isOperatorImportTransferring
{
let index = min(1, self.pages.count - 1) // Recents page, or first game system

Expand All @@ -497,7 +497,7 @@ private extension GamesViewController
self.pageControl.model.currentPage = index
}
}
else
else if !self.isOperatorImportTransferring
{
self.pageViewController.setViewControllers(self.pageViewController.viewControllers, direction: .forward, animated: false, completion: nil)
}
Expand Down
2 changes: 1 addition & 1 deletion External/DeltaOperator
Submodule DeltaOperator updated 26 files
+ − Frameworks/OperatorKit.xcframework/ios-arm64/OperatorKit.framework/Info.plist
+78 −7 ...work/ios-arm64/OperatorKit.framework/Modules/OperatorKit.swiftmodule/arm64-apple-ios.package.swiftinterface
+78 −7 ...work/ios-arm64/OperatorKit.framework/Modules/OperatorKit.swiftmodule/arm64-apple-ios.private.swiftinterface
+78 −7 ....xcframework/ios-arm64/OperatorKit.framework/Modules/OperatorKit.swiftmodule/arm64-apple-ios.swiftinterface
+ − ...Kit.xcframework/ios-arm64/OperatorKit.framework/Modules/OperatorKit.swiftmodule/arm64-apple-ios.swiftmodule
+ − Frameworks/OperatorKit.xcframework/ios-arm64/OperatorKit.framework/OperatorKit
+ − Frameworks/OperatorKit.xcframework/ios-x86_64_arm64-simulator/OperatorKit.framework/Info.plist
+78 −7 ...ator/OperatorKit.framework/Modules/OperatorKit.swiftmodule/arm64-apple-ios-simulator.package.swiftinterface
+78 −7 ...ator/OperatorKit.framework/Modules/OperatorKit.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface
+78 −7 ...64-simulator/OperatorKit.framework/Modules/OperatorKit.swiftmodule/arm64-apple-ios-simulator.swiftinterface
+ − ...arm64-simulator/OperatorKit.framework/Modules/OperatorKit.swiftmodule/arm64-apple-ios-simulator.swiftmodule
+78 −7 ...tor/OperatorKit.framework/Modules/OperatorKit.swiftmodule/x86_64-apple-ios-simulator.package.swiftinterface
+78 −7 ...tor/OperatorKit.framework/Modules/OperatorKit.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface
+78 −7 ...4-simulator/OperatorKit.framework/Modules/OperatorKit.swiftmodule/x86_64-apple-ios-simulator.swiftinterface
+ − ...rm64-simulator/OperatorKit.framework/Modules/OperatorKit.swiftmodule/x86_64-apple-ios-simulator.swiftmodule
+ − Frameworks/OperatorKit.xcframework/ios-x86_64_arm64-simulator/OperatorKit.framework/OperatorKit
+14 −2 Sources/DeltaOperatorDelegate.swift
+74 −37 Sources/DeltaOperatorGameObserver.swift
+69 −0 Sources/DeltaOperatorImportToast.swift
+64 −0 Sources/DeltaOperatorSaveReadToast.swift
+65 −0 Sources/DeltaOperatorToast.swift
+32 −50 Sources/DeltaOperatorWritebackToast.swift
+92 −0 Sources/OperatorLaunchCoordinator.swift
+15 −1 Sources/OperatorSlotDataSource.swift
+4 −2 Sources/OperatorStatusView.swift
+28 −101 Sources/OperatorUICoordinators.swift