|
| 1 | +diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/WindowManagementService+StateOperations.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/WindowManagementService+StateOperations.swift |
| 2 | +index cc8ea70e4..d0c57fa06 100644 |
| 3 | +--- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/WindowManagementService+StateOperations.swift |
| 4 | ++++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/WindowManagementService+StateOperations.swift |
| 5 | +@@ -3,6 +3,7 @@ import AppKit |
| 6 | + import AXorcist |
| 7 | + import CoreGraphics |
| 8 | + import Foundation |
| 9 | ++import os |
| 10 | + import PeekabooFoundation |
| 11 | + |
| 12 | + @MainActor |
| 13 | +@@ -70,21 +71,25 @@ extension WindowManagementService { |
| 14 | + hint: "Restore the same exact window first, or retry with explicit foreground consent.") |
| 15 | + } |
| 16 | + |
| 17 | +- let attempt = try await self.attemptPinnedBackgroundClose(expectedIdentity) |
| 18 | +- guard attempt.dispatched else { |
| 19 | +- throw OperationError.interactionFailed( |
| 20 | +- action: "close window", |
| 21 | +- reason: "Window close operation failed") |
| 22 | +- } |
| 23 | +- guard attempt.disappeared else { |
| 24 | +- throw WindowManagementActionOutcome.suspectedNoop( |
| 25 | ++ try await BoundedBackgroundWindowAX.dispatchOneClose(expectedIdentity: expectedIdentity) |
| 26 | ++ do { |
| 27 | ++ guard try await self.verifyPinnedWindowClose(expectedIdentity) == .succeeded else { |
| 28 | ++ // An accepted close may be waiting on unsaved work; it is not safe to repeat. |
| 29 | ++ return .dispatchedUnverified( |
| 30 | ++ delivery: WindowManagementActionOutcome.backgroundActionDelivery, |
| 31 | ++ evidence: .operationStillRunning, |
| 32 | ++ unitCount: .one) |
| 33 | ++ } |
| 34 | ++ } catch { |
| 35 | ++ throw WindowManagementActionOutcome.dispatchedUnverified( |
| 36 | + action: "close window", |
| 37 | + delivery: WindowManagementActionOutcome.backgroundActionDelivery, |
| 38 | +- dispatchCount: attempt.dispatchCount) |
| 39 | ++ dispatchCount: 1, |
| 40 | ++ cause: error) |
| 41 | + } |
| 42 | +- return WindowManagementActionOutcome.confirmedChange( |
| 43 | ++ return .confirmedChange( |
| 44 | + delivery: WindowManagementActionOutcome.backgroundActionDelivery, |
| 45 | +- dispatchCount: attempt.dispatchCount) |
| 46 | ++ unitCount: .one) |
| 47 | + } |
| 48 | + } |
| 49 | + return DesktopActionResult(outcome: outcome) |
| 50 | +@@ -1087,6 +1092,106 @@ private enum BoundedBackgroundWindowAX { |
| 51 | + } |
| 52 | + } |
| 53 | + |
| 54 | ++ static func dispatchOneClose(expectedIdentity: WindowMutationIdentity) async throws { |
| 55 | ++ let cancelled = OSAllocatedUnfairLock(initialState: false) |
| 56 | ++ try await withTaskCancellationHandler { |
| 57 | ++ let result: Result<Void, DesktopActionFailure> = await self.perform(expectedIdentity: expectedIdentity) { |
| 58 | ++ guard SystemIdentityResolver.validateWindowMutationIdentity(expectedIdentity), |
| 59 | ++ let capturedBounds = expectedIdentity.capturedBounds, |
| 60 | ++ let windowID = CGWindowID(exactly: expectedIdentity.windowID), |
| 61 | ++ let rawWindow = self.exactWindow( |
| 62 | ++ windowID: windowID, |
| 63 | ++ ownerPID: expectedIdentity.ownerProcessIdentifier) |
| 64 | ++ else { |
| 65 | ++ return .failure(.preDispatchRefusal( |
| 66 | ++ reason: .targetUnavailable, |
| 67 | ++ message: "The exact window could not be verified before close.")) |
| 68 | ++ } |
| 69 | ++ return AXChildWindowMessagingTimeout.perform(on: rawWindow, timeout: self.messagingTimeout) { window in |
| 70 | ++ var actionsValue: CFArray? |
| 71 | ++ guard AXUIElementCopyActionNames(window, &actionsValue) == .success, |
| 72 | ++ let actions = actionsValue as? [String] |
| 73 | ++ else { |
| 74 | ++ return .failure(.preDispatchRefusal( |
| 75 | ++ reason: .operationUnsupported, |
| 76 | ++ message: "The exact window's supported close actions could not be read.")) |
| 77 | ++ } |
| 78 | ++ // Choose one route from read-only evidence. No native failure selects another route. |
| 79 | ++ let receiver: AXUIElement |
| 80 | ++ let action: CFString |
| 81 | ++ if actions.contains("AXClose") { |
| 82 | ++ receiver = window |
| 83 | ++ action = "AXClose" as CFString |
| 84 | ++ } else { |
| 85 | ++ var buttonValue: CFTypeRef? |
| 86 | ++ guard AXUIElementCopyAttributeValue( |
| 87 | ++ window, kAXCloseButtonAttribute as CFString, &buttonValue) == .success, |
| 88 | ++ let buttonValue, CFGetTypeID(buttonValue) == AXUIElementGetTypeID() |
| 89 | ++ else { |
| 90 | ++ return .failure(.preDispatchRefusal( |
| 91 | ++ reason: .operationUnsupported, |
| 92 | ++ message: "The exact window exposes no supported background close route.")) |
| 93 | ++ } |
| 94 | ++ receiver = unsafeDowncast(buttonValue, to: AXUIElement.self) |
| 95 | ++ action = kAXPressAction as CFString |
| 96 | ++ } |
| 97 | ++ return AXChildWindowMessagingTimeout.perform(on: receiver, timeout: self.messagingTimeout) { target in |
| 98 | ++ if !CFEqual(target, window) { |
| 99 | ++ var buttonActionsValue: CFArray? |
| 100 | ++ var currentButton: CFTypeRef? |
| 101 | ++ guard AXUIElementCopyActionNames(target, &buttonActionsValue) == .success, |
| 102 | ++ (buttonActionsValue as? [String])?.contains(kAXPressAction as String) == true, |
| 103 | ++ AXUIElementCopyAttributeValue( |
| 104 | ++ window, kAXCloseButtonAttribute as CFString, ¤tButton) == .success, |
| 105 | ++ let currentButton, CFEqual(currentButton, target) |
| 106 | ++ else { |
| 107 | ++ return .failure(.preDispatchRefusal( |
| 108 | ++ reason: .operationUnsupported, |
| 109 | ++ message: "The exact window's close button could not be verified.")) |
| 110 | ++ } |
| 111 | ++ } |
| 112 | ++ var ownerPID: pid_t = 0 |
| 113 | ++ var currentWindowID: CGWindowID = 0 |
| 114 | ++ guard AXUIElementGetPid(target, &ownerPID) == .success, |
| 115 | ++ ownerPID == expectedIdentity.ownerProcessIdentifier, |
| 116 | ++ AXWindowIDResolver.copyWindowID(window, into: ¤tWindowID) == .success, |
| 117 | ++ currentWindowID == windowID, |
| 118 | ++ self.bounds(of: window) == capturedBounds, |
| 119 | ++ self.boolAttribute(kAXMinimizedAttribute as String, of: window) == false, |
| 120 | ++ SystemIdentityResolver.validateWindowMutationIdentity(expectedIdentity) |
| 121 | ++ else { |
| 122 | ++ return .failure(.preDispatchRefusal( |
| 123 | ++ reason: .targetUnavailable, |
| 124 | ++ message: "The exact window changed before close.")) |
| 125 | ++ } |
| 126 | ++ guard !cancelled.withLock({ $0 }) else { |
| 127 | ++ return .failure(.preDispatchRefusal( |
| 128 | ++ reason: .requestCancelled, |
| 129 | ++ message: "Close was cancelled before native dispatch.")) |
| 130 | ++ } |
| 131 | ++ let nativeResult = AXUIElementPerformAction(target, action) |
| 132 | ++ if nativeResult == .success { return .success(()) } |
| 133 | ++ let error = ActionInputDriver.classify(nativeResult) |
| 134 | ++ if ActionInputDriver.nativeMutationFailureMayHaveDispatched(error) { |
| 135 | ++ return .failure(.indeterminate( |
| 136 | ++ delivery: WindowManagementActionOutcome.backgroundActionDelivery, |
| 137 | ++ evidence: .completionUnknown, |
| 138 | ++ unitCount: .one, |
| 139 | ++ message: "The close request may have been delivered; native completion is unknown.", |
| 140 | ++ hint: "Refresh the window inventory; do not blindly retry close.", |
| 141 | ++ causeDescription: String(describing: error))) |
| 142 | ++ } |
| 143 | ++ return .failure(WindowManagementActionOutcome.refused(action: "close window", error: error)) |
| 144 | ++ } |
| 145 | ++ } |
| 146 | ++ } |
| 147 | ++ // Keep the coordinator lane until the actual AX call settles, including after cancellation. |
| 148 | ++ try result.get() |
| 149 | ++ } onCancel: { |
| 150 | ++ cancelled.withLock { $0 = true } |
| 151 | ++ } |
| 152 | ++ } |
| 153 | ++ |
| 154 | + static func dispatchClose( |
| 155 | + expectedIdentity: WindowMutationIdentity, |
| 156 | + action: BoundedBackgroundWindowCloseAction) async -> Bool |
0 commit comments