mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
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:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user