From a54760552b296c8a7d0365072042e5c6b6288900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 6 Aug 2026 04:51:14 +0800 Subject: [PATCH] refactor(computer-use): drop the capture glow, keep the virtual cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/internals/computer-use-codex-redesign.md | 2 +- .../cu-helper/CaptureGlowOverlay.swift | 921 ------------------ .../cu-helper/Sources/cu-helper/Daemon.swift | 122 +-- .../Sources/cu-helper/VirtualCursor.swift | 17 + .../cu-helper/WindowFrameTracker.swift | 669 ------------- .../WindowFrameTrackerTests.swift | 37 - src/utils/computerUse/cleanup.test.ts | 2 +- src/utils/computerUse/cleanup.ts | 4 +- src/utils/computerUse/cuHelperDaemon.ts | 6 +- src/utils/computerUse/helperBridge.ts | 10 +- 10 files changed, 66 insertions(+), 1724 deletions(-) delete mode 100644 native/cu-helper/Sources/cu-helper/CaptureGlowOverlay.swift delete mode 100644 native/cu-helper/Sources/cu-helper/WindowFrameTracker.swift delete mode 100644 native/cu-helper/Tests/CuHelperTests/WindowFrameTrackerTests.swift diff --git a/docs/internals/computer-use-codex-redesign.md b/docs/internals/computer-use-codex-redesign.md index aebf0612..07511063 100644 --- a/docs/internals/computer-use-codex-redesign.md +++ b/docs/internals/computer-use-codex-redesign.md @@ -180,7 +180,7 @@ All anchors confirmed: `CommandRouter.swift:234` awaits the blocking glide befor | **Python 兜底层** | `runtime/mac_helper.py`、`pythonBridge.ts`、`callPythonHelper` 别名 | macOS 死重 | | **CLI 一次性兜底**(评估后) | `helperBridge.ts` 回退链 | macOS 只留 daemon | | **base64-over-NDJSON 截图**(若换 XPC 则连 socket 一起;若留 socket 则至少改二进制帧) | `cuHelperDaemon.ts:287-305`、`Daemon.swift` | 去编解码税 | -| **30Hz `CGWindowList` 轮询**(评估) | `WindowFrameTracker.swift` | glow 跟随改纯 AXObserver(`KeyWindowTracker` 已是事件驱动,轮询是 fallback,可砍) | +| ~~**30Hz `CGWindowList` 轮询**(评估)~~ **已解决** | ~~`WindowFrameTracker.swift`~~(已删) | 该轮询只服务 glow 边框跟随。边框本身已移除(视觉反馈只保留虚拟光标),`WindowFrameTracker` 随之删除,轮询不复存在 | ### 3.4 确认策略复刻(SKILL.md 4 档,逐字) diff --git a/native/cu-helper/Sources/cu-helper/CaptureGlowOverlay.swift b/native/cu-helper/Sources/cu-helper/CaptureGlowOverlay.swift deleted file mode 100644 index c4f8ae24..00000000 --- a/native/cu-helper/Sources/cu-helper/CaptureGlowOverlay.swift +++ /dev/null @@ -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() - } -} diff --git a/native/cu-helper/Sources/cu-helper/Daemon.swift b/native/cu-helper/Sources/cu-helper/Daemon.swift index 21c3850e..d0e83fe1 100644 --- a/native/cu-helper/Sources/cu-helper/Daemon.swift +++ b/native/cu-helper/Sources/cu-helper/Daemon.swift @@ -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":"","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() } } diff --git a/native/cu-helper/Sources/cu-helper/VirtualCursor.swift b/native/cu-helper/Sources/cu-helper/VirtualCursor.swift index 5ca0686a..1ec497d4 100644 --- a/native/cu-helper/Sources/cu-helper/VirtualCursor.swift +++ b/native/cu-helper/Sources/cu-helper/VirtualCursor.swift @@ -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 { diff --git a/native/cu-helper/Sources/cu-helper/WindowFrameTracker.swift b/native/cu-helper/Sources/cu-helper/WindowFrameTracker.swift deleted file mode 100644 index 254eb4e7..00000000 --- a/native/cu-helper/Sources/cu-helper/WindowFrameTracker.swift +++ /dev/null @@ -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.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.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) - } -} diff --git a/native/cu-helper/Tests/CuHelperTests/WindowFrameTrackerTests.swift b/native/cu-helper/Tests/CuHelperTests/WindowFrameTrackerTests.swift deleted file mode 100644 index 9691307b..00000000 --- a/native/cu-helper/Tests/CuHelperTests/WindowFrameTrackerTests.swift +++ /dev/null @@ -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) - } -} diff --git a/src/utils/computerUse/cleanup.test.ts b/src/utils/computerUse/cleanup.test.ts index 5f3fa4bb..c4638f91 100644 --- a/src/utils/computerUse/cleanup.test.ts +++ b/src/utils/computerUse/cleanup.test.ts @@ -40,7 +40,7 @@ describe('cleanupComputerUseAfterTurn — turn-end overlay hide', () => { test('calls the injected overlayHide exactly once on a no-hidden-apps, lock-not-held turn', async () => { // Proves overlayHide runs even when the turn hid nothing AND the lock-release // path early-returns (isLockHeldLocally() is false in this process) — i.e. it - // runs BEFORE that early return, so the glow never gets stuck. + // runs BEFORE that early return, so the cursor never gets stuck. let hidden = 0 await cleanupComputerUseAfterTurn(makeCtx(), { overlayHide: async () => { diff --git a/src/utils/computerUse/cleanup.ts b/src/utils/computerUse/cleanup.ts index 104da05a..24d8994a 100644 --- a/src/utils/computerUse/cleanup.ts +++ b/src/utils/computerUse/cleanup.ts @@ -14,7 +14,7 @@ import { unregisterEscHotkey } from './escHotkey.js' // background regardless; we just stop blocking on it. const UNHIDE_TIMEOUT_MS = 5000 -// The macOS cu-helper daemon's glow/cursor overlay must drop at every turn +// The macOS cu-helper daemon's cursor overlay must drop at every turn // boundary. overlayHide() rides the daemon's 20s REQUEST_TIMEOUT_MS; on an // abort path a wedged daemon could otherwise stall lock release for that long, // so we cap the wait here (same non-blocking discipline as UNHIDE_TIMEOUT_MS). @@ -52,7 +52,7 @@ export async function cleanupComputerUseAfterTurn( deps: { overlayHide?: () => Promise } = {}, ): Promise { // Drop the daemon overlay FIRST — before the hidden-apps block and before the - // isLockHeldLocally early-return below — so the glow fades promptly even on a + // isLockHeldLocally early-return below — so the cursor drops promptly even on a // turn that hid no apps and whose lock-release short-circuits. overlayHide // self-guards on its module-level `overlayShown`, so calling it unconditionally // is a single bool check (a pure no-op) off-daemon / on Windows. diff --git a/src/utils/computerUse/cuHelperDaemon.ts b/src/utils/computerUse/cuHelperDaemon.ts index b3a82826..659e00aa 100644 --- a/src/utils/computerUse/cuHelperDaemon.ts +++ b/src/utils/computerUse/cuHelperDaemon.ts @@ -11,7 +11,7 @@ import { getRuntimePaths } from './pythonBridge.js' * Long-lived `cu-helper daemon` client (macOS only). * * The daemon owns the main run loop that the animated virtual cursor and the - * glowing capture overlay need, and holds the virtual cursor's position + + * animated virtual cursor needs, and holds the virtual cursor's position + * held-input state across commands. We spawn ONE daemon per CLI process, keep * an AF_UNIX socket open to it, and speak the NDJSON request/response protocol: * @@ -20,7 +20,7 @@ import { getRuntimePaths } from './pythonBridge.js' * response : {"id":"","ok":true,"result":...}\n | {"id":"","ok":false,"error":{...}}\n * * Routing through the daemon (instead of one-shot CLI) is what makes execution - * VISIBLE — the user sees the AI cursor glide and the target window glow while + * VISIBLE — the user sees the AI cursor glide over the target window while * their real mouse stays free. * * Resilience: pre-dispatch launch/connect failures are typed so helperBridge can @@ -690,7 +690,7 @@ function scheduleOverlayReconciliation(): Promise { return overlayReconcilePromise } -/** Reveal/retarget the animated cursor + glow to an explicit app selector. */ +/** Reveal/retarget the animated cursor to an explicit app selector. */ export function overlayShow( target: Record = {}, ): Promise { diff --git a/src/utils/computerUse/helperBridge.ts b/src/utils/computerUse/helperBridge.ts index 98953a8d..d247902b 100644 --- a/src/utils/computerUse/helperBridge.ts +++ b/src/utils/computerUse/helperBridge.ts @@ -40,14 +40,14 @@ function maybeRestartDaemonForGrant(err: unknown, restart: () => void): void { restart() } -// Canonical selector last requested for the glow. The explicit action target, +// Canonical selector last requested for the cursor. The explicit action target, // never the frontmost app, is the source of truth. overlayHide() makes // isOverlayShown() false at turn end, so the next turn re-shows even when this // key is unchanged. let lastOverlayTargetKey: string | undefined /** - * Commands that visibly drive the screen. The daemon's animated cursor + glow + * Commands that visibly drive the screen. The daemon's animated cursor * overlay should be on-screen while these run, so we (idempotently) show the * overlay before dispatching one. Read-only commands (screenshot, displays, * apps, clipboard, permissions) don't trigger the overlay. @@ -57,7 +57,7 @@ const INJECTION_COMMANDS = new Set([ 'click', 'type', 'key', 'hold_key', 'scroll', 'drag', 'move_mouse', 'mouse_down', 'mouse_up', // Codex contract verbs: get_app_state is each turn's opener (best place to - // re-aim the glow at the target app), and the index-action verbs so a + // re-aim the cursor at the target app), and the index-action verbs so a // pure-fill turn (no coordinate click) still lights the overlay. 'get_app_state', 'set_value', 'select_text', 'perform_secondary_action', 'type_text', 'press_key', @@ -91,7 +91,7 @@ function overlayTargetPayload( * Platform-routed Computer Use helper call. * * - macOS → authenticated native `cu-helper` DAEMON only (animated virtual - * cursor + glowing target overlay; no cursor steal). There is no + * cursor overlay; no cursor steal). There is no * stateless CLI fallback: the helper rejects direct one-shot * screenshot, mutation, clipboard and app commands. * - Windows → the Python helper (`win_helper.py`); the native engine is @@ -129,7 +129,7 @@ export async function callHelper( } if (INJECTION_COMMANDS.has(command)) { - // The action's explicit selector is the only valid glow target. This is + // The action's explicit selector is the only valid cursor target. This is // fire-and-forget so visual feedback stays off the mutation hot path; // cuHelperDaemon serializes show/hide and deduplicates the wire call. const overlayTarget = overlayTargetPayload(payload)