mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix(computer-use): deminiaturize offscreen macOS targets
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user