diff --git a/native/cu-helper/Sources/cu-helper/TargetWindowRecovery.swift b/native/cu-helper/Sources/cu-helper/TargetWindowRecovery.swift index 575b2d13..0c8f5d67 100644 --- a/native/cu-helper/Sources/cu-helper/TargetWindowRecovery.swift +++ b/native/cu-helper/Sources/cu-helper/TargetWindowRecovery.swift @@ -5,10 +5,9 @@ import AppKit /// /// Ordinary background windows stay untouched. Recovery is reserved for the /// three states that cannot be reached by window-bound input: a hidden app, a -/// minimized window, or a window on another Space. `activateAllWindows` is the -/// public native equivalent of the user choosing the exact app in the Dock; it -/// can unhide/restore the app and move macOS to its Space without a shell or -/// AppleScript fallback. +/// minimized window, or a window on another Space. Recovery uses only AppKit +/// activation plus the public writable AXMinimized window attribute; no shell +/// or AppleScript fallback is involved. @MainActor enum TargetWindowRecovery { enum Outcome: Equatable { @@ -19,26 +18,91 @@ enum TargetWindowRecovery { struct Runtime { var currentIdentity: (pid_t) -> AXTreeProcessIdentity? var hasOnScreenWindow: (pid_t) -> Bool - var activateAllWindows: (pid_t) -> Bool + var requestRecovery: (ProvenProcessTarget) -> Bool var pause: () async throws -> Void var attempts: Int = 20 @MainActor static let live = Runtime( currentIdentity: { AXTree.currentProcessIdentity(pid: $0) }, hasOnScreenWindow: { WindowGeometry.hasWindowOnScreen(pid: $0) }, - activateAllWindows: { pid in - guard let application = NSRunningApplication(processIdentifier: pid), - !application.isTerminated else { return false } - // `activate` normally unhides as part of activation. Sending an - // explicit unhide first also covers apps whose reopen handling is - // delayed; the on-screen verification below is still authoritative. - if application.isHidden { _ = application.unhide() } - return application.activate(options: [.activateAllWindows]) - }, + requestRecovery: { requestNativeRecovery(target: $0) }, pause: { try await Task.sleep(for: .milliseconds(100)) } ) } + /// Type-erased native handles keep the recovery policy testable at the same + /// seam used by AppKit and AX, without granting tests access to real apps. + struct NativeWindow { + var isMinimized: () -> Bool? + var setMinimized: (Bool) -> Bool + } + + struct NativeApplication { + let identity: AXTreeProcessIdentity + var isTerminated: () -> Bool + var isHidden: () -> Bool + var unhide: () -> Bool + var windows: () -> [NativeWindow] + var activateAllWindows: () -> Bool + } + + struct NativeRuntime { + var application: (pid_t) -> NativeApplication? + + @MainActor static let live = NativeRuntime(application: { pid in + guard let running = NSRunningApplication(processIdentifier: pid) else { + return nil + } + let appElement = AXUIElementCreateApplication(pid) + AXUIElementSetMessagingTimeout(appElement, 2.0) + return NativeApplication( + identity: AXTreeProcessIdentity( + bundleID: running.bundleIdentifier, + executablePath: running.executableURL?.path, + launchTime: running.launchDate?.timeIntervalSinceReferenceDate + ), + isTerminated: { running.isTerminated }, + isHidden: { running.isHidden }, + unhide: { running.unhide() }, + windows: { nativeWindows(appElement) }, + activateAllWindows: { + running.activate(options: [.activateAllWindows]) + } + ) + }) + } + + /// Revalidates the exact NSRunningApplication object before touching any of + /// its AX windows. A PID-only lookup is insufficient because macOS can reuse + /// the number after the explicitly authorized process exits. + static func requestNativeRecovery( + target: ProvenProcessTarget, + runtime: NativeRuntime = .live + ) -> Bool { + guard let application = runtime.application(target.pid), + target.validatedPid(currentIdentity: application.identity) != nil, + !application.isTerminated() else { return false } + + var accepted = false + if application.isHidden() { + accepted = application.unhide() + } + guard !application.isTerminated() else { return accepted } + let windows = application.windows() + guard !application.isTerminated() else { return accepted } + if let minimizedWindow = windows.first(where: { + $0.isMinimized() == true + }) { + accepted = minimizedWindow.setMinimized(false) || accepted + } + + // Activation moves hidden/other-Space windows into view but does not + // deminiaturize a native document window (verified with TextEdit). The + // AX transition above is therefore intentionally before activation. + guard !application.isTerminated() else { return accepted } + return application.activateAllWindows() || accepted + } + static func recoverIfNeeded( target: ProvenProcessTarget, runtime: Runtime = .live @@ -48,7 +112,7 @@ enum TargetWindowRecovery { return .alreadyOnScreen } - let activationAccepted = runtime.activateAllWindows(target.pid) + let recoveryAccepted = runtime.requestRecovery(target) // The request is asynchronous and AppKit explicitly says acceptance is // not proof of activation. Check once immediately, then yield the main @@ -66,9 +130,9 @@ enum TargetWindowRecovery { } } - let reason = activationAccepted - ? "macOS accepted activation, but no target window appeared on screen" - : "macOS refused the target app activation request" + let reason = recoveryAccepted + ? "macOS accepted recovery, but no target window appeared on screen" + : "macOS refused the target app recovery request" throw CUError( "target_window_offscreen", "Computer Use could not restore the explicitly selected app: \(reason). No state snapshot was published." @@ -86,4 +150,39 @@ enum TargetWindowRecovery { ) } } + + private static func nativeWindows(_ app: AXUIElement) -> [NativeWindow] { + var raw: CFTypeRef? + guard AXUIElementCopyAttributeValue( + app, + kAXWindowsAttribute as CFString, + &raw + ) == .success, + let elements = raw as? [AXUIElement] else { return [] } + + return elements.map { element in + NativeWindow( + isMinimized: { minimizedValue(element) }, + setMinimized: { minimized in + AXUIElementSetAttributeValue( + element, + kAXMinimizedAttribute as CFString, + minimized ? kCFBooleanTrue : kCFBooleanFalse + ) == .success + } + ) + } + } + + private static func minimizedValue(_ window: AXUIElement) -> Bool? { + var raw: CFTypeRef? + guard AXUIElementCopyAttributeValue( + window, + kAXMinimizedAttribute as CFString, + &raw + ) == .success, + let raw, + CFGetTypeID(raw) == CFBooleanGetTypeID() else { return nil } + return CFBooleanGetValue((raw as! CFBoolean)) + } } diff --git a/native/cu-helper/Tests/CuHelperTests/TargetWindowRecoveryTests.swift b/native/cu-helper/Tests/CuHelperTests/TargetWindowRecoveryTests.swift index 5fc515c1..45b90be4 100644 --- a/native/cu-helper/Tests/CuHelperTests/TargetWindowRecoveryTests.swift +++ b/native/cu-helper/Tests/CuHelperTests/TargetWindowRecoveryTests.swift @@ -11,8 +11,107 @@ final class TargetWindowRecoveryTests: XCTestCase { launchTime: 44 ) - func testHiddenMinimizedAndOtherSpaceTargetsTransitionBackOnScreen() async throws { - for placement in RecoveryHarness.Placement.unavailableCases { + func testMinimizedNativeWindowIsDeminimizedBeforeActivationCanReportRecovery() async throws { + let window = AXWindowHarness(minimized: true) + + var activationPIDs: [pid_t] = [] + let nativeRuntime = TargetWindowRecovery.NativeRuntime( + application: { [identity] requestedPID in + XCTAssertEqual(requestedPID, self.pid) + return TargetWindowRecovery.NativeApplication( + identity: identity, + isTerminated: { false }, + isHidden: { false }, + unhide: { XCTFail("a minimized visible app must not be unhidden"); return false }, + windows: { + [TargetWindowRecovery.NativeWindow( + isMinimized: { window.minimized }, + setMinimized: { minimized in + window.minimizedWrites.append(minimized) + window.minimized = minimized + return true + } + )] + }, + activateAllWindows: { + activationPIDs.append(requestedPID) + return true + } + ) + } + ) + var pauseCount = 0 + let runtime = TargetWindowRecovery.Runtime( + currentIdentity: { [identity] _ in identity }, + hasOnScreenWindow: { _ in window.isOnScreen }, + requestRecovery: { target in + TargetWindowRecovery.requestNativeRecovery( + target: target, + runtime: nativeRuntime + ) + }, + pause: { pauseCount += 1 } + ) + + let result = try await TargetWindowRecovery.recoverIfNeeded( + target: try target(), + runtime: runtime + ) + + XCTAssertEqual(result, .recovered) + XCTAssertFalse(window.minimized) + XCTAssertTrue(window.isOnScreen) + XCTAssertEqual(window.minimizedWrites, [false]) + XCTAssertEqual(activationPIDs, [pid]) + XCTAssertEqual(pauseCount, 0) + } + + func testPIDReuseIsRejectedBeforeAnyNativeWindowMutation() throws { + let replacement = AXTreeProcessIdentity( + bundleID: identity.bundleID, + executablePath: identity.executablePath, + launchTime: 45 + ) + let window = AXWindowHarness(minimized: true) + var activated = false + let runtime = TargetWindowRecovery.NativeRuntime( + application: { [replacement] _ in + TargetWindowRecovery.NativeApplication( + identity: replacement, + isTerminated: { false }, + isHidden: { false }, + unhide: { XCTFail("a replacement process must not be unhidden"); return true }, + windows: { + window.readCount += 1 + return [TargetWindowRecovery.NativeWindow( + isMinimized: { window.minimized }, + setMinimized: { value in + window.minimizedWrites.append(value) + window.minimized = value + return true + } + )] + }, + activateAllWindows: { + activated = true + return true + } + ) + } + ) + + XCTAssertFalse(TargetWindowRecovery.requestNativeRecovery( + target: try target(), + runtime: runtime + )) + XCTAssertEqual(window.readCount, 0) + XCTAssertTrue(window.minimizedWrites.isEmpty) + XCTAssertTrue(window.minimized) + XCTAssertFalse(activated) + } + + func testHiddenAndOtherSpaceTargetsTransitionBackOnScreen() async throws { + for placement in RecoveryHarness.Placement.activationCases { let harness = RecoveryHarness(pid: pid, identity: identity, placement: placement) let result = try await TargetWindowRecovery.recoverIfNeeded( @@ -121,7 +220,9 @@ final class TargetWindowRecoveryTests: XCTestCase { let body = String(source[start.lowerBound...].prefix(8_000)) let recovery = try XCTUnwrap(body.range(of: "TargetWindowRecovery.recoverIfNeeded")) let snapshot = try XCTUnwrap(body.range(of: "AXTree.appState")) + let authorization = try XCTUnwrap(body.range(of: "authorizeResolvedTarget")) + XCTAssertLessThan(authorization.lowerBound, recovery.lowerBound) XCTAssertLessThan( recovery.lowerBound, snapshot.lowerBound, @@ -143,6 +244,22 @@ final class TargetWindowRecoveryTests: XCTestCase { } } +/// Stateful stand-in for the exact AXMinimized read/write seam used in +/// production. Visibility is derived from the attribute transition; neither +/// activation nor the polling harness can manufacture an on-screen result. +@MainActor +private final class AXWindowHarness { + var minimized: Bool + var minimizedWrites: [Bool] = [] + var readCount = 0 + + init(minimized: Bool) { + self.minimized = minimized + } + + var isOnScreen: Bool { !minimized } +} + @MainActor private final class RecoveryHarness { enum Placement: CaseIterable { @@ -151,7 +268,7 @@ private final class RecoveryHarness { case minimized case otherSpace - static let unavailableCases: [Placement] = [.hidden, .minimized, .otherSpace] + static let activationCases: [Placement] = [.hidden, .otherSpace] } let pid: pid_t @@ -193,8 +310,8 @@ private final class RecoveryHarness { XCTAssertEqual(requestedPID, pid) return placement == .onScreen }, - activateAllWindows: { [self] requestedPID in - activationPIDs.append(requestedPID) + requestRecovery: { [self] target in + activationPIDs.append(target.pid) recoveryRequested = true return activationAccepted },