fix(computer-use): deminiaturize offscreen macOS targets

This commit is contained in:
程序员阿江(Relakkes)
2026-09-02 09:25:19 +08:00
parent 79f15a08a4
commit e1616ddfc7
2 changed files with 239 additions and 23 deletions
@@ -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
},