refactor(computer-use): drop the capture glow, keep the virtual cursor

The glowing border framed the app being driven. It was purely decorative and
the user asked for it to go — the animated cursor already shows what Claude is
acting on.

It was not, as suspected, interfering with clicks: the overlay is
`ignoresMouseEvents`, sits at `CGShieldingWindowLevel()`, and every window
enumeration in `WindowGeometry` filters to `layer == 0`, so it could not enter
any hit-test or occlusion decision. It went because it earns nothing, not
because it broke anything.

`WindowFrameTracker` goes with it: its own header says it exists to keep the
glow glued to a moving window, and nothing else referenced it. That also
resolves the "30Hz CGWindowList polling" entry on the redesign doc's cut list —
the polling was the tracker's fallback path, so it is gone rather than reduced.

`NonAnimatedGradientLayer` moved into `VirtualCursor.swift`. It lived in the
glow file but the cursor's orb needs it too (without it Core Animation's
implicit 0.25s actions smear every frame change), and the compiler caught the
dangling reference.

`overlay_show` now only reveals the cursor, so `playSound` and
`stopOverlaySession(animated:)` lost their only consumers. The TypeScript side
had eleven comments describing a border that no longer exists; they now describe
what the code does.
This commit is contained in:
程序员阿江(Relakkes)
2026-08-06 04:51:14 +08:00
parent d14866f6d7
commit a54760552b
10 changed files with 66 additions and 1724 deletions
@@ -1,921 +0,0 @@
import AppKit
import AudioToolbox
import CoreGraphics
import QuartzCore
// MARK: - Public types
/// Visual treatment for the capture glow border.
///
/// - `breathingAxial`: a soft axial (linear) gradient stroke whose outer
/// bloom pulses opacity (the calm default).
/// - `breathingConic`: a conic gradient stroke (full hue/anglular sweep)
/// with the same breathing bloom — richer, "alive".
/// - `travelingShimmer`: a conic gradient that slowly rotates, so a highlight
/// appears to travel around the border (the most active
/// treatment, mirrors Codex's traveling-shimmer look).
public enum GlowStyle: Sendable {
case breathingAxial
case breathingConic
case travelingShimmer
}
/// What the glow border should frame. Mirrors Codex `StartCaptureAnimation`
/// (color + rect + target + display): you can target a live app window (by pid,
/// optionally a specific window id, which is then *followed* as it moves), a
/// whole display, or a fixed rectangle.
///
/// Coordinate convention for `.display` and `.rect`: **global, top-left origin
/// (Quartz / logical points)** — the same convention every other coordinate in
/// this helper uses (screenshots, click targets). `CaptureGlowOverlay` flips to
/// Cocoa (bottom-left) internally on the primary screen height.
public enum OverlayTarget: Sendable {
/// Follow the frontmost window of `pid`.
case window(pid: pid_t)
/// Follow a specific `CGWindowID` owned by `pid`.
case windowID(CGWindowID, pid: pid_t)
/// Frame an entire display.
case display(CGDirectDisplayID)
/// Frame a fixed rectangle in global top-left (Quartz) points.
case rect(CGRect)
}
// MARK: - Non-animated gradient layer
/// A `CAGradientLayer` that refuses every implicit animation. Without this,
/// each `setFrame:` (window follow) and each gradient/color mutation triggers
/// Core Animation's default 0.25s fade/move, which makes the border smear and
/// lag behind a dragged window. Returning `nil` from `action(forKey:)` disables
/// implicit actions for ALL keys on this layer.
final class NonAnimatedGradientLayer: CAGradientLayer {
override func action(forKey event: String) -> CAAction? {
// NSNull would also disable; nil is the documented "no action" answer.
return nil
}
}
// MARK: - CaptureGlowOverlay
/// `@MainActor` glowing/shimmering border overlay drawn over the target the AI
/// is driving. Daemon-only (needs a live main run loop for the breathing /
/// shimmer animations). The overlay never intercepts input — it is purely a
/// visual affordance so the user can see what Claude is acting on.
///
/// Internals:
/// - One borderless, transparent, click-through `NSWindow` at
/// `CGShieldingWindowLevel() - 1` (above normal app windows + the system
/// shield-adjacent band, just under the absolute max so a real shield can
/// still cover it), `canJoinAllSpaces` so it follows the user across Spaces.
/// - A `NonAnimatedGradientLayer` filling the window, masked to a rounded-rect
/// stroke by a `CAShapeLayer` (so the gradient only shows *as the border*).
/// - A separate outer-bloom `CALayer` carrying the rounded-rect stroke as its
/// own path with a large `shadow*` — its `shadowOpacity` is animated for the
/// "breathing", and for `travelingShimmer` the gradient layer is rotated.
/// - A `WindowFrameTracker` (when targeting a live window) drives
/// `setFrame:display:` so the border tracks the window as it moves/resizes.
@MainActor
public final class CaptureGlowOverlay {
// MARK: Stored state
private var window: GlowWindow?
private var tracker: WindowFrameTracker?
private var lifecycle = OverlayLifecycleState()
private var trackedTargetPid: pid_t?
private var trackedProcessIdentity: AXTreeProcessIdentity?
/// Most recent tracked content frame (Cocoa space); the background-exposure
/// check in `currentDecision()` evaluates its center.
private var lastCocoaContentFrame: CGRect?
/// Current accent color (kept so `updateAccentColor` and re-`show` reuse it
/// without the caller re-specifying).
private var accentColor: NSColor = .systemBlue
private var cornerRadius: CGFloat = 12
private var borderWidth: CGFloat = 4
private var style: GlowStyle = .breathingAxial
/// Margin (points) added around the target rect so the *outer* bloom and the
/// stroke (which straddles the path) are not clipped by the window edge.
/// Derived from borderWidth + bloom radius on show.
private var inset: CGFloat = 24
private var screenParamsObserver: NSObjectProtocol?
public var isVisible: Bool { lifecycle.isVisible }
public var isActive: Bool { lifecycle.isActive }
// MARK: Init / deinit
public init() {}
// An `isolated deinit` (Swift 6.0+) lets us touch `@MainActor`-isolated
// stored state during teardown. The notification token is normally removed
// in `hide()` / `removeScreenParamsObserver()`; this is a last-resort guard
// (e.g. dropped without an explicit hide).
isolated deinit {
// Last-resort guard for a drop-without-hide: stop the window-follow
// tracker (its AX observer / run-loop source / timer) so it cannot leak
// or fire a callback into a half-deallocated overlay.
tracker?.stop()
if let token = screenParamsObserver {
// `removeObserver` is thread-safe for token-based observers.
NotificationCenter.default.removeObserver(token)
}
}
// MARK: Public API
/// Reveal the glow border framing `target`. Re-entrant: calling `show` again
/// reconfigures the existing window/tracker in place (no flash).
///
/// - Parameters:
/// - target: window / windowID / display / rect to frame.
/// - accentColor: base hue of the border + bloom.
/// - cornerRadius: corner radius of the rounded-rect stroke (points).
/// - borderWidth: stroke width (points).
/// - style: breathing axial / breathing conic / traveling shimmer.
/// - playCaptureSound: when true, play a short system click via
/// AudioServices. Default callers pass `false`.
func show(
over target: OverlayTarget,
resolvedTarget: ProvenProcessTarget?,
accentColor: NSColor,
cornerRadius: CGFloat,
borderWidth: CGFloat,
style: GlowStyle,
playCaptureSound: Bool
) {
self.accentColor = accentColor
self.cornerRadius = max(0, cornerRadius)
self.borderWidth = max(1, borderWidth)
self.style = style
// The window must be large enough to contain the stroke (centered on the
// path, so ±borderWidth/2) plus the outer bloom (shadowRadius). Pad
// generously; the actual drawing is clipped to the window content layer.
let bloomRadius = max(8, borderWidth * 3)
self.inset = ceil(borderWidth / 2 + bloomRadius + 4)
// Re-targeting stops only the old tracker. The new target starts hidden
// and must pass the shared foreground policy before any order-front.
tracker?.stop()
tracker = nil
if lifecycle.isActive { hideWindow(animated: false) }
// A stale frame must not feed the previous target's exposure into the
// new target's first decision.
lastCocoaContentFrame = nil
trackedTargetPid = Self.targetPid(target)
if resolvedTarget?.pid == trackedTargetPid {
trackedProcessIdentity = resolvedTarget?.identity
} else {
trackedProcessIdentity = nil
}
lifecycle.startTracking()
ensureWindow()
guard let window else { return }
window.configure(
accentColor: accentColor,
cornerRadius: self.cornerRadius,
borderWidth: self.borderWidth,
style: style,
inset: inset
)
installScreenParamsObserverIfNeeded()
// Start live tracking before considering the initial frame. Its initial
// absence report must be able to hide without tearing the tracker down.
switch target {
case let .window(pid):
startTracking(pid: pid, windowID: nil)
case let .windowID(windowID, pid):
startTracking(pid: pid, windowID: windowID)
case .display, .rect:
break // static frame; no tracker
}
if case .display = target {
applyTrackedFrame(resolveInitialCocoaFrame(for: target))
} else if case .rect = target {
applyTrackedFrame(resolveInitialCocoaFrame(for: target))
}
if playCaptureSound, isVisible {
Self.playCaptureSound()
}
}
/// Recolor the border + bloom in place without re-resolving the target.
/// No-op if not currently shown.
public func updateAccentColor(_ color: NSColor) {
accentColor = color
window?.updateAccentColor(color)
}
/// Transiently hide visuals while retaining the active target, tracker, and
/// observers. A later valid foreground frame can reveal the same overlay.
public func hideWindow(animated: Bool = true) {
guard lifecycle.isActive else { return }
let wasVisible = lifecycle.isVisible
lifecycle.hideWindow()
guard let window else { return }
guard animated, wasVisible else {
window.stopAnimations()
window.orderOut(nil)
return
}
window.fadeOutAndOrderOut()
}
/// Terminal lifecycle path for explicit hide, disconnect, teardown, and
/// daemon shutdown. Unlike `hideWindow`, this destroys tracking state.
public func stopTrackingAndHide(animated: Bool) {
hideWindow(animated: animated)
tracker?.stop()
tracker = nil
removeScreenParamsObserver()
trackedTargetPid = nil
trackedProcessIdentity = nil
lastCocoaContentFrame = nil
lifecycle.stopTracking()
}
/// Backward-compatible terminal alias. New lifecycle-sensitive callers use
/// `hideWindow` or `stopTrackingAndHide` explicitly.
public func hide(animated: Bool) {
stopTrackingAndHide(animated: animated)
}
/// True only for the same still-proven process lifetime. Visibility is not
/// consulted because a background target remains actively tracked.
public func isTracking(pid: pid_t) -> Bool {
guard lifecycle.isActive,
trackedTargetPid == pid,
let expected = trackedProcessIdentity,
let current = Self.provenProcessIdentity(pid),
current == expected else { return false }
return true
}
// MARK: - Window lifecycle
private func ensureWindow() {
if window != nil { return }
let w = GlowWindow()
window = w
}
/// Fully release the window (used by callers that want to reclaim memory;
/// not part of the public API but kept tidy).
private func teardown() {
stopTrackingAndHide(animated: false)
window?.orderOut(nil)
window = nil
}
// MARK: - Frame resolution
/// Resolve the target's current frame in **Cocoa** space (bottom-left,
/// y-up). Returns nil when the target cannot be located right now.
private func resolveInitialCocoaFrame(for target: OverlayTarget) -> CGRect? {
switch target {
case let .window(pid):
return Self.cocoaWindowFrame(pid: pid, windowID: nil)
case let .windowID(windowID, pid):
return Self.cocoaWindowFrame(pid: pid, windowID: windowID)
case let .display(displayID):
let bounds = CGDisplayBounds(displayID) // global, top-left
guard !bounds.isNull, !bounds.isEmpty else { return nil }
return Self.quartzToCocoa(bounds)
case let .rect(rect):
guard !rect.isNull, !rect.isEmpty else { return nil }
// Incoming rect is global top-left (Quartz) points by contract.
return Self.quartzToCocoa(rect)
}
}
/// Position the window so the *content* rect (Cocoa space) is framed, with
/// `inset` padding on every side for the stroke + bloom. The drawing layers
/// inside the window draw the rounded rect inset by `inset` from the window
/// edge, so the path lines up exactly with `contentCocoaFrame`.
private func applyContentFrame(_ contentCocoaFrame: CGRect) {
guard let window else { return }
let windowFrame = contentCocoaFrame.insetBy(dx: -inset, dy: -inset)
window.setOverlayFrame(windowFrame, contentInset: inset)
}
// MARK: - Window tracking
private func startTracking(pid: pid_t, windowID: CGWindowID?) {
let t = WindowFrameTracker(pid: pid, windowID: windowID)
tracker = t
// `onChange` delivers a Cocoa-space frame (bottom-left), per contract.
t.start { [weak self, weak t] cocoaFrame in
// Tracker hops to the main actor before invoking; assert isolation.
MainActor.assumeIsolated {
guard let self, let t, self.lifecycle.isActive, self.tracker === t else { return }
self.applyTrackedFrame(cocoaFrame.isNull ? nil : cocoaFrame)
}
}
}
private func applyTrackedFrame(_ cocoaFrame: CGRect?) {
guard lifecycle.isActive else { return }
if let pid = trackedTargetPid, !isTracking(pid: pid) {
hideWindow(animated: false)
return
}
guard let cocoaFrame, !cocoaFrame.isNull, !cocoaFrame.isEmpty else {
hideWindow(animated: true)
return
}
// Record BEFORE deciding: the exposure check inside currentDecision()
// evaluates this frame's center.
lastCocoaContentFrame = cocoaFrame
let decision = currentDecision()
guard decision.visible else {
hideWindow(animated: true)
return
}
applyContentFrame(cocoaFrame)
window?.orderFrontRegardless()
window?.startAnimations()
lifecycle.showWindow()
}
private func currentDecision() -> OverlayPolicy.Decision {
let verifiedPid: pid_t?
if let pid = trackedTargetPid, isTracking(pid: pid) {
verifiedPid = pid
} else {
verifiedPid = nil
}
let frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier
// Background glow: visible while the tracked window's CENTER is
// actually exposed. Center-of-frame is a deliberate proxy — a mostly
// visible window glows, a buried one does not.
var exposed = false
if let pid = verifiedPid,
frontmostPid != pid,
let cocoaFrame = lastCocoaContentFrame {
let quartz = Self.cocoaToQuartz(cocoaFrame)
let center = CGPoint(x: quartz.midX, y: quartz.midY)
exposed = WindowExposure.targetWindowExposed(at: center, targetPid: pid)
}
return OverlayPolicy.decision(
targetPid: verifiedPid,
frontmostPid: frontmostPid,
overlayRequested: lifecycle.isActive,
targetWindowExposed: exposed
)
}
private static func targetPid(_ target: OverlayTarget) -> pid_t? {
switch target {
case let .window(pid), let .windowID(_, pid):
return pid
case .display, .rect:
return nil
}
}
private static func provenProcessIdentity(_ pid: pid_t) -> AXTreeProcessIdentity? {
guard let identity = AXTree.currentProcessIdentity(pid: pid), identity.isProven else {
return nil
}
return identity
}
// MARK: - Screen parameter changes
/// When displays are reconfigured (resolution change, monitor plugged), the
/// primary-screen height used for the Quartz→Cocoa flip changes, so any
/// static (display/rect) target must be re-flipped. For window targets the
/// tracker already re-reads AX/CGWindow geometry, so we only need to nudge
/// the static case — but re-resolving is cheap, so we just re-apply via the
/// tracker's current frame when present, and leave static frames as-is
/// (they were captured against the then-current primary height; a display
/// reconfig is rare mid-action).
private func installScreenParamsObserverIfNeeded() {
guard screenParamsObserver == nil else { return }
screenParamsObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self, let window = self.window, self.lifecycle.isActive else { return }
// Re-clamp the window onto the (possibly changed) screen layout
// and refresh the tracked frame if a tracker is live.
if let cocoa = self.tracker?.currentCocoaFrame(),
!cocoa.isNull, !cocoa.isEmpty {
self.applyTrackedFrame(cocoa)
}
window.refreshScaleFactor()
}
}
}
private func removeScreenParamsObserver() {
if let token = screenParamsObserver {
NotificationCenter.default.removeObserver(token)
screenParamsObserver = nil
}
}
// MARK: - Geometry helpers
/// Quartz (top-left, y-down, global) → Cocoa (bottom-left, y-up). Flip about
/// the PRIMARY screen height (`NSScreen.screens.first`). Matches
/// `FrameResolver.toCocoa` in WindowFrameTracker.swift.
static func quartzToCocoa(_ quartz: CGRect) -> CGRect {
let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
return CGRect(
x: quartz.origin.x,
y: primaryHeight - quartz.origin.y - quartz.height,
width: quartz.width,
height: quartz.height
)
}
/// Inverse of `quartzToCocoa` — the flip is an involution about the same
/// primary-screen height, so the formula is identical.
static func cocoaToQuartz(_ cocoa: CGRect) -> CGRect {
quartzToCocoa(cocoa)
}
/// Best-effort current window frame in Cocoa space via CGWindowList. Used
/// for the *initial* placement (the tracker takes over live updates).
static func cocoaWindowFrame(pid: pid_t, windowID: CGWindowID?) -> CGRect? {
guard let quartz = cgWindowQuartzFrame(pid: pid, windowID: windowID) else {
return nil
}
return quartzToCocoa(quartz)
}
/// Quartz-space (top-left) bounds of the target window from the window
/// server. Picks the largest on-screen, non-zero-layer window for `pid`
/// when no explicit `windowID` is given (heuristic for "the main window").
static func cgWindowQuartzFrame(pid: pid_t, windowID: CGWindowID?) -> CGRect? {
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
guard let infoList = CGWindowListCopyWindowInfo(options, kCGNullWindowID)
as? [[String: Any]]
else { return nil }
var best: CGRect?
var bestArea: CGFloat = 0
for info in infoList {
guard let ownerPID = info[kCGWindowOwnerPID as String] as? pid_t,
ownerPID == pid
else { continue }
if let windowID {
guard let number = info[kCGWindowNumber as String] as? CGWindowID,
number == windowID
else { continue }
} else {
// Skip menu-bar / overlay layers; layer 0 is normal window content.
let layer = (info[kCGWindowLayer as String] as? Int) ?? 0
if layer != 0 { continue }
}
guard let boundsDict = info[kCGWindowBounds as String] as? [String: Any],
let rect = CGRect(dictionaryRepresentation: boundsDict as CFDictionary)
else { continue }
if rect.isEmpty { continue }
if let windowID, windowID != kCGNullWindowID {
// Exact window requested → first match wins.
return rect
}
let area = rect.width * rect.height
if area > bestArea {
bestArea = area
best = rect
}
}
return best
}
// MARK: - Capture sound
private static func playCaptureSound() {
// A short, unobtrusive system click. AudioServices is the lightest path
// and needs no asset bundling. Default-off per the contract.
let soundID: SystemSoundID = 1108 // "begin recording"-style camera tick
AudioServicesPlaySystemSound(soundID)
}
}
// MARK: - GlowWindow (private)
/// The borderless, click-through overlay window + its layer tree. Kept private
/// so the public surface stays exactly the documented API.
@MainActor
private final class GlowWindow: NSWindow {
private let glowView: GlowContentView
init() {
glowView = GlowContentView(frame: .zero)
super.init(
contentRect: .zero,
styleMask: [.borderless],
backing: .buffered,
defer: false
)
isOpaque = false
backgroundColor = .clear
hasShadow = false
ignoresMouseEvents = true // click-through
isMovableByWindowBackground = false
// Just under the absolute top so a genuine system shield can still
// cover us, but above normal app windows + most utility panels.
level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()) - 1)
collectionBehavior = [
.canJoinAllSpaces,
.stationary,
.fullScreenAuxiliary,
.ignoresCycle,
]
// Never participate in window restoration / Mission Control cycling.
isReleasedWhenClosed = false
animationBehavior = .none
// Allow drawing outside the nominal content for the bloom.
contentView = glowView
glowView.wantsLayer = true
}
// MARK: Configuration
func configure(
accentColor: NSColor,
cornerRadius: CGFloat,
borderWidth: CGFloat,
style: GlowStyle,
inset: CGFloat
) {
glowView.configure(
accentColor: accentColor,
cornerRadius: cornerRadius,
borderWidth: borderWidth,
style: style,
inset: inset
)
}
func updateAccentColor(_ color: NSColor) {
glowView.updateAccentColor(color)
}
/// Set the window frame (Cocoa) and tell the content view how far in from
/// the window edge to draw the stroke path.
func setOverlayFrame(_ frame: CGRect, contentInset: CGFloat) {
// No implicit animation on the window move — the layers handle their own.
setFrame(frame, display: true, animate: false)
glowView.contentInset = contentInset
glowView.layoutGlow()
}
func refreshScaleFactor() {
glowView.refreshScaleFactor(backingScaleFactor)
}
// MARK: Animation control
func startAnimations() {
glowView.startAnimations()
}
func stopAnimations() {
glowView.stopAnimations()
}
func fadeOutAndOrderOut() {
glowView.fadeOut { [weak self] in
MainActor.assumeIsolated {
self?.glowView.stopAnimations()
self?.orderOut(nil)
}
}
}
// A borderless window cannot become key/main by default, but be explicit so
// a stray `makeKey` can never steal focus from the app Claude is driving.
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
}
// MARK: - GlowContentView (private)
/// Layer-backed content view that owns the gradient + mask + bloom layers and
/// drives the breathing / shimmer animations. Flipped is irrelevant here
/// because we lay out layers manually in the view's bounds.
@MainActor
private final class GlowContentView: NSView {
// Layer tree:
// layer (root, view-backed)
// ├─ bloomLayer (outer glow: stroked path + big shadow, breathing)
// └─ gradientLayer (NonAnimatedGradientLayer, masked to the stroke)
// └─ mask = strokeMaskLayer (CAShapeLayer stroke)
private let bloomLayer = CALayer()
private let gradientLayer = NonAnimatedGradientLayer()
private let strokeMaskLayer = CAShapeLayer()
// Configuration snapshot.
private var accentColor: NSColor = .systemBlue
private var cornerRadius: CGFloat = 12
private var borderWidth: CGFloat = 4
private var style: GlowStyle = .breathingAxial
/// Distance from the view edge to the stroke path (matches the window inset).
var contentInset: CGFloat = 24
private var animating = false
/// Bumped on every `cancelFadeOut()` (re-show) and every `fadeOut()`. A
/// fade's completion block (which orders the window out) captures the value
/// at scheduling time and runs only if it still matches — so a re-show that
/// lands inside the 0.25s fade window cancels the stale order-out. Fixes the
/// "glow stays invisible after the first hide→show turn boundary" bug.
private var fadeGeneration = 0
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layerContentsRedrawPolicy = .onSetNeedsDisplay
setupLayers()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is not used")
}
override var isFlipped: Bool { false }
// The view manages its own layer; nothing else draws.
override func makeBackingLayer() -> CALayer {
let root = CALayer()
root.masksToBounds = false
return root
}
override func layout() {
super.layout()
layoutGlow()
}
// Keep layers crisp across Retina/non-Retina moves.
override func viewDidChangeBackingProperties() {
super.viewDidChangeBackingProperties()
refreshScaleFactor(window?.backingScaleFactor ?? 2.0)
}
private func setupLayers() {
guard let root = layer else { return }
root.masksToBounds = false
// Bloom: a stroked rounded rect with a large soft shadow. We draw the
// stroke itself faintly and rely on the shadow for the glow halo.
bloomLayer.masksToBounds = false
bloomLayer.backgroundColor = NSColor.clear.cgColor
bloomLayer.shadowOffset = .zero
bloomLayer.shouldRasterize = false
root.addSublayer(bloomLayer)
// Gradient stroke: gradient fills the layer, masked to a ring by the
// CAShapeLayer stroke so only the border shows.
gradientLayer.masksToBounds = false
gradientLayer.needsDisplayOnBoundsChange = true
strokeMaskLayer.fillColor = NSColor.clear.cgColor
strokeMaskLayer.strokeColor = NSColor.white.cgColor // mask: alpha matters, not hue
strokeMaskLayer.lineCap = .round
strokeMaskLayer.lineJoin = .round
gradientLayer.mask = strokeMaskLayer
root.addSublayer(gradientLayer)
}
// MARK: Configure
func configure(
accentColor: NSColor,
cornerRadius: CGFloat,
borderWidth: CGFloat,
style: GlowStyle,
inset: CGFloat
) {
self.accentColor = accentColor
self.cornerRadius = cornerRadius
self.borderWidth = borderWidth
// Use the caller's style as-is. (An earlier attempt forced travelingShimmer
// here for a "flowing highlight"; its transform.rotation.z spin rotated the
// gradient layer TOGETHER WITH its stroke mask, turning the border into a
// rotating diagonal cross. The accent gradient still uses the Codex blue
// palette — see applyAccentColor — just without the broken rotation.)
self.style = style
self.contentInset = inset
applyStyle()
applyAccentColor()
layoutGlow()
if animating { restartAnimations() }
}
func updateAccentColor(_ color: NSColor) {
accentColor = color
applyAccentColor()
}
func refreshScaleFactor(_ scale: CGFloat) {
bloomLayer.contentsScale = scale
gradientLayer.contentsScale = scale
strokeMaskLayer.contentsScale = scale
}
// MARK: Layout
/// Recompute the rounded-rect path + gradient frame for the current bounds.
func layoutGlow() {
CATransaction.begin()
CATransaction.setDisableActions(true)
let b = bounds
gradientLayer.frame = b
bloomLayer.frame = b
strokeMaskLayer.frame = b
// The stroke path is inset from the view edge by `contentInset`; the
// stroke straddles the path (±borderWidth/2), so the path itself sits
// exactly on the target content rect's edge.
let pathRect = b.insetBy(dx: contentInset, dy: contentInset)
guard pathRect.width > 0, pathRect.height > 0 else {
CATransaction.commit()
return
}
let radius = min(cornerRadius, min(pathRect.width, pathRect.height) / 2)
let path = CGPath(
roundedRect: pathRect,
cornerWidth: radius,
cornerHeight: radius,
transform: nil
)
strokeMaskLayer.path = path
strokeMaskLayer.lineWidth = borderWidth
// Bloom path mirrors the stroke; its shadow provides the halo.
bloomLayer.shadowPath = path
bloomLayer.contents = nil
// Re-derive bloom shadow radius from border width. Kept modest so the
// halo is a faint glow, not a hot bloom (was *2.5, then *2.0 — still a bit
// bright on the user's display).
bloomLayer.shadowRadius = max(5, borderWidth * 1.7)
// For conic styles the gradient must cover the layer fully; for axial it
// sweeps along the border. Keep gradient frame == bounds (mask clips it).
CATransaction.commit()
}
// MARK: Style + color
private func applyStyle() {
switch style {
case .breathingAxial:
gradientLayer.type = .axial
gradientLayer.startPoint = CGPoint(x: 0, y: 0)
gradientLayer.endPoint = CGPoint(x: 1, y: 1)
case .breathingConic, .travelingShimmer:
gradientLayer.type = .conic
gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
// For conic, endPoint defines the 0° reference direction.
gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5)
}
}
private func applyAccentColor() {
// Fixed Codex-style blue→cyan palette sampled from the orb, rather than
// tinting from an arbitrary caller accent — the whole point is a pretty,
// consistent hue. `cyan` is the bright travelling highlight, `deep` the base.
let cyan = NSColor(srgbRed: 0.349, green: 0.773, blue: 0.996, alpha: 1) // #59C5FE
let core = NSColor(srgbRed: 0.071, green: 0.580, blue: 0.988, alpha: 1) // #1294FC
let deep = NSColor(srgbRed: 0.000, green: 0.463, blue: 0.949, alpha: 1) // #0076F2
switch style {
case .breathingAxial:
gradientLayer.colors = [cyan.cgColor, core.cgColor, deep.cgColor]
gradientLayer.locations = [0.0, 0.5, 1.0]
case .breathingConic, .travelingShimmer:
// Seamless conic loop: one bright cyan point that travels the border.
gradientLayer.colors = [
cyan.cgColor, core.cgColor, deep.cgColor, core.cgColor, cyan.cgColor,
]
gradientLayer.locations = [0.0, 0.25, 0.5, 0.75, 1.0]
}
// Bloom halo uses the core blue.
bloomLayer.shadowColor = core.cgColor
bloomLayer.shadowOpacity = 0 // animated up in startAnimations()
}
// MARK: Animations
func startAnimations() {
animating = true
// A re-show can land within the previous hide's 0.25s fade-out. Cancel
// any in-flight fade and restore full opacity first, otherwise the root
// layer stays filled-forward at opacity 0 (invisible) and the stale fade
// completion orders the freshly-shown window back out ~250ms later.
cancelFadeOut()
restartAnimations()
}
func stopAnimations() {
animating = false
bloomLayer.removeAllAnimations()
gradientLayer.removeAllAnimations()
}
/// Cancel an in-flight fade-out and restore full opacity, invalidating any
/// pending fade completion via the generation token.
func cancelFadeOut() {
fadeGeneration &+= 1
CATransaction.begin()
CATransaction.setDisableActions(true)
layer?.removeAnimation(forKey: "fadeOut")
layer?.opacity = 1
CATransaction.commit()
}
private func restartAnimations() {
bloomLayer.removeAllAnimations()
gradientLayer.removeAllAnimations()
// Capture border bloom: a SINGLE gentle bloom-in that settles to a faint,
// steady halo — deliberately NOT an infinite pulse. The old infinite
// autoreversing breathing (0.35↔0.95) read as a too-bright blinking alarm.
// Codex shows a brief capture transition then a calm border that simply
// follows the window; we mirror that — bloom in once, then rest dim.
let restOpacity: Float = 0.12
// Softer initial bloom-in peak (was 0.30) so the capture transition reads
// as a gentle settle, not a bright flash — the user reported it too bright.
let peakOpacity: Float = 0.22
bloomLayer.shadowOpacity = restOpacity
let bloomIn = CABasicAnimation(keyPath: "shadowOpacity")
bloomIn.fromValue = peakOpacity
bloomIn.toValue = restOpacity
bloomIn.duration = 0.55
bloomIn.timingFunction = CAMediaTimingFunction(name: .easeOut)
// Default isRemovedOnCompletion = true → after the bloom-in the presentation
// settles onto the model value (restOpacity), leaving a faint steady halo.
bloomLayer.add(bloomIn, forKey: "breathe")
if style == .travelingShimmer {
// Slowly rotate the conic gradient so the highlight travels around
// the border. Rotating the whole gradient layer is cheapest and the
// mask (stroke) stays put, so only the colors appear to move.
let spin = CABasicAnimation(keyPath: "transform.rotation.z")
spin.fromValue = 0
spin.toValue = Double.pi * 2
spin.duration = 5.5 // slow, elegant travel — a gentle shimmer, not a spin
spin.repeatCount = .infinity
spin.isRemovedOnCompletion = false
// Rotate about the layer center.
gradientLayer.add(spin, forKey: "shimmer")
}
}
func fadeOut(completion: @escaping () -> Void) {
fadeGeneration &+= 1
let generation = fadeGeneration
CATransaction.begin()
CATransaction.setCompletionBlock { [weak self] in
MainActor.assumeIsolated {
// Suppress the order-out if a re-show (or a newer fade) bumped
// the generation in the meantime — the window is visible again.
guard let self, self.fadeGeneration == generation else { return }
completion()
}
}
let fade = CABasicAnimation(keyPath: "opacity")
fade.fromValue = layer?.presentation()?.opacity ?? 1.0
fade.toValue = 0.0
fade.duration = 0.25
fade.timingFunction = CAMediaTimingFunction(name: .easeOut)
fade.isRemovedOnCompletion = false
fade.fillMode = .forwards
layer?.add(fade, forKey: "fadeOut")
CATransaction.commit()
}
}
+37 -85
View File
@@ -30,7 +30,7 @@ enum DaemonOverlayTargetResolver {
// Long-lived, dual-purpose engine for the Computer Use helper.
//
// The daemon owns the single main-thread CFRunLoop (via `NSApplication.run()`)
// that the animated virtual cursor and the glowing capture overlay require —
// that the animated virtual cursor requires —
// CADisplayLink / CABasicAnimation only tick while a live run loop is spinning,
// and a per-command one-shot process dies before any animation can outlive a
// single stdout write. It also holds the *virtual* cursor's logical position
@@ -50,8 +50,8 @@ enum DaemonOverlayTargetResolver {
// {"id":"<opaque>","ok":false,"error":{"message":"…","code":"…"}}\n
//
// Control verbs handled in-daemon (never reach CommandRouter):
// overlay_show -> cursor.show() + glow.show(over: frontmost) ; result true
// overlay_hide -> cursor.hide() + glow.hide(animated:true) ; result true
// overlay_show -> cursor.show() ; result true
// overlay_hide -> cursor.hide() ; result true
// ping -> result "pong"
// shutdown -> result true, then NSApp.terminate(nil)
// Every other cmd is forwarded to the shared CommandRouter — the exact same
@@ -61,14 +61,9 @@ enum DaemonOverlayTargetResolver {
public final class Daemon {
private let socketPath: String
private let cursor: VirtualCursor
private let glow: CaptureGlowOverlay
private let inputMonitor: PhysicalInputEpochMonitor
private let router: CommandRouter
/// Accent color for the capture-glow border. A calm system-blue glow reads
/// as "an assistant is driving this" without being alarming.
private let glowAccent: NSColor = NSColor(srgbRed: 0.20, green: 0.55, blue: 1.0, alpha: 1.0)
/// Listening socket fd (AF_UNIX SOCK_STREAM). -1 until `bindAndListen`.
private var listenFD: Int32 = -1
@@ -88,7 +83,7 @@ public final class Daemon {
private var didStartRunLoop = false
/// True between `overlay_show` and `overlay_hide` (CU active this turn). The
/// glow re-aims at the actual injection target while this holds.
/// cursor re-aims at the actual injection target while this holds.
private var overlayActive = false
private var explicitOverlayTarget: ProvenProcessTarget?
@@ -109,7 +104,6 @@ public final class Daemon {
self.inputMonitor = inputMonitor
let cursor = VirtualCursor(headless: false)
self.cursor = cursor
self.glow = CaptureGlowOverlay()
self.router = CommandRouter(
cursor: cursor,
capabilities: Capabilities(headless: false),
@@ -212,7 +206,7 @@ public final class Daemon {
unlink(socketPath)
unlink(pidfilePath)
stopOverlaySession(animated: false)
stopOverlaySession()
// Release any keys/buttons the daemon was holding, SYNCHRONOUSLY, so we
// never strand a stuck modifier/button when teardown is followed
@@ -431,7 +425,7 @@ public final class Daemon {
/// Park all session-owned state only when the generation gate says no
/// request can still touch it.
private func cleanupDisconnectedSession() {
stopOverlaySession(animated: true)
stopOverlaySession()
Injection.releaseAllHeldSync()
router.resetSessionState()
}
@@ -508,7 +502,7 @@ public final class Daemon {
return .bool(true)
case "overlay_hide":
stopOverlaySession(animated: true)
stopOverlaySession()
return .bool(true)
case "ping":
@@ -527,25 +521,23 @@ public final class Daemon {
return .bool(true)
default:
// Re-aim the glow at the app the injection resolved as its target —
// even if the injection itself THREW (e.g. `not_trusted` before the
// user has granted Accessibility). `Injection.resolveTargetPid` sets
// `lastResolvedTargetPid` BEFORE `ensurePostable` can throw, so this
// `defer` re-aims the glow whether the command succeeded or failed.
// Re-aim the cursor at the app the injection resolved as its target
// — even if the injection itself THREW (e.g. `not_trusted` before
// the user has granted Accessibility). `Injection.resolveTargetPid`
// sets `lastResolvedTargetPid` BEFORE `ensurePostable` can throw, so
// this `defer` re-aims whether the command succeeded or failed.
// Without it, a thrown injection skipped the re-aim and stranded the
// glow on the initial overlay_show frame forever (= "glow stuck,
// doesn't follow" when injection was failing on a missing grant).
defer { retargetGlowIfNeeded() }
// cursor on the initial overlay_show target forever.
defer { retargetCursorIfNeeded() }
return try await router.handle(cmd: cmd, payload: payload)
}
}
// MARK: Control-verb helpers
/// Reveals the virtual cursor and the glowing border over the app Claude is
/// driving. The target selector is `{pid}` / `{bundleId}` / `{app}` and is
/// resolved independently of the frontmost app. Optional `playSound`
/// defaults false.
/// Reveals the virtual cursor over the app Claude is driving. The target
/// selector is `{pid}` / `{bundleId}` / `{app}` and is resolved
/// independently of the frontmost app.
private func showOverlay(payload: JSONValue) throws {
overlayActive = true
// A new explicit request must never inherit the previous command's
@@ -553,7 +545,6 @@ public final class Daemon {
// running.
Injection.clearResolvedTarget()
explicitOverlayTarget = nil
let playSound = payload["playSound"]?.asBool ?? false
do {
explicitOverlayTarget = try DaemonOverlayTargetResolver.resolve(
payload: payload,
@@ -561,82 +552,45 @@ public final class Daemon {
currentIdentity: { AXTree.currentProcessIdentity(pid: $0) }
)
} catch {
// A failed retarget must not leave the prior app glowing while the
// caller sees an overlay_show error.
stopOverlaySession(animated: false)
// A failed retarget must not leave the cursor bound to the prior
// app while the caller sees an overlay_show error.
stopOverlaySession()
throw error
}
// A named app may not be running until get_app_state launches it. Stay
// hidden rather than framing frontmost; the command's defer below will
// re-aim as soon as the real process is resolved.
if let target = explicitOverlayTarget {
cursor.setVisualTarget(target)
cursor.show()
showGlow(
over: .window(pid: target.pid),
resolvedTarget: target,
playSound: playSound
)
} else {
cursor.setVisualTarget(nil)
cursor.show()
glow.stopTrackingAndHide(animated: false)
}
cursor.setVisualTarget(explicitOverlayTarget)
cursor.show()
}
/// Show (or re-aim, re-entrantly) the glow border over `target`.
private func showGlow(
over target: OverlayTarget,
resolvedTarget: ProvenProcessTarget,
playSound: Bool
) {
glow.show(
over: target,
resolvedTarget: resolvedTarget,
accentColor: glowAccent,
cornerRadius: 12,
borderWidth: 3,
style: .breathingAxial,
playCaptureSound: playSound
)
}
/// If the most recent injection resolved a target pid different from the one
/// the glow currently tracks, re-show the glow over that app's window so the
/// border tracks what Claude is actually driving. Transient visibility is
/// deliberately irrelevant: a hidden background tracker remains active.
private func retargetGlowIfNeeded() {
/// Bind the cursor to whatever the most recent injection actually resolved,
/// so it animates over the app Claude is driving rather than the one named
/// when the turn began.
private func retargetCursorIfNeeded() {
guard overlayActive else { return }
guard let target = resolvedInjectionOverlayTarget() else {
cursor.setVisualTarget(nil)
glow.stopTrackingAndHide(animated: false)
return
}
explicitOverlayTarget = target
cursor.setVisualTarget(target)
if glow.isTracking(pid: target.pid) { return }
showGlow(
over: .window(pid: target.pid),
resolvedTarget: target,
playSound: false
)
}
private func stopOverlaySession(animated: Bool) {
private func stopOverlaySession() {
overlayActive = false
explicitOverlayTarget = nil
Injection.clearResolvedTarget()
cursor.hide()
glow.stopTrackingAndHide(animated: animated)
}
/// The app the glow should frame: ONLY the app the last injection / get_app_state
/// actually resolved (`Injection.lastResolvedTargetPid`). We deliberately do NOT
/// fall back to the frontmost app — at turn start, before any real target is
/// resolved, the frontmost app is the HOST (Claude Code Haha), so the fallback
/// framed our own window and the cursor glided onto our own sidebar. nil here =>
/// show nothing until a real target lands; `retargetGlowIfNeeded()` re-aims the
/// glow the moment an injection / get_app_state resolves the true target.
/// The app the cursor should follow: ONLY the app the last injection /
/// get_app_state actually resolved (`Injection.lastResolvedTargetPid`). We
/// deliberately do NOT fall back to the frontmost app — at turn start,
/// before any real target is resolved, the frontmost app is the HOST
/// (Claude Code Haha), so the fallback made the cursor glide onto our own
/// sidebar. nil here => follow nothing until a real target lands;
/// `retargetCursorIfNeeded()` re-aims the moment one resolves.
private func resolvedInjectionOverlayTarget() -> ProvenProcessTarget? {
guard let target = Injection.lastResolvedTarget,
target.pid != getpid(),
@@ -703,8 +657,8 @@ public final class Daemon {
}
/// When monitors are plugged/unplugged the overlay windows must re-anchor.
/// The cursor/glow own their own per-screen window sets; rebuilding them on
/// reconfiguration keeps them aligned with the new NSScreen layout.
/// The cursor owns its own per-screen window set; rebuilding it on
/// reconfiguration keeps it aligned with the new NSScreen layout.
private func installScreenParameterObserver() {
NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
@@ -713,9 +667,7 @@ public final class Daemon {
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
// Re-warm cursor windows for the new screen set. The glow's own
// WindowFrameTracker re-resolves on this notification; we only
// need to keep the cursor's per-screen windows in sync.
// Re-warm cursor windows for the new screen set.
self.cursor.preload()
}
}
@@ -34,6 +34,23 @@ import AppKit
import Foundation
import QuartzCore
// MARK: - Non-animated gradient layer
/// A `CAGradientLayer` that refuses every implicit animation. Without this,
/// each `setFrame:` and each gradient/color mutation triggers Core Animation's
/// default 0.25s fade/move, which makes the cursor's orb smear and lag behind
/// its own motion. Returning `nil` from `action(forKey:)` disables implicit
/// actions for ALL keys on this layer.
///
/// Previously lived in `CaptureGlowOverlay.swift`; it moved here when the glow
/// border was removed, since the virtual cursor is now its only consumer.
final class NonAnimatedGradientLayer: CAGradientLayer {
override func action(forKey event: String) -> CAAction? {
// NSNull would also disable; nil is the documented "no action" answer.
return nil
}
}
@MainActor
public final class VirtualCursor {
@@ -1,669 +0,0 @@
//
// WindowFrameTracker.swift
// cu-helper
//
// Live window-follow for `CaptureGlowOverlay`. Keeps the glow border glued to
// the target app's focused window as the user moves/resizes it, switches
// Spaces, or rearranges displays.
//
// Two strategies, in priority order:
//
// 1. PRIMARY — Accessibility observer. `AXObserverCreateWithInfoCallback`
// against the target pid, subscribed to kAXMoved / kAXResized /
// kAXWindowMiniaturized / kAXUIElementDestroyed / kAXFocusedWindowChanged
// on both the application element and its focused window, with the
// observer's run-loop source spliced onto the MAIN run loop. The C
// callback recovers `self` via an `Unmanaged` refcon and hops back onto
// the MainActor. This is event-driven (zero polling) and reacts the
// instant AppKit posts the notification. Requires Accessibility (TCC) to
// read the window geometry; without it `AXUIElementCopyAttributeValue`
// fails and we fall through to strategy 2.
//
// 2. FALLBACK — ~30 Hz `DispatchSourceTimer` poll. Reads kAXPosition/kAXSize
// when available, else `CGWindowListCopyWindowInfo` kCGWindowBounds (which
// needs NO TCC for geometry). The timer fires on a private serial queue
// and only re-resolves geometry there; the `onChange` callback is always
// delivered on the MainActor. We diff against the last delivered frame so
// the overlay isn't churned every tick.
//
// COORDINATE SPACES — this is the whole point of the file. Quartz / CGWindow /
// AX kAXPosition all report a GLOBAL TOP-LEFT origin, y growing DOWNWARD, with
// the primary display's top-left at (0,0). AppKit `NSWindow.setFrame` wants a
// GLOBAL BOTTOM-LEFT origin, y growing UPWARD. The flip pivots on the PRIMARY
// screen's height (`NSScreen.screens.first!.frame.height`), exactly as the
// rest of the helper converts. `start`/`currentCocoaFrame` always hand back
// Cocoa-space rects so `CaptureGlowOverlay` can `setFrame:` verbatim.
//
// The real OS cursor is never touched here; this module only observes.
//
import AppKit
import ApplicationServices
import CoreGraphics
import Foundation
enum WindowFrameReport: Equatable, Sendable {
case frame(CGRect)
case absent
}
/// Pure coalescing gate for tracker reports. Unlike the old optional-frame
/// cache, an initial absence is observable and repeated absences are suppressed.
struct WindowFrameReportGate: Sendable {
private var lastReport: WindowFrameReport?
var currentFrame: CGRect? {
guard case let .frame(frame) = lastReport else { return nil }
return frame
}
mutating func consume(_ frame: CGRect?, force: Bool = false) -> WindowFrameReport? {
let next: WindowFrameReport
if let frame, !frame.isNull, !frame.isEmpty {
next = .frame(frame)
} else {
next = .absent
}
if let lastReport {
switch (lastReport, next) {
case (.absent, .absent):
return nil
case let (.frame(old), .frame(new)) where !force && Self.framesEqual(old, new):
return nil
default:
break
}
}
lastReport = next
return next
}
mutating func reset() {
lastReport = nil
}
private static func framesEqual(_ a: CGRect, _ b: CGRect) -> Bool {
let eps: CGFloat = 0.5
return abs(a.origin.x - b.origin.x) < eps
&& abs(a.origin.y - b.origin.y) < eps
&& abs(a.size.width - b.size.width) < eps
&& abs(a.size.height - b.size.height) < eps
}
}
// MARK: - FrameResolver
/// Stateless geometry helpers shared by the observer and the polling fallback.
/// Every `CGRect` returned by `axFrame`/`cgWindowFrame` is in QUARTZ space
/// (global, top-left origin, y-down). `toCocoa` performs the single y-flip.
enum FrameResolver {
/// Read the focused window's frame for `pid` via the Accessibility API.
/// Returns a Quartz-space rect (top-left origin), or `nil` when the app has
/// no focused window, the attributes are unreadable, or TCC is denied.
static func axFrame(pid: pid_t) -> CGRect? {
guard pid > 0 else { return nil }
// Not trusted → don't touch the AX API at all; the caller falls back to
// the TCC-free CGWindowList path. Avoids any chance of a blocking AX
// round-trip when we hold no Accessibility grant.
guard AXIsProcessTrusted() else { return nil }
let app = AXUIElementCreateApplication(pid)
// Hard-cap every AX message: a slow / unresponsive target app must NEVER
// wedge the main run loop (that would freeze the whole daemon).
AXUIElementSetMessagingTimeout(app, 0.25)
guard let window = AXUtil.copyElement(app, kAXFocusedWindowAttribute)
?? AXUtil.firstWindow(of: app)
else { return nil }
AXUIElementSetMessagingTimeout(window, 0.25)
return AXUtil.frame(of: window)
}
/// Read the target window's frame via the window-server window list. Needs
/// no Accessibility grant — `kCGWindowBounds` is already Quartz top-left.
/// When `windowID` is given we match it exactly; otherwise we pick the
/// frontmost (lowest window-list index) normal-layer window owned by `pid`.
static func cgWindowFrame(pid: pid_t, windowID: CGWindowID?) -> CGRect? {
guard pid > 0 else { return nil }
let options: CGWindowListOption = [.optionOnScreenOnly, .excludeDesktopElements]
guard
let infoList = CGWindowListCopyWindowInfo(options, kCGNullWindowID)
as? [[String: Any]]
else { return nil }
for info in infoList {
guard let ownerPID = info[kCGWindowOwnerPID as String] as? pid_t,
ownerPID == pid
else { continue }
if let want = windowID {
guard let wid = info[kCGWindowNumber as String] as? CGWindowID,
wid == want
else { continue }
} else {
// Only normal app windows (layer 0). Skip menus, shadows,
// status items, the Dock, etc.
let layer = (info[kCGWindowLayer as String] as? Int) ?? 0
guard layer == 0 else { continue }
}
guard
let boundsDict = info[kCGWindowBounds as String] as? [String: Any],
let rect = CGRect(dictionaryRepresentation: boundsDict as CFDictionary)
else { continue }
// Ignore degenerate / off-screen-parked windows.
if rect.width < 1 || rect.height < 1 { continue }
return rect
}
return nil
}
/// Quartz (global, top-left origin, y-down) → Cocoa (global, bottom-left
/// origin, y-up). Pivots on the PRIMARY screen height. `NSScreen.screens`
/// is ordered with the primary (menu-bar) display first; its frame always
/// has origin (0,0), so its height is the global flip axis.
static func toCocoa(_ quartz: CGRect) -> CGRect {
let primaryHeight = NSScreen.screens.first?.frame.height
?? CGDisplayBounds(CGMainDisplayID()).height
return CGRect(
x: quartz.origin.x,
y: primaryHeight - quartz.origin.y - quartz.size.height,
width: quartz.size.width,
height: quartz.size.height
)
}
}
// MARK: - AX attribute helpers (CF memory-safe)
/// Thin wrappers around the un-annotated `AXUIElementCopyAttributeValue` C API.
/// Each `copy*` returns a value the caller owns; ARC reclaims the bridged
/// `CFTypeRef` automatically once it goes out of scope.
private enum AXUtil {
/// Copy a child `AXUIElement`-valued attribute (e.g. focused window).
static func copyElement(_ element: AXUIElement, _ attribute: String) -> AXUIElement? {
var value: CFTypeRef?
let err = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
guard err == .success, let value else { return nil }
guard CFGetTypeID(value) == AXUIElementGetTypeID() else { return nil }
return (value as! AXUIElement)
}
/// First window from the application's kAXWindowsAttribute array, used when
/// there is no focused window (e.g. app just launched, or focus is on a
/// sheet). Returns `nil` if the app has no windows.
static func firstWindow(of app: AXUIElement) -> AXUIElement? {
var value: CFTypeRef?
let err = AXUIElementCopyAttributeValue(app, kAXWindowsAttribute as CFString, &value)
guard err == .success, let value else { return nil }
guard CFGetTypeID(value) == CFArrayGetTypeID() else { return nil }
let array = value as! CFArray
let count = CFArrayGetCount(array)
guard count > 0 else { return nil }
let raw = CFArrayGetValueAtIndex(array, 0)
guard let raw else { return nil }
let element = Unmanaged<AXUIElement>.fromOpaque(raw).takeUnretainedValue()
return element
}
/// Read kAXPosition (CGPoint) + kAXSize (CGSize) off a window element and
/// assemble a Quartz-space rect. `kAXPositionAttribute` is documented as
/// top-left in the global Quartz coordinate space.
static func frame(of window: AXUIElement) -> CGRect? {
guard let origin = copyPoint(window, kAXPositionAttribute),
let size = copySize(window, kAXSizeAttribute)
else { return nil }
return CGRect(origin: origin, size: size)
}
static func copyPoint(_ element: AXUIElement, _ attribute: String) -> CGPoint? {
guard let axValue = copyAXValue(element, attribute) else { return nil }
var point = CGPoint.zero
guard AXValueGetType(axValue) == .cgPoint,
AXValueGetValue(axValue, .cgPoint, &point)
else { return nil }
return point
}
static func copySize(_ element: AXUIElement, _ attribute: String) -> CGSize? {
guard let axValue = copyAXValue(element, attribute) else { return nil }
var size = CGSize.zero
guard AXValueGetType(axValue) == .cgSize,
AXValueGetValue(axValue, .cgSize, &size)
else { return nil }
return size
}
private static func copyAXValue(_ element: AXUIElement, _ attribute: String) -> AXValue? {
var value: CFTypeRef?
let err = AXUIElementCopyAttributeValue(element, attribute as CFString, &value)
guard err == .success, let value else { return nil }
guard CFGetTypeID(value) == AXValueGetTypeID() else { return nil }
return (value as! AXValue)
}
}
// MARK: - WindowFrameTracker
@MainActor
public final class WindowFrameTracker {
private let pid: pid_t
private let windowID: CGWindowID?
/// Delivered on the MainActor whenever the resolved frame changes.
private var onChange: ((CGRect) -> Void)?
/// Coalesces frame/absence transitions, including the initial-absence case.
private var reportGate = WindowFrameReportGate()
// AX observer (primary strategy).
private var observer: AXObserver?
private var observedAppElement: AXUIElement?
private var observedWindowElement: AXUIElement?
private var runLoopSource: CFRunLoopSource?
// Polling fallback.
private var pollTimer: DispatchSourceTimer?
private let pollQueue = DispatchQueue(label: "dev.cchaha.cu-helper.window-tracker")
private var screenParamsObserver: NSObjectProtocol?
/// NSWorkspace app-level hide/unhide/activate observers. AX has no reliable
/// app-hide (Cmd+H) notification — on hide the window simply leaves the
/// on-screen list with no kAXMiniaturized — so we watch the workspace to hide
/// / restore the glow when the tracked app is hidden or comes back.
private var workspaceObservers: [NSObjectProtocol] = []
private var started = false
public init(pid: pid_t, windowID: CGWindowID?) {
self.pid = pid
self.windowID = windowID
}
deinit {
// `stop()` is @MainActor and tears everything down; by the time we are
// deallocated the run-loop source / observer / timer must already be
// gone (CFRunLoopSource and AXObserver hold a +1 on us via the refcon
// would otherwise keep us alive). We intentionally do nothing here.
}
// MARK: Public API
public func start(onChange: @escaping (CGRect) -> Void) {
if started { stop() }
started = true
self.onChange = onChange
// React to display arrangement / resolution changes — the flip axis
// (primary screen height) and the window's screen can both change, so
// re-resolve from scratch.
screenParamsObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self else { return }
self.rebindObserverWindow()
self.refresh(force: true)
}
}
installWorkspaceObservers()
// Install the low-latency AX observer when possible, but ALWAYS run the
// ~30 Hz CGWindowList poll alongside it. The poll is TCC-free and
// session-independent, so it is the reliable backstop for BOTH follow
// (window moved/resized) and hide (app hidden via Cmd+H / window closed /
// minimized → the window drops off the on-screen list) when the AX and
// NSWorkspace callbacks don't fire reliably in the daemon's `.accessory`,
// launchd-reparented process context. Duplicate deliveries are coalesced
// by the frame diff in `refresh`, so running both is cheap and never
// churns the overlay. (Previously the poll ran ONLY when the AX observer
// failed to install — so with AX granted, follow/hide depended entirely
// on callbacks that may never arrive, leaving the glow stuck.)
_ = installObserver()
startPolling()
// Deliver the initial frame immediately so the overlay is positioned
// on the very first paint, before any move/resize event arrives.
refresh(force: true)
}
public func stop() {
started = false
onChange = nil
reportGate.reset()
if let screenParamsObserver {
NotificationCenter.default.removeObserver(screenParamsObserver)
self.screenParamsObserver = nil
}
removeWorkspaceObservers()
stopPolling()
teardownObserver()
}
public func currentCocoaFrame() -> CGRect? {
if let current = reportGate.currentFrame { return current }
guard let quartz = resolveQuartzFrame() else { return nil }
return FrameResolver.toCocoa(quartz)
}
// MARK: Geometry resolution
/// Resolve the target's current frame in QUARTZ space, preferring AX (more
/// accurate, picks the *focused* window) and falling back to the window
/// server (TCC-free).
private func resolveQuartzFrame() -> CGRect? {
if let axRect = FrameResolver.axFrame(pid: pid) {
return axRect
}
return FrameResolver.cgWindowFrame(pid: pid, windowID: windowID)
}
/// Re-resolve and, if the Cocoa frame moved (or `force`), notify. Always
/// runs on the MainActor; safe to call from the AX callback or the screen
/// param observer.
private func refresh(force: Bool) {
guard started else { return }
// Only show the glow while the controlled app is frontmost. Computer Use now
// runs in the BACKGROUND (no forced activate), so the controlled window
// usually isn't frontmost — the glow then stays hidden and never strands on
// top of whatever the user switched to; it reappears the instant the user
// brings the controlled app forward. Same gate as the virtual cursor's
// syncVisibilityToFrontmost(); the ~30 Hz poll re-evaluates it continuously.
guard NSWorkspace.shared.frontmostApplication?.processIdentifier == pid else {
reportDisappeared()
return
}
guard let quartz = resolveQuartzFrame() else {
// The window is gone — app hidden (Cmd+H), all windows closed, or
// minimized (drops out of the on-screen list). Report absence ONCE so
// the overlay hides; this previously returned silently and the glow
// was stranded over the now-empty spot ("App 隐藏了 glow 还在原位").
reportDisappeared()
return
}
let cocoa = FrameResolver.toCocoa(quartz)
deliver(cocoa, force: force)
}
/// Report that the tracked window vanished — exactly once — by delivering a
/// null frame, which `CaptureGlowOverlay` treats as "hide". Idempotent: a
/// no-op once we've already signalled absence, so the ~30 Hz poll and
/// repeated hide notifications don't churn the overlay.
private func reportDisappeared() {
deliver(nil, force: false)
}
private func deliver(_ frame: CGRect?, force: Bool) {
guard let report = reportGate.consume(frame, force: force) else { return }
switch report {
case let .frame(frame):
onChange?(frame)
case .absent:
onChange?(.null)
}
}
// MARK: Workspace hide/unhide (app-level)
/// Watch NSWorkspace for the target app being hidden (Cmd+H), unhidden, or
/// re-activated. Unlike AX move/resize/miniaturize, an app *hide* posts no AX
/// geometry notification — the windows simply leave the on-screen list — so
/// the glow would otherwise stay stranded where the window used to be.
private func installWorkspaceObservers() {
let nc = NSWorkspace.shared.notificationCenter
// Hidden → the window is no longer on screen; hide the glow.
workspaceObservers.append(nc.addObserver(
forName: NSWorkspace.didHideApplicationNotification,
object: nil,
queue: .main
) { [weak self] note in
// Read the (non-Sendable) Notification on the delivery queue first;
// only the Sendable pid crosses into actor isolation.
let notePid = Self.notePid(note)
MainActor.assumeIsolated {
guard let self, self.started, notePid == self.pid else { return }
self.reportDisappeared()
}
})
// Unhidden → the controlled app's window is back; re-resolve & re-anchor.
workspaceObservers.append(nc.addObserver(
forName: NSWorkspace.didUnhideApplicationNotification,
object: nil,
queue: .main
) { [weak self] note in
let notePid = Self.notePid(note)
MainActor.assumeIsolated {
guard let self, self.started, notePid == self.pid else { return }
self.refresh(force: true)
}
})
// ANY app activation → re-evaluate. refresh()'s frontmost gate shows the
// glow when the controlled app comes forward and hides it when the user
// switches to a different app. Deliberately NOT guarded on self.pid — we
// must react precisely when the user activates some OTHER app, to hide.
workspaceObservers.append(nc.addObserver(
forName: NSWorkspace.didActivateApplicationNotification,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
guard let self, self.started else { return }
self.refresh(force: true)
}
})
}
private func removeWorkspaceObservers() {
let nc = NSWorkspace.shared.notificationCenter
for token in workspaceObservers { nc.removeObserver(token) }
workspaceObservers.removeAll()
}
/// The pid an NSWorkspace app notification refers to. Read off the delivery
/// queue BEFORE hopping into MainActor isolation — `Notification` isn't
/// `Sendable`, but the resulting pid (a `Sendable` Int32) crosses cleanly.
private nonisolated static func notePid(_ note: Notification) -> pid_t? {
(note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)?
.processIdentifier
}
// MARK: AX observer (primary)
/// Build the observer, subscribe to the geometry/lifecycle notifications on
/// both the app and its focused window, and splice the run-loop source onto
/// the main run loop. Returns `false` if the observer could not be created
/// or no notification could be registered (→ caller starts polling).
private func installObserver() -> Bool {
guard pid > 0 else { return false }
// Without Accessibility the observer can't read geometry anyway — skip
// straight to the TCC-free polling fallback so we never risk a blocking
// AX call on the main run loop.
guard AXIsProcessTrusted() else { return false }
var observerRef: AXObserver?
let createErr = AXObserverCreateWithInfoCallback(pid, axCallback, &observerRef)
guard createErr == .success, let observerRef else { return false }
observer = observerRef
let appElement = AXUIElementCreateApplication(pid)
AXUIElementSetMessagingTimeout(appElement, 0.25)
observedAppElement = appElement
// Unretained pointer to self handed to the C callback as refcon. We do
// NOT take a +1 (no retain cycle) — `stop()`/`teardownObserver` runs
// while `self` is guaranteed alive (the overlay owns the tracker).
let refcon = Unmanaged.passUnretained(self).toOpaque()
// App-level notifications: focus changes + a window being destroyed/
// minimized bubble here regardless of which window currently has focus.
var addedAny = false
for note in [
kAXFocusedWindowChangedNotification,
kAXWindowMiniaturizedNotification,
kAXUIElementDestroyedNotification,
kAXMainWindowChangedNotification,
] {
let err = AXObserverAddNotification(observerRef, appElement, note as CFString, refcon)
if err == .success { addedAny = true }
}
// Window-level notifications: move/resize fire on the window element
// itself, so bind them to the currently focused window.
bindWindowNotifications(observer: observerRef, refcon: refcon)
guard addedAny || observedWindowElement != nil else {
teardownObserver()
return false
}
let source = AXObserverGetRunLoopSource(observerRef)
runLoopSource = source
CFRunLoopAddSource(CFRunLoopGetMain(), source, .defaultMode)
return true
}
/// Subscribe to kAXMoved/kAXResized on the app's CURRENT focused window and
/// remember the element so we can unbind on rebind/teardown.
private func bindWindowNotifications(observer: AXObserver, refcon: UnsafeMutableRawPointer) {
guard let appElement = observedAppElement else { return }
guard let window = AXUtil.copyElement(appElement, kAXFocusedWindowAttribute)
?? AXUtil.firstWindow(of: appElement)
else { return }
observedWindowElement = window
for note in [
kAXMovedNotification,
kAXResizedNotification,
kAXUIElementDestroyedNotification,
] {
_ = AXObserverAddNotification(observer, window, note as CFString, refcon)
}
}
/// On focus change we must move the move/resize subscriptions to the new
/// focused window. Unbind the old one, bind the new one.
private func rebindObserverWindow() {
guard let observer, started else { return }
let refcon = Unmanaged.passUnretained(self).toOpaque()
if let old = observedWindowElement {
for note in [
kAXMovedNotification,
kAXResizedNotification,
kAXUIElementDestroyedNotification,
] {
_ = AXObserverRemoveNotification(observer, old, note as CFString)
}
observedWindowElement = nil
}
bindWindowNotifications(observer: observer, refcon: refcon)
}
private func teardownObserver() {
if let source = runLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .defaultMode)
runLoopSource = nil
}
if let observer {
if let window = observedWindowElement {
for note in [
kAXMovedNotification,
kAXResizedNotification,
kAXUIElementDestroyedNotification,
] {
_ = AXObserverRemoveNotification(observer, window, note as CFString)
}
}
if let app = observedAppElement {
for note in [
kAXFocusedWindowChangedNotification,
kAXWindowMiniaturizedNotification,
kAXUIElementDestroyedNotification,
kAXMainWindowChangedNotification,
] {
_ = AXObserverRemoveNotification(observer, app, note as CFString)
}
}
}
observer = nil
observedAppElement = nil
observedWindowElement = nil
}
/// Called from the C trampoline (already hopped onto the MainActor).
fileprivate func handleAXNotification(_ notification: String) {
guard started else { return }
// Focus moved → re-point the move/resize subscriptions before reading.
if notification == kAXFocusedWindowChangedNotification as String
|| notification == kAXMainWindowChangedNotification as String
{
rebindObserverWindow()
}
refresh(force: false)
}
// MARK: Polling fallback
private func startPolling() {
stopPolling()
let timer = DispatchSource.makeTimerSource(queue: pollQueue)
// ~30 Hz, generous leeway so we don't spin the CPU when nothing moves.
timer.schedule(deadline: .now(), repeating: .milliseconds(33), leeway: .milliseconds(8))
// The handler MUST be @Sendable (i.e. NONISOLATED): it runs on `pollQueue`,
// NOT the MainActor. WindowFrameTracker is @MainActor, so a plain closure
// here is inferred @MainActor — and Swift 6's runtime isolation check
// (`swift_task_isCurrentExecutor`) SIGTRAPs when libdispatch invokes it on
// the background queue, crashing the ENTIRE daemon. That is exactly what
// happened once the poll started running alongside the AX observer: the
// first injection shows the overlay → starts this tracker → the 33 ms timer
// fires on pollQueue → trap → the client sees "cu-helper daemon reset:
// socket closed" and every click/type fails. The @Sendable closure touches
// no isolated state; it only hops to the MainActor to diff + deliver.
let handler: @Sendable () -> Void = { [weak self] in
Task { @MainActor in self?.refresh(force: false) }
}
timer.setEventHandler(handler: handler)
pollTimer = timer
timer.resume()
}
private func stopPolling() {
pollTimer?.cancel()
pollTimer = nil
}
}
// MARK: - C callback trampoline
/// AX observer callback. Fires on whatever run loop the observer source was
/// added to — here, the MAIN run loop (`CFRunLoopGetMain()`), i.e. the main
/// thread, which is the MainActor's executor. We recover the tracker from the
/// unretained refcon and hop into MainActor isolation to do the real work.
///
/// `AXObserverCreateWithInfoCallback`'s callback may not capture context, so
/// this must be a free C function; state travels through `refcon`.
private func axCallback(
_ observer: AXObserver,
_ element: AXUIElement,
_ notification: CFString,
_ info: CFDictionary?,
_ refcon: UnsafeMutableRawPointer?
) {
guard let refcon else { return }
let tracker = Unmanaged<WindowFrameTracker>.fromOpaque(refcon).takeUnretainedValue()
let note = notification as String
// The source lives on the main run loop, so we are already on the main
// thread; assert that isolation rather than bouncing through another hop.
MainActor.assumeIsolated {
tracker.handleAXNotification(note)
}
}
@@ -1,37 +0,0 @@
import CoreGraphics
import XCTest
@testable import cc_haha_computer_use
final class WindowFrameTrackerTests: XCTestCase {
private let frame = CGRect(x: 10, y: 20, width: 300, height: 200)
func testInitialAbsenceEmitsOnceAndRepeatedAbsenceCoalesces() {
var gate = WindowFrameReportGate()
XCTAssertEqual(gate.consume(nil), .absent)
XCTAssertNil(gate.consume(nil))
}
func testAbsenceThenFrameEmitsFrame() {
var gate = WindowFrameReportGate()
_ = gate.consume(nil)
XCTAssertEqual(gate.consume(frame), .frame(frame))
}
func testFrameThenAbsenceEmitsNull() {
var gate = WindowFrameReportGate()
_ = gate.consume(frame)
XCTAssertEqual(gate.consume(nil), .absent)
}
func testStopResetsGateToUnreported() {
var gate = WindowFrameReportGate()
_ = gate.consume(nil)
gate.reset()
XCTAssertEqual(gate.consume(nil), .absent)
}
}