From d9d3ac113dd00218312e064d3e404502a0123c46 Mon Sep 17 00:00:00 2001 From: Eduard Gavrilovici Date: Mon, 6 Jul 2026 15:25:40 +0300 Subject: [PATCH] Harden Epilogue Operator device support --- Delta.xcodeproj/project.pbxproj | 16 ++++ Delta/Extensions/AppDelegate+Operator.swift | 4 +- ...ameCollectionViewController+Operator.swift | 75 +++++++++++++++++++ .../GamesViewController+Operator.swift | 70 ++++++++++++++++- .../GameCollectionViewController.swift | 1 + .../Game Selection/GamesViewController.swift | 8 +- External/DeltaOperator | 2 +- 7 files changed, 168 insertions(+), 8 deletions(-) diff --git a/Delta.xcodeproj/project.pbxproj b/Delta.xcodeproj/project.pbxproj index 28ebc11e..0a3f25e4 100644 --- a/Delta.xcodeproj/project.pbxproj +++ b/Delta.xcodeproj/project.pbxproj @@ -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 */; }; @@ -938,6 +942,10 @@ E6BD34AC2F587BF0001C9D78 /* AchievementGameBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementGameBanner.swift; sourceTree = ""; }; E6EB10AE2F58B1C50001AAD2 /* AchievementNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AchievementNotification.swift; sourceTree = ""; }; FCE7674E5643CDB43BC8ED32 /* DeltaOperatorWritebackToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaOperatorWritebackToast.swift; sourceTree = ""; }; + DE17A0A57E000000000000A1 /* DeltaOperatorToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaOperatorToast.swift; sourceTree = ""; }; + DE17A0A57E00000000000201 /* DeltaOperatorSaveReadToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaOperatorSaveReadToast.swift; sourceTree = ""; }; + DE17A0A57E00000000000301 /* OperatorLaunchCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperatorLaunchCoordinator.swift; sourceTree = ""; }; + DE17A0A57E000000000000E1 /* DeltaOperatorImportToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeltaOperatorImportToast.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -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 */, ); @@ -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 */, diff --git a/Delta/Extensions/AppDelegate+Operator.swift b/Delta/Extensions/AppDelegate+Operator.swift index 192990cd..2c3d6fec 100644 --- a/Delta/Extensions/AppDelegate+Operator.swift +++ b/Delta/Extensions/AppDelegate+Operator.swift @@ -26,7 +26,9 @@ extension AppDelegate func handleOperatorDatabaseReady() { guard ExperimentalFeatures.shared.operatorDevice.isEnabled else { return } - self.operatorFacade.onDatabaseReady() + DispatchQueue.main.async { + self.operatorFacade.onDatabaseReady() + } } } diff --git a/Delta/Extensions/GameCollectionViewController+Operator.swift b/Delta/Extensions/GameCollectionViewController+Operator.swift index 1864b8b8..eb86d2c9 100644 --- a/Delta/Extensions/GameCollectionViewController+Operator.swift +++ b/Delta/Extensions/GameCollectionViewController+Operator.swift @@ -6,6 +6,7 @@ // Copyright © 2026 Epilogue. All rights reserved. // +import Combine import UIKit import ObjectiveC.runtime @@ -13,6 +14,9 @@ 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 { @@ -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 @@ -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) + } + } } diff --git a/Delta/Extensions/GamesViewController+Operator.swift b/Delta/Extensions/GamesViewController+Operator.swift index 92587abc..dee22377 100644 --- a/Delta/Extensions/GamesViewController+Operator.swift +++ b/Delta/Extensions/GamesViewController+Operator.swift @@ -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 { @@ -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 } } @@ -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 + 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 + } + } } diff --git a/Delta/Game Selection/GameCollectionViewController.swift b/Delta/Game Selection/GameCollectionViewController.swift index 150b5921..55e12264 100644 --- a/Delta/Game Selection/GameCollectionViewController.swift +++ b/Delta/Game Selection/GameCollectionViewController.swift @@ -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, *), diff --git a/Delta/Game Selection/GamesViewController.swift b/Delta/Game Selection/GamesViewController.swift index b9774231..7e45a353 100644 --- a/Delta/Game Selection/GamesViewController.swift +++ b/Delta/Game Selection/GamesViewController.swift @@ -400,7 +400,7 @@ private extension GamesViewController } // MARK: - Helper Methods - -private extension GamesViewController +internal extension GamesViewController { func viewControllerForIndex(_ index: Int) -> GameCollectionViewController? { @@ -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 @@ -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) } diff --git a/External/DeltaOperator b/External/DeltaOperator index 1fe5f5f2..7d589c1d 160000 --- a/External/DeltaOperator +++ b/External/DeltaOperator @@ -1 +1 @@ -Subproject commit 1fe5f5f2304c2440c18f74274eac5f69624d7aaf +Subproject commit 7d589c1d1014c0389bfa59b3487e0011fb677a5e