diff --git a/native-views/react-native-native-overlay/docs/SPEC.md b/native-views/react-native-native-overlay/docs/SPEC.md index 18286f536..e4ad23bbf 100644 --- a/native-views/react-native-native-overlay/docs/SPEC.md +++ b/native-views/react-native-native-overlay/docs/SPEC.md @@ -347,16 +347,50 @@ not change React ownership. Public APIs, Android/Web, and page scope are unchang first responder; passive toast/debug entries do not win solely by level. Keyboard restoration may finish after dismissal completion; callers/tests requiring keyboard geometry must observe the new keyboard readiness event. -- Removing an entry while the app window has an interactive UIKit presentation +- Delivered-touch retirement extension (Implemented; thirty native checks and + four focused local iOS 27.0 App cases passed): + before showing a global entry, install a passive touch observer on its real + app window and on the entry window. Active and retired entries of that real + host share a touch lifetime, including touches delivered to a lower entry or + the exiting window itself. Other real app windows remain independent, even + within the same scene. Each observer tracks delivered touch identities, never + retains UIEvent objects, never cancels or delays touches, and neither prevents + nor can be prevented by another recognizer. It stays possible until the last + tracked touch ends or cancels, then fails and resets without recognizing. + It accepts concurrent touch types with `requiresExclusiveTouchType = false` + and preserves UIKit's default allowed touch types. +- Removing an entry while its real app window or any active/retired entry owned + by that real host has delivered active touches or + an interactive UIKit presentation removes that entry from active ordering and restores key ownership immediately, - but retains its exiting window/root/scene until the interaction finishes or - cancels. After UIKit reports the interaction change, cleanup runs on the next - main turn and rechecks the current transition coordinator before releasing + but retains its exiting window/root/scene, without hiding it, until both the + delivered touch set is empty and the interaction finishes or cancels. Touch + completion/cancellation and UIKit interaction changes schedule cleanup on the + next main turn. Cleanup rechecks the complete current touch set and transition + coordinator before releasing that exact window lifetime. A newly interactive transition keeps it retired until its own completion; repeated or late callbacks cannot release a new - entry for the same owner. Scene disconnect releases active and retired windows + entry for the same owner. A new touch beginning after a previous touch ends but + before queued cleanup keeps the window retired. Overlapping multi-touch can + extend retirement without a timeout. When neither condition exists, removal + still releases synchronously. Remove an entry observer only when its window + is released; remove a real app window's observer only when that host has no + active or retired entries. Touch callbacks query current host ownership across + scenes, rather than binding to the scene of first installation. Moving the + same real host to another scene keeps its remaining users protected. + Reassigning an observed window to a different host does not change its + delivered touch context: retain weak prior-host associations until a next-turn + check finds its complete touch set empty and those hosts' retirements released. + A new touch during an idle gap still protects a prior host whose context remains + retired. Touch completion, observer removal and host reassignment wake current + and prior hosts; queued cleanup does not require the source observer to survive. + Scene disconnect removes that scene's records and unregisters only observers with + no remaining users before releasing active and retired windows without notifying an already removed owner again. No fixed timeout controls this handoff. + Key restoration MUST NOT activate a host that has moved to another scene. + Public UIKit observation cannot account for a DOWN not yet delivered to the + app window; acceptance must separately exercise that delivery/teardown boundary. - Every presentation lifetime has a generation. Asynchronous present, dismiss, and animation completions must match the generation, controller/entry identity and applicable phase before changing state or emitting callbacks. @@ -692,10 +726,57 @@ More reopened and a subsequent full drag dismissed normally. Home and Market remained interactive. This checks the main-UI window handoff only; physical devices and the broader runtime matrix remain unverified. +Historical app-touch acceptance for revision `404e87c` (2026-11-13, +local Debug build on an isolated +iOS 27.0 simulator): all eight first-pan-only cases passed, starting from the +Home header or list body at 250, 300, 350 and 400 ms after More close UP. The +250/300 ms cases kept all twenty old-context events routed to the app window; +350/400 ms started after teardown (observed 5–49 ms later) and routed all twenty +new-context events normally. No backing-event-only dispatch occurred. Native +pager logs recorded ended state 3 in every case. List-body accessibility samples +continued moving after UP without another input, confirming inertia. After +waiting for release, the first React control tap opened Home More (header) or +AccountSelectorStack (body), without another pan or tab switch. Per-case main +and background runtime markers remained unchanged. These measured teardown +boundaries do not guarantee observation of a DOWN UIKit has not delivered yet. + +For the same historical revision, two additional selector handoffs exercised +multiple front-card drags and a partial rebound, then front dismissal followed +250 ms later by either a complete +back-selector dismissal or a partial drag canceled into rebound. Both preserved +complete old-context delivery (36 and 16 events respectively), zero backing-only +events, stable main/background markers, and working direct React controls. +The entry was the watch-only account-row More inside AccountSelectorStack, +which uses the same global NativeOverlay UIWindow over a UIKit modal host. +It is an equivalent-host regression, not a replay of the original three-account +HD wallet-level menu. Release/TestFlight binaries, physical devices and other +OS versions remain unverified. + +Review extension acceptance for `c59572b`: thirty focused native checks passed. +A rebuilt local Debug App passed first-only header/250 ms and body/300 ms Home +handoffs with native ended state 3, direct React control actions and stable +main/background runtime markers. Two further cases used an independently mounted +passive global entry over Home More. A lower-entry drag received all sixteen +events in its own context while the upper entry closed; its next React close +action worked. This does not establish use of the upper entry's context by the +lower drag. In the exiting-entry case, UIKit delivered DOWN to the upper window, +restored key ownership to the lower window on close, and delivered UP to the +same upper window/context 657 ms after native close; the next lower React close +action worked. Runtime markers stayed stable throughout these four post-recovery +cases. The earlier nineteen native checks, eight Home cases and two equivalent +selector handoffs above belong to `404e87c`, not a rerun of the review version. +The eleven added native groups cover lower/exiting-entry and cross-window touch +ownership, mixed delivered types, repeated host reassignment, UP/rehost/DOWN and +coordinator-held idle gaps, projected scene ownership/disconnect, and key scope. +Mixed-type tests directly deliver synthetic touches and assert observer flags; +they do not prove physical Pencil input. Scene tests project one real scene into +controlled manager identity buckets using an internal resolver; they do not prove +system multi-scene migration. The default resolver remains `ObjectIdentifier(scene)`. + Automated (P0): - `tests/ios/run.sh` compiles the production WindowManager, SheetController and - Animation into an isolated UIKit test app. Its eleven checks cover bounded/live + Animation into an isolated UIKit test app. Its original nineteen checks cover bounded/live ordering, key restoration eligibility, owning-entry release, injected scene disconnect/reuse with original-window identity and reentrant-registration rejection, conditional original-input focus restoration (including @@ -705,6 +786,14 @@ Automated (P0): retired, and disconnecting a scene with only a retired window. They use a controlled transition coordinator to test manager lifecycle; the real gesture check is the iOS 27.0 simulator run above. + Eight app-touch checks directly drive the production observer's + `touchesBegan`/`touchesEnded`/`touchesCancelled` and reset with synthetic + touch objects. They assert passive gesture flags, touch-only retirement, + multi-touch and per-real-window isolation, next-turn touch-set rechecks, + reset cleanup, conjunction with coordinator ownership, owner/orphan reuse, + and scene-disconnect callback invalidation. They prove manager lifecycle, + not UIKit delivery or scroll inertia; those are covered separately by the + local App runtime checks above, with the stated selector-entry limitation. Sheet checks use `animated: false`; they do not compile the React/Fabric Container or replace Gallery coverage of animated reversal/generation races, real multi-scene/disconnect, keyboard geometry or accessibility. diff --git a/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift b/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift index 0c3584b3b..dfc846408 100644 --- a/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift +++ b/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift @@ -147,6 +147,57 @@ protocol NativeOverlayWindowOwner: AnyObject { func overlaySceneDidDisconnect(window: UIWindow) } +/// Observes delivered window touches without participating in gesture arbitration. +final class NativeOverlayAppTouchObserver: UIGestureRecognizer { + var onTouchesFinished: (() -> Void)? + private var activeTouches = Set() + var hasActiveTouches: Bool { !activeTouches.isEmpty } + + override init(target: Any?, action: Selector?) { + super.init(target: target, action: action) + configure() + } + + private func configure() { + cancelsTouchesInView = false + delaysTouchesBegan = false + delaysTouchesEnded = false + requiresExclusiveTouchType = false + } + + override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool { false } + override func canBePrevented(by preventingGestureRecognizer: UIGestureRecognizer) -> Bool { false } + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + activeTouches.formUnion(touches.map(ObjectIdentifier.init)) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent) {} + + override func touchesEnded(_ touches: Set, with event: UIEvent) { + finish(touches) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent) { + finish(touches) + } + + private func finish(_ touches: Set) { + let hadTouches = hasActiveTouches + activeTouches.subtract(touches.map(ObjectIdentifier.init)) + guard hadTouches, !hasActiveTouches else { return } + state = .failed + onTouchesFinished?() + } + + override func reset() { + super.reset() + let hadTouches = hasActiveTouches + activeTouches.removeAll() + if hadTouches { onTouchesFinished?() } + } +} + final class NativeOverlayWindowManager: NSObject { static let shared = NativeOverlayWindowManager() @@ -185,12 +236,42 @@ final class NativeOverlayWindowManager: NSObject { } } + private final class WeakAppWindow { + weak var window: UIWindow? + init(_ window: UIWindow) { self.window = window } + } + + private final class AppTouchObservation { + weak var window: UIWindow? + weak var appWindow: UIWindow? + let observer: NativeOverlayAppTouchObserver + var previousAppWindows: [ObjectIdentifier: WeakAppWindow] = [:] + + var appWindows: [UIWindow] { + (appWindow.map { [$0] } ?? []) + previousAppWindows.values.compactMap(\.window) + } + + init(window: UIWindow, appWindow: UIWindow, observer: NativeOverlayAppTouchObserver) { + self.window = window + self.appWindow = appWindow + self.observer = observer + } + } + private var scenes: [ObjectIdentifier: [ObjectIdentifier: Entry]] = [:] private var retiredWindows: [ObjectIdentifier: [ObjectIdentifier: RetiredWindow]] = [:] + private var appTouchObservations: [ObjectIdentifier: AppTouchObservation] = [:] private var disconnectingScenes = Set() private var sequence = 0 + private let sceneIdentity: (UIWindowScene) -> ObjectIdentifier - override init() { + override convenience init() { + self.init(sceneIdentity: { ObjectIdentifier($0) }) + } + + // Internal identity projection lets native tests exercise scene ownership with real windows. + init(sceneIdentity: @escaping (UIWindowScene) -> ObjectIdentifier) { + self.sceneIdentity = sceneIdentity super.init() NotificationCenter.default.addObserver( self, selector: #selector(sceneDidDisconnect(_:)), @@ -198,7 +279,13 @@ final class NativeOverlayWindowManager: NSObject { ) } - deinit { NotificationCenter.default.removeObserver(self) } + deinit { + NotificationCenter.default.removeObserver(self) + for observation in appTouchObservations.values { + observation.observer.onTouchesFinished = nil + observation.window?.removeGestureRecognizer(observation.observer) + } + } /// Every owner has an independent root presenter, shared by no other entry. func window(for owner: NativeOverlayWindowOwner, level: NativeOverlayLevel, @@ -208,10 +295,11 @@ final class NativeOverlayWindowManager: NSObject { ((hostWindow as? NativeOverlayPassthroughWindow)?.rootViewController as? NativeOverlayRootViewController)?.appWindow ?? hostWindow guard let scene = appWindow.windowScene, scene.activationState != .unattached else { return nil } - let sceneKey = ObjectIdentifier(scene) + let sceneKey = sceneIdentity(scene) guard !disconnectingScenes.contains(sceneKey) else { return nil } let orphanWindows = (scenes[sceneKey] ?? [:]).values.filter { $0.owner == nil }.map(\.window) orphanWindows.forEach { entryRemoved(from: $0) } + observeTouches(in: appWindow, appWindow: appWindow) let ownerKey = ObjectIdentifier(owner) var entries = scenes[sceneKey] ?? [:] let entry: Entry @@ -234,9 +322,11 @@ final class NativeOverlayWindowManager: NSObject { } entry.appWindow = appWindow (entry.window.rootViewController as? NativeOverlayRootViewController)?.appWindow = appWindow + observeTouches(in: entry.window, appWindow: appWindow) reorder(entries) entry.window.isHidden = false entry.window.rootViewController?.setNeedsStatusBarAppearanceUpdate() + removeUnusedTouchObservers() return entry.window } @@ -268,8 +358,8 @@ final class NativeOverlayWindowManager: NSObject { scenes[sceneKey]?[ownerKey] = nil let remaining = scenes[sceneKey] ?? [:] if remaining.isEmpty { scenes[sceneKey] = nil } - if Self.interactiveCoordinator(in: entry.appWindow) != nil { - // Destroying this window context can strand a touch already routed to the app's interactive sheet. + if hasActiveTouches(in: entry.appWindow) || Self.interactiveCoordinator(in: entry.appWindow) != nil { + // Keep the routing context until this host's delivered touches and UIKit interactions finish. let retirement = RetiredWindow(window: entry.window, appWindow: entry.appWindow) let windowKey = ObjectIdentifier(entry.window) retiredWindows[sceneKey, default: [:]][windowKey] = retirement @@ -277,12 +367,14 @@ final class NativeOverlayWindowManager: NSObject { } else { release(entry.window) } + removeUnusedTouchObservers() reorder(remaining) guard wasKey, let scene, scene.activationState == .foregroundActive || scene.activationState == .foregroundInactive else { return } let next = remaining.values.filter { Self.eligible($0, in: scene) } .max { Self.ordered($0, before: $1) } - (next?.window ?? entry.appWindow)?.makeKey() + let appWindow = entry.appWindow?.windowScene === scene ? entry.appWindow : nil + (next?.window ?? appWindow)?.makeKey() } func restoreFocus(_ responder: UIView, in window: UIWindow) -> Bool { @@ -351,6 +443,92 @@ final class NativeOverlayWindowManager: NSObject { window.windowScene = nil } + private func observeTouches(in window: UIWindow, appWindow: UIWindow) { + let windowKey = ObjectIdentifier(window) + if let observation = appTouchObservations[windowKey] { + if observation.appWindow !== appWindow, let previous = observation.appWindow { + // Reparenting does not change the context of an already delivered or queued touch stream. + if observation.observer.hasActiveTouches || hasRetirements(for: previous) { + observation.previousAppWindows[ObjectIdentifier(previous)] = WeakAppWindow(previous) + } + observation.appWindow = appWindow + scheduleObservationCleanup(observation, windowKey: windowKey) + scheduleRetirementCheck(for: previous) + } else { + observation.appWindow = appWindow + } + return + } + let observer = NativeOverlayAppTouchObserver(target: nil, action: nil) + appTouchObservations[windowKey] = AppTouchObservation( + window: window, appWindow: appWindow, observer: observer) + observer.onTouchesFinished = { [weak self, weak window, weak observer] in + guard let self, let window, let observer, + let observation = self.appTouchObservations[ObjectIdentifier(window)], + observation.observer === observer else { return } + self.scheduleObservationCleanup(observation, windowKey: ObjectIdentifier(window)) + for appWindow in observation.appWindows { self.scheduleRetirementCheck(for: appWindow) } + } + window.addGestureRecognizer(observer) + } + + private func scheduleObservationCleanup(_ observation: AppTouchObservation, windowKey: ObjectIdentifier) { + guard !observation.previousAppWindows.isEmpty else { return } + DispatchQueue.main.async { [weak self, weak observation] in + guard let self, let observation, self.appTouchObservations[windowKey] === observation, + !observation.observer.hasActiveTouches else { return } + self.prunePreviousAppWindows(in: observation) + } + } + + private func prunePreviousAppWindows(in observation: AppTouchObservation) { + guard !observation.observer.hasActiveTouches else { return } + // An idle gap does not retire the old context if another touch or transition still owns it. + observation.previousAppWindows = observation.previousAppWindows.filter { + $0.value.window.map { hasRetirements(for: $0) } ?? false + } + } + + private func hasRetirements(for appWindow: UIWindow) -> Bool { + retiredWindows.values.contains { $0.values.contains { $0.appWindow === appWindow } } + } + + private func scheduleRetirementCheck(for appWindow: UIWindow?) { + guard let appWindow, hasRetirements(for: appWindow) else { return } + DispatchQueue.main.async { [weak self, weak appWindow] in + guard let self, let appWindow else { return } + // The source observer may be gone; query current host ownership and all delivered touches. + for (sceneKey, windows) in self.retiredWindows { + for (windowKey, retirement) in windows where retirement.appWindow === appWindow { + self.finishRetirement(retirement, sceneKey: sceneKey, windowKey: windowKey) + } + } + } + } + + private func hasActiveTouches(in appWindow: UIWindow?) -> Bool { + guard let appWindow else { return false } + return appTouchObservations.values.contains { + $0.observer.hasActiveTouches && ($0.appWindow === appWindow || + $0.previousAppWindows[ObjectIdentifier(appWindow)]?.window === appWindow) + } + } + + private func removeUnusedTouchObservers() { + for (key, observation) in appTouchObservations { + prunePreviousAppWindows(in: observation) + let active = observation.window.map { window in + scenes.values.contains { $0.values.contains { $0.window === window || $0.appWindow === window } } || + retiredWindows.values.contains { $0.values.contains { $0.window === window || $0.appWindow === window } } + } ?? false + guard !active else { continue } + observation.observer.onTouchesFinished = nil + observation.window?.removeGestureRecognizer(observation.observer) + appTouchObservations[key] = nil + for appWindow in observation.appWindows { scheduleRetirementCheck(for: appWindow) } + } + } + private static func interactiveCoordinator(in appWindow: UIWindow?) -> UIViewControllerTransitionCoordinator? { var controller = appWindow?.rootViewController while let current = controller { @@ -365,12 +543,15 @@ final class NativeOverlayWindowManager: NSObject { private func finishRetirement(_ retirement: RetiredWindow, sceneKey: ObjectIdentifier, windowKey: ObjectIdentifier) { guard retiredWindows[sceneKey]?[windowKey] === retirement else { return } - guard let coordinator = Self.interactiveCoordinator(in: retirement.appWindow) else { + let coordinator = Self.interactiveCoordinator(in: retirement.appWindow) + if coordinator == nil && !hasActiveTouches(in: retirement.appWindow) { retiredWindows[sceneKey]?[windowKey] = nil if retiredWindows[sceneKey]?.isEmpty == true { retiredWindows[sceneKey] = nil } release(retirement.window) + removeUnusedTouchObservers() return } + guard let coordinator else { return } guard retirement.observedCoordinator !== coordinator else { return } retirement.observedCoordinator = coordinator coordinator.notifyWhenInteractionChanges { [weak self, weak retirement] context in @@ -386,12 +567,13 @@ final class NativeOverlayWindowManager: NSObject { @objc private func sceneDidDisconnect(_ notification: Notification) { precondition(Thread.isMainThread) guard let scene = notification.object as? UIWindowScene else { return } - let sceneKey = ObjectIdentifier(scene) + let sceneKey = sceneIdentity(scene) let entries = scenes.removeValue(forKey: sceneKey) ?? [:] let retired = retiredWindows.removeValue(forKey: sceneKey) ?? [:] guard !entries.isEmpty || !retired.isEmpty else { return } disconnectingScenes.insert(sceneKey) defer { disconnectingScenes.remove(sceneKey) } + removeUnusedTouchObservers() // Unregister first: owner callbacks must not restore a disconnected scene's key. for entry in entries.values { entry.owner?.overlaySceneDidDisconnect(window: entry.window) diff --git a/native-views/react-native-native-overlay/tests/ios/WindowManagerTests.swift b/native-views/react-native-native-overlay/tests/ios/WindowManagerTests.swift index 5e1c05d54..41966f77a 100644 --- a/native-views/react-native-native-overlay/tests/ios/WindowManagerTests.swift +++ b/native-views/react-native-native-overlay/tests/ios/WindowManagerTests.swift @@ -16,6 +16,17 @@ private final class Owner: NativeOverlayWindowOwner, NativeOverlaySheetHost { func sheetRequestedDismiss(_ controller: NativeOverlaySheetController, reason: String) {} } +private final class TypedTouch: UITouch { + private let touchType: UITouch.TouchType + init(_ type: UITouch.TouchType) { touchType = type; super.init() } + override var type: UITouch.TouchType { touchType } +} + +private final class KeyCountingWindow: UIWindow { + var keyActivations = 0 + override func makeKey() { keyActivations += 1; super.makeKey() } +} + private final class TransitionRoot: UIViewController { var activeCoordinator: UIViewControllerTransitionCoordinator? override var transitionCoordinator: UIViewControllerTransitionCoordinator? { activeCoordinator } @@ -71,6 +82,12 @@ private func require(_ condition: @autoclosure () -> Bool, _ name: String) throw } } +private func touchObserver(in window: UIWindow) throws -> NativeOverlayAppTouchObserver { + guard let observer = window.gestureRecognizers?.compactMap({ $0 as? NativeOverlayAppTouchObserver }).first + else { throw Failure(name: "app touch observer installed before overlay visibility") } + return observer +} + @MainActor private func runTests(scene: UIWindowScene, app: UIWindow) async throws -> [String] { var passed: [String] = [] let manager = NativeOverlayWindowManager() @@ -256,6 +273,465 @@ private func require(_ condition: @autoclosure () -> Bool, _ name: String) throw transitionApp.windowScene = nil app.makeKey() passed.append("interactive-window-retirement-scene-disconnect") + + let touchManager = NativeOverlayWindowManager() + let touchOwner = Owner() + let touchWindow = touchManager.window(for: touchOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let observer = try touchObserver(in: app) + let pan = UIPanGestureRecognizer() + try require(!observer.cancelsTouchesInView && !observer.delaysTouchesBegan && + !observer.delaysTouchesEnded && !observer.requiresExclusiveTouchType && !observer.canPrevent(pan) && !observer.canBePrevented(by: pan), + "touch observer cannot delay, cancel, prevent or be prevented") + let event = UIEvent() + let firstTouch = UITouch() + observer.touchesBegan([firstTouch], with: event) + observer.touchesMoved([firstTouch], with: event) + touchWindow.makeKey() + let touchRoot = touchWindow.rootViewController + touchManager.entryRemoved(from: touchWindow) + try require(app.isKeyWindow && touchWindow.windowScene === scene && + touchWindow.rootViewController === touchRoot && !touchWindow.isHidden && observer.state == .possible, + "touch-only removal keeps routing context visible while restoring key") + observer.touchesEnded([firstTouch], with: event) + try require(touchWindow.windowScene === scene, "touch completion waits for next main turn") + await nextMainTurn() + try require(touchWindow.windowScene == nil && touchWindow.rootViewController == nil && + observer.view == nil && observer.onTouchesFinished == nil, + "last touch releases window and unregisters idle observer") + passed.append("touch-window-retirement-and-passive-observer") + + let overlapA = Owner(), overlapB = Owner() + let overlapWindowA = touchManager.window(for: overlapA, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let sharedObserver = try touchObserver(in: app) + let overlapWindowB = touchManager.window(for: overlapB, level: .modal, + stackOrder: 2, blocking: true, appWindow: overlapWindowA)! + let nestedObserver = try touchObserver(in: app) + try require(nestedObserver === sharedObserver && + app.gestureRecognizers?.filter({ $0 is NativeOverlayAppTouchObserver }).count == 1 && + overlapWindowA.gestureRecognizers?.filter({ $0 is NativeOverlayAppTouchObserver }).count == 1 && + overlapWindowB.gestureRecognizers?.filter({ $0 is NativeOverlayAppTouchObserver }).count == 1, + "nested entries each observe delivery while sharing the real host lifetime") + let fingerA = UITouch(), fingerB = UITouch() + sharedObserver.touchesBegan([fingerA, fingerB], with: event) + let otherApp = UIWindow(windowScene: scene) + otherApp.rootViewController = UIViewController() + let otherOwner = Owner() + let otherWindow = touchManager.window(for: otherOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: otherApp)! + let otherObserver = try touchObserver(in: otherApp) + touchManager.entryRemoved(from: otherWindow) + try require(otherObserver !== sharedObserver && otherWindow.windowScene == nil && + otherObserver.view == nil && sharedObserver.view === app, + "another app window in the same scene has an independent touch lifetime") + otherApp.rootViewController = nil + otherApp.windowScene = nil + touchManager.entryRemoved(from: overlapWindowA) + touchManager.entryRemoved(from: overlapWindowB) + sharedObserver.touchesEnded([fingerA], with: event) + await nextMainTurn() + try require(overlapWindowA.windowScene === scene && overlapWindowB.windowScene === scene && + sharedObserver.hasActiveTouches, "one finger ending cannot release either retired entry") + sharedObserver.touchesCancelled([fingerB], with: event) + await nextMainTurn() + try require(overlapWindowA.windowScene == nil && overlapWindowB.windowScene == nil && + sharedObserver.view == nil, "last finger cancellation releases all retired entries") + passed.append("touch-window-retirement-shared-multitouch-cancel") + + let queuedOwner = Owner() + let queuedWindow = touchManager.window(for: queuedOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let queuedObserver = try touchObserver(in: app) + let endingTouch = UITouch(), nextTouch = UITouch() + queuedObserver.touchesBegan([endingTouch], with: event) + touchManager.entryRemoved(from: queuedWindow) + queuedObserver.touchesEnded([endingTouch], with: event) + queuedObserver.reset() + queuedObserver.touchesBegan([nextTouch], with: event) + await nextMainTurn() + try require(queuedWindow.windowScene === scene && queuedObserver.hasActiveTouches, + "queued cleanup rechecks a new touch beginning after the prior last UP") + queuedObserver.touchesEnded([nextTouch], with: event) + await nextMainTurn() + try require(queuedWindow.windowScene == nil, "replacement touch completion releases old window") + passed.append("touch-window-retirement-rechecks-current-touch-set") + + let resetOwner = Owner() + let resetWindow = touchManager.window(for: resetOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let resetObserver = try touchObserver(in: app) + resetObserver.touchesBegan([UITouch()], with: event) + touchManager.entryRemoved(from: resetWindow) + resetObserver.reset() + await nextMainTurn() + try require(resetWindow.windowScene == nil && resetObserver.view == nil, + "UIKit reset of a live observer wakes pending retirement cleanup") + passed.append("touch-window-retirement-reset-cleanup") + + let combinedApp = UIWindow(windowScene: scene) + let combinedRoot = TransitionRoot() + combinedApp.rootViewController = combinedRoot + combinedApp.makeKeyAndVisible() + let combinedOwner = Owner() + let combinedWindow = touchManager.window(for: combinedOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: combinedApp)! + let combinedObserver = try touchObserver(in: combinedApp) + let combinedTouch = UITouch() + let combinedCoordinator = TestTransitionCoordinator() + combinedRoot.activeCoordinator = combinedCoordinator + combinedObserver.touchesBegan([combinedTouch], with: event) + touchManager.entryRemoved(from: combinedWindow) + combinedCoordinator.changeInteraction(to: false) + await nextMainTurn() + try require(combinedWindow.windowScene === scene, "coordinator completion cannot release an active touch") + combinedCoordinator.changeInteraction(to: true) + combinedObserver.touchesCancelled([combinedTouch], with: event) + await nextMainTurn() + try require(combinedWindow.windowScene === scene && combinedCoordinator.subscriptions == 1, + "touch cancellation cannot release a newly interactive coordinator") + combinedCoordinator.changeInteraction(to: false, cancelled: true) + await nextMainTurn() + try require(combinedWindow.windowScene == nil && combinedObserver.view == nil, + "combined ownership releases only after both current conditions finish") + combinedApp.isHidden = true + combinedApp.rootViewController = nil + combinedApp.windowScene = nil + app.makeKey() + passed.append("touch-window-retirement-coordinator-conjunction") + + let reuseOwner = Owner() + let reuseOldWindow = touchManager.window(for: reuseOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let reuseObserver = try touchObserver(in: app) + let reuseTouch = UITouch() + reuseObserver.touchesBegan([reuseTouch], with: event) + touchManager.entryRemoved(from: reuseOldWindow) + let reuseNewWindow = touchManager.window(for: reuseOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + reuseObserver.touchesEnded([reuseTouch], with: event) + await nextMainTurn() + try require(reuseOldWindow.windowScene == nil && reuseNewWindow.windowScene === scene && + reuseObserver.view === app, "touch callback releases only the retired window, preserving reopened owner") + touchManager.entryRemoved(from: reuseNewWindow) + try require(reuseNewWindow.windowScene == nil && reuseObserver.view == nil, + "touch-free noninteractive removal remains synchronous") + passed.append("touch-window-retirement-owner-reuse-and-sync-removal") + + var orphanOwner: Owner? = Owner() + let orphanWindow = touchManager.window(for: orphanOwner!, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let orphanObserver = try touchObserver(in: app) + orphanOwner = nil + let afterOrphanOwner = Owner() + let afterOrphanWindow = touchManager.window(for: afterOrphanOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let afterOrphanObserver = try touchObserver(in: app) + try require(orphanWindow.windowScene == nil && orphanObserver.view == nil && + afterOrphanObserver !== orphanObserver, "orphan cleanup precedes replacement observer installation") + let afterOrphanTouch = UITouch() + afterOrphanObserver.touchesBegan([afterOrphanTouch], with: event) + touchManager.entryRemoved(from: afterOrphanWindow) + try require(afterOrphanWindow.windowScene === scene, "replacement observer protects the new entry") + afterOrphanObserver.touchesEnded([afterOrphanTouch], with: event) + await nextMainTurn() + passed.append("touch-window-retirement-orphan-replacement") + + let disconnectTouchOwner = Owner(), activeDisconnectOwner = Owner() + let disconnectTouchWindow = touchManager.window(for: disconnectTouchOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let disconnectObserver = try touchObserver(in: app) + let disconnectTouch = UITouch() + disconnectObserver.touchesBegan([disconnectTouch], with: event) + touchManager.entryRemoved(from: disconnectTouchWindow) + disconnectObserver.touchesEnded([disconnectTouch], with: event) + let activeDisconnectWindow = touchManager.window(for: activeDisconnectOwner, level: .modal, + stackOrder: 2, blocking: true, appWindow: app)! + var observerRemovedBeforeCallback = false + activeDisconnectOwner.onDisconnect = { _ in + observerRemovedBeforeCallback = disconnectObserver.view == nil && disconnectObserver.onTouchesFinished == nil + } + NotificationCenter.default.post(name: UIScene.didDisconnectNotification, object: scene) + try require(observerRemovedBeforeCallback && disconnectTouchWindow.windowScene == nil && + activeDisconnectWindow.windowScene == nil, "disconnect unregisters observer before owner callbacks and releases retired windows") + let afterDisconnectOwner = Owner() + let afterDisconnectWindow = touchManager.window(for: afterDisconnectOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + await nextMainTurn() + let afterDisconnectObserver = try touchObserver(in: app) + try require(afterDisconnectWindow.windowScene === scene && + afterDisconnectObserver.view === app, "queued pre-disconnect touch callback cannot affect new registration") + touchManager.entryRemoved(from: afterDisconnectWindow) + passed.append("touch-window-retirement-disconnect-callback-identity") + let lowerOwner = Owner(), upperOwner = Owner() + let lower = touchManager.window(for: lowerOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let upper = touchManager.window(for: upperOwner, level: .toast, + stackOrder: 2, blocking: false, appWindow: lower)! + let lowerObserver = try touchObserver(in: lower) + let upperObserver = try touchObserver(in: upper) + let lowerRoot = lower.rootViewController + let lowerTouch = UITouch() + lowerObserver.touchesBegan([lowerTouch], with: event) + touchManager.entryRemoved(from: upper) + try require(upper.windowScene === scene && upper.rootViewController != nil && + !upper.isHidden && lower.rootViewController === lowerRoot, + "lower entry touch retains upper toast context without changing lower root") + lowerObserver.touchesEnded([lowerTouch], with: event) + await nextMainTurn() + try require(upper.windowScene == nil && upperObserver.view == nil && + lowerObserver.view === lower && lower.rootViewController === lowerRoot, + "lower entry UP releases only retired toast and its observer") + passed.append("entry-touch-retirement-lower-entry") + + let ownTouch = UITouch(), ownHostTouch = UITouch() + let ownHostObserver = try touchObserver(in: app) + ownHostObserver.touchesBegan([ownHostTouch], with: event) + lowerObserver.touchesBegan([ownTouch], with: event) + lower.makeKey() + touchManager.entryRemoved(from: lower) + try require(lower.windowScene === scene && !lower.isHidden && app.isKeyWindow, + "exiting entry retains its own delivered touch and restores key") + ownHostObserver.touchesEnded([ownHostTouch], with: event) + await nextMainTurn() + try require(lower.windowScene === scene && lowerObserver.hasActiveTouches, + "one window touch ending cannot release another window's simultaneous touch") + lowerObserver.touchesCancelled([ownTouch], with: event) + await nextMainTurn() + try require(lower.windowScene == nil && lowerObserver.view == nil, + "exiting entry cancellation releases window and observer") + passed.append("entry-touch-retirement-exiting-window") + + let mixedOwner = Owner() + let mixedWindow = touchManager.window(for: mixedOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let mixedObserver = try touchObserver(in: mixedWindow) + let finger = TypedTouch(.direct), pencil = TypedTouch(.pencil) + try require(finger.type != pencil.type && !mixedObserver.requiresExclusiveTouchType, + "observer accepts simultaneous touch types without restricting allowed types") + mixedObserver.touchesBegan([finger], with: event) + mixedObserver.touchesBegan([pencil], with: event) + touchManager.entryRemoved(from: mixedWindow) + mixedObserver.touchesEnded([finger], with: event) + await nextMainTurn() + try require(mixedWindow.windowScene === scene && mixedObserver.hasActiveTouches, + "first touch type ending preserves the second delivered touch type") + mixedObserver.touchesEnded([pencil], with: event) + await nextMainTurn() + try require(mixedWindow.windowScene == nil && mixedObserver.view == nil, + "second touch type ending releases retirement") + passed.append("entry-touch-retirement-mixed-types") + + let switchOwner = Owner(), switchToastOwner = Owner() + let switchWindow = touchManager.window(for: switchOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let switchToast = touchManager.window(for: switchToastOwner, level: .toast, + stackOrder: 2, blocking: false, appWindow: switchWindow)! + let switchAppObserver = try touchObserver(in: app) + let switchEntryObserver = try touchObserver(in: switchWindow) + let switchA = UITouch(), switchB = UITouch() + switchAppObserver.touchesBegan([switchA], with: event) + touchManager.entryRemoved(from: switchToast) + switchAppObserver.touchesEnded([switchA], with: event) + switchEntryObserver.touchesBegan([switchB], with: event) + await nextMainTurn() + try require(switchToast.windowScene === scene && switchEntryObserver.hasActiveTouches, + "queued cleanup rechecks touches that move to another window of the same host") + touchManager.entryRemoved(from: switchWindow) + switchEntryObserver.touchesEnded([switchB], with: event) + await nextMainTurn() + try require(switchToast.windowScene == nil && switchWindow.windowScene == nil && + switchEntryObserver.view == nil && switchAppObserver.view == nil, + "last cross-window touch releases all retired windows and idle observers") + passed.append("entry-touch-retirement-cross-window-recheck") + + let firstHost = UIWindow(windowScene: scene), secondHost = UIWindow(windowScene: scene) + let thirdHost = UIWindow(windowScene: scene) + thirdHost.rootViewController = UIViewController() + firstHost.rootViewController = UIViewController() + secondHost.rootViewController = UIViewController() + let rehostOwner = Owner(), rehostToastOwner = Owner() + let rehostWindow = touchManager.window(for: rehostOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: firstHost)! + let rehostToast = touchManager.window(for: rehostToastOwner, level: .toast, + stackOrder: 2, blocking: false, appWindow: firstHost)! + let rehostObserver = try touchObserver(in: rehostWindow) + let rehostTouch = UITouch() + rehostObserver.touchesBegan([rehostTouch], with: event) + touchManager.entryRemoved(from: rehostToast) + let reassignedWindow = touchManager.window(for: rehostOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: secondHost)! + try require(reassignedWindow === rehostWindow && rehostToast.windowScene === scene, + "same owner can change real host while old host retirement awaits a next-turn check") + await nextMainTurn() + try require(rehostToast.windowScene === scene && rehostWindow.windowScene === scene && + rehostObserver.hasActiveTouches, "reparenting cannot release the old context of a delivered touch") + let secondToastOwner = Owner() + let secondToast = touchManager.window(for: secondToastOwner, level: .toast, + stackOrder: 2, blocking: false, appWindow: secondHost)! + touchManager.entryRemoved(from: secondToast) + _ = touchManager.window(for: rehostOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: thirdHost)! + touchManager.entryRemoved(from: rehostWindow) + await nextMainTurn() + try require(rehostToast.windowScene === scene && secondToast.windowScene === scene && + rehostWindow.windowScene === scene, "repeated host changes preserve all contexts held by the same touch") + rehostObserver.touchesEnded([rehostTouch], with: event) + await nextMainTurn() + try require(rehostToast.windowScene == nil && secondToast.windowScene == nil && + rehostWindow.windowScene == nil && rehostObserver.view == nil, + "final touch completion releases original, intermediate and current host retirements") + firstHost.rootViewController = nil; firstHost.windowScene = nil + secondHost.rootViewController = nil; secondHost.windowScene = nil + thirdHost.rootViewController = nil; thirdHost.windowScene = nil + passed.append("entry-touch-retirement-real-host-reassignment") + + let queuedHostA = UIWindow(windowScene: scene), queuedHostB = UIWindow(windowScene: scene) + queuedHostA.rootViewController = UIViewController() + queuedHostB.rootViewController = UIViewController() + let queuedRehostOwner = Owner(), queuedRehostToastOwner = Owner() + let queuedRehost = touchManager.window(for: queuedRehostOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: queuedHostA)! + let queuedRehostToast = touchManager.window(for: queuedRehostToastOwner, level: .toast, + stackOrder: 2, blocking: false, appWindow: queuedHostA)! + let queuedRehostObserver = try touchObserver(in: queuedRehost) + let rehostEnding = UITouch(), rehostBeginning = UITouch() + queuedRehostObserver.touchesBegan([rehostEnding], with: event) + touchManager.entryRemoved(from: queuedRehostToast) + queuedRehostObserver.touchesEnded([rehostEnding], with: event) + queuedRehostObserver.reset() + _ = touchManager.window(for: queuedRehostOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: queuedHostB)! + queuedRehostObserver.touchesBegan([rehostBeginning], with: event) + await nextMainTurn() + try require(queuedRehostToast.windowScene === scene, + "UP then rehost then DOWN before cleanup preserves old host context") + queuedRehostObserver.touchesCancelled([rehostBeginning], with: event) + await nextMainTurn() + try require(queuedRehostToast.windowScene == nil && queuedRehost.windowScene === scene, + "replacement touch completion releases only the retired old host window") + touchManager.entryRemoved(from: queuedRehost) + queuedHostA.rootViewController = nil; queuedHostA.windowScene = nil + queuedHostB.rootViewController = nil; queuedHostB.windowScene = nil + passed.append("entry-touch-retirement-rehost-up-down-boundary") + + let delayedHost = UIWindow(windowScene: scene), currentHost = UIWindow(windowScene: scene) + let delayedRoot = TransitionRoot() + delayedHost.rootViewController = delayedRoot + currentHost.rootViewController = UIViewController() + let delayedCoordinator = TestTransitionCoordinator() + delayedRoot.activeCoordinator = delayedCoordinator + let delayedOwner = Owner(), delayedToastOwner = Owner() + let delayedEntry = touchManager.window(for: delayedOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: delayedHost)! + let delayedToast = touchManager.window(for: delayedToastOwner, level: .toast, + stackOrder: 2, blocking: false, appWindow: delayedHost)! + let delayedObserver = try touchObserver(in: delayedEntry) + let delayedTouchA = UITouch(), delayedTouchB = UITouch(), unrelatedTouch = UITouch() + delayedObserver.touchesBegan([delayedTouchA], with: event) + touchManager.entryRemoved(from: delayedToast) + _ = touchManager.window(for: delayedOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: currentHost)! + delayedObserver.touchesEnded([delayedTouchA], with: event) + await nextMainTurn() + try require(delayedToast.windowScene === scene, "old host coordinator retains context through an idle touch gap") + delayedObserver.reset() + delayedObserver.touchesBegan([delayedTouchB], with: event) + delayedCoordinator.changeInteraction(to: false) + await nextMainTurn() + try require(delayedToast.windowScene === scene, + "new touch after an idle turn still protects an old host context retained by a coordinator") + delayedObserver.touchesEnded([delayedTouchB], with: event) + await nextMainTurn() + try require(delayedToast.windowScene == nil, "last old-context dependency releases after both conditions end") + delayedObserver.reset() + delayedObserver.touchesBegan([unrelatedTouch], with: event) + let unrelatedOwner = Owner() + let unrelatedWindow = touchManager.window(for: unrelatedOwner, level: .toast, + stackOrder: 3, blocking: false, appWindow: delayedHost)! + touchManager.entryRemoved(from: unrelatedWindow) + try require(unrelatedWindow.windowScene == nil, + "released context prunes old host affiliation so future current-host touches remain independent") + delayedObserver.touchesEnded([unrelatedTouch], with: event) + await nextMainTurn() + touchManager.entryRemoved(from: delayedEntry) + delayedHost.rootViewController = nil; delayedHost.windowScene = nil + currentHost.rootViewController = nil; currentHost.windowScene = nil + passed.append("entry-touch-retirement-rehost-coordinator-idle-gap") + + // Project one genuine UIWindowScene into controlled manager buckets; this is not OS scene migration. + let sceneTokenA = NSObject(), sceneTokenB = NSObject() + var projectedSceneKey = ObjectIdentifier(sceneTokenA) + let migrationManager = NativeOverlayWindowManager(sceneIdentity: { _ in projectedSceneKey }) + let migratingHost = UIWindow(windowScene: scene) + migratingHost.rootViewController = UIViewController() + let inAOwner = Owner(), inBOwner = Owner() + let inA = migrationManager.window(for: inAOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: migratingHost)! + let migratingObserver = try touchObserver(in: migratingHost) + projectedSceneKey = ObjectIdentifier(sceneTokenB) + let inB = migrationManager.window(for: inBOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: migratingHost)! + let migratingTouch = UITouch() + migratingObserver.touchesBegan([migratingTouch], with: event) + migrationManager.entryRemoved(from: inB) + migratingObserver.touchesEnded([migratingTouch], with: event) + await nextMainTurn() + try require(inB.windowScene == nil && inA.windowScene === scene && migratingObserver.view === migratingHost, + "host touch callback cleans new logical scene while old scene remains active") + passed.append("touch-window-retirement-projected-scene-migration") + + let inBPendingOwner = Owner() + let inBPending = migrationManager.window(for: inBPendingOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: migratingHost)! + let inAObserver = try touchObserver(in: inA) + let queuedSceneTouch = UITouch() + inAObserver.touchesBegan([queuedSceneTouch], with: event) + migrationManager.entryRemoved(from: inBPending) + inAObserver.touchesEnded([queuedSceneTouch], with: event) + projectedSceneKey = ObjectIdentifier(sceneTokenA) + NotificationCenter.default.post(name: UIScene.didDisconnectNotification, object: scene) + try require(inA.windowScene == nil && inAObserver.view == nil && + migratingObserver.view === migratingHost && inBPending.windowScene === scene, + "old logical scene disconnect removes source observer while preserving migrated host") + await nextMainTurn() + try require(inBPending.windowScene == nil && migratingObserver.view == nil, + "queued old-scene UP wakes new-scene retirement even after its source observer is removed") + passed.append("touch-window-retirement-projected-scene-queued-up-disconnect") + + let disconnectSourceOwner = Owner(), disconnectTargetOwner = Owner() + let disconnectSource = migrationManager.window(for: disconnectSourceOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: migratingHost)! + let sourceObserver = try touchObserver(in: disconnectSource) + projectedSceneKey = ObjectIdentifier(sceneTokenB) + let disconnectTarget = migrationManager.window(for: disconnectTargetOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: migratingHost)! + sourceObserver.touchesBegan([UITouch()], with: event) + migrationManager.entryRemoved(from: disconnectTarget) + projectedSceneKey = ObjectIdentifier(sceneTokenA) + NotificationCenter.default.post(name: UIScene.didDisconnectNotification, object: scene) + await nextMainTurn() + try require(disconnectTarget.windowScene == nil && sourceObserver.view == nil && + sourceObserver.onTouchesFinished == nil, + "removing active old-scene observer wakes and releases another scene retirement") + migratingHost.rootViewController = nil + migratingHost.windowScene = nil + passed.append("touch-window-retirement-projected-scene-active-disconnect") + + let detachedHost = KeyCountingWindow(windowScene: scene) + detachedHost.rootViewController = UIViewController() + let detachedOwner = Owner() + let detachedWindow = touchManager.window(for: detachedOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: detachedHost)! + detachedWindow.makeKey() + detachedHost.windowScene = nil + let previousActivations = detachedHost.keyActivations + try require(detachedWindow.isKeyWindow, "exiting window remains key before cross-scene restoration") + touchManager.entryRemoved(from: detachedWindow) + try require(detachedHost.keyActivations == previousActivations && !detachedHost.isKeyWindow, + "removal cannot restore key to a host outside the entry scene") + detachedHost.rootViewController = nil + passed.append("entry-removal-host-scene-focus-boundary") return passed }