Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions Cotabby/App/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

private let activationIndicatorController: ActivationIndicatorController
private let focusDebugOverlayController: FocusDebugOverlayController?
/// Retained for the app's lifetime because the environment owns its own `cancellables` (the only
/// subscriptions wiring the focus-poll-interval setting and the global-toggle hotkey rebind to the
/// runtime). If the environment deallocated when `init` returned, those subscriptions would be
/// cancelled and both settings would silently stop working until the next relaunch.
private let environment: CotabbyAppEnvironment
private var cancellables = Set<AnyCancellable>()
private var didStartServices = false

override init() {
CotabbyLogger.bootstrap()

// Build the dependency graph once up front so every scene/view observes the same
// long-lived objects for the entire app session. `CotabbyAppEnvironment` is a composition
// helper here; the app delegate retains the root objects it needs directly.
// long-lived objects for the entire app session.
let environment = CotabbyAppEnvironment()
self.environment = environment
permissionManager = environment.permissionManager
runtimeModel = environment.runtimeModel
modelDownloadManager = environment.modelDownloadManager
Expand Down
15 changes: 14 additions & 1 deletion Cotabby/Services/UI/OverlayController.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AppKit
import Foundation
import Logging
import SwiftUI

/// File overview:
Expand Down Expand Up @@ -183,6 +184,13 @@ final class OverlayController: SuggestionOverlayControlling {

let frame = layout.panelFrame(for: contentSize, caretRect: geometry.caretRect)

// Last-resort guard: AppKit raises on a non-finite frame. The AX ingest boundary already
// rejects NaN/Inf rects, so reaching here means the layout math produced one; skip the show
// rather than crash on the hottest path.
guard AXHelper.rectHasFiniteComponents(frame) else {
CotabbyLogger.suggestion.warning("Skipped inline overlay: computed a non-finite frame")
return
}
panel.setFrame(frame.integral, display: true)
panel.orderFrontRegardless()
}
Expand Down Expand Up @@ -231,7 +239,12 @@ final class OverlayController: SuggestionOverlayControlling {
panel.contentView = contentView
}

panel.setFrame(layout.panelFrame, display: true)
let panelFrame = layout.panelFrame
guard AXHelper.rectHasFiniteComponents(panelFrame) else {
CotabbyLogger.suggestion.warning("Skipped mirror overlay: computed a non-finite frame")
return
}
panel.setFrame(panelFrame, display: true)
panel.orderFrontRegardless()
}

Expand Down
15 changes: 15 additions & 0 deletions Cotabby/Support/AXHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,9 @@ enum AXHelper {
/// Use this for element-level rects (AXFrame) that are reliably in Cocoa points.
/// For text-range rects (BoundsForRange, TextMarker), use `validatedCocoaTextRect` instead.
static func cocoaRect(fromAccessibilityRect rect: CGRect) -> CGRect {
guard rectHasFiniteComponents(rect) else {
return .zero
}
guard !rect.isNull, rect != .zero else {
Comment on lines +593 to 596
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 CGRect.null silently collapses to .zero

CGRect.null has its origin at (+∞, +∞) in CoreGraphics, so rectHasFiniteComponents(CGRect.null) returns false and both cocoaRect and validatedCocoaTextRect now return .zero for a null rect. Previously the guard !rect.isNull branch caught it and returned the null rect itself. Any downstream caller that distinguishes null from zero (e.g. an isNull check) will now see .zero instead. Worth verifying that every caller only guards with != .zero (not .isNull) so no silent pass-through occurs after this change.

Fix in Codex Fix in Claude Code

return rect
}
Expand All @@ -605,6 +608,15 @@ enum AXHelper {
return legacyDesktopUnionFlip(rect)
}

/// True only when every component of `rect` is a finite number. Some host AX implementations
/// (Chromium/Electron, especially mid-scroll or under load) return NaN/Inf bounds; AppKit raises
/// on a non-finite window frame and `Int(.nan)` traps, so such rects are rejected here at the AX
/// ingest boundary before they can reach geometry math or `NSWindow.setFrame`.
static func rectHasFiniteComponents(_ rect: CGRect) -> Bool {
rect.origin.x.isFinite && rect.origin.y.isFinite
&& rect.size.width.isFinite && rect.size.height.isFinite
}

/// Converts a text-range AX rect to Cocoa coordinates, using the element's AXFrame (already
/// in Cocoa coordinates) as a ground-truth anchor to detect whether pixel-to-point scaling
/// is needed. This replaces the old bundle-ID heuristic with empirical geometric validation:
Expand All @@ -615,6 +627,9 @@ enum AXHelper {
fromAccessibilityRect textRect: CGRect,
anchorFrame cocoaAnchorFrame: CGRect?
) -> CGRect {
guard rectHasFiniteComponents(textRect) else {
return .zero
}
guard !textRect.isNull, textRect != .zero else {
return textRect
}
Expand Down
16 changes: 16 additions & 0 deletions CotabbyTests/AXTextGeometryResolverTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,20 @@ final class AXTextGeometryResolverTests: XCTestCase {
XCTAssertTrue(resolver.rectIsNearAnchor(rect, anchor: nil))
XCTAssertTrue(resolver.rectIsNearAnchor(rect, anchor: .zero))
}

// MARK: - Non-finite AX rect rejection (crash guard)

func test_rectHasFiniteComponents_rejectsNaNAndInfinity() {
XCTAssertTrue(AXHelper.rectHasFiniteComponents(CGRect(x: 1, y: 2, width: 3, height: 4)))
XCTAssertFalse(AXHelper.rectHasFiniteComponents(CGRect(x: CGFloat.nan, y: 0, width: 10, height: 10)))
XCTAssertFalse(AXHelper.rectHasFiniteComponents(CGRect(x: 0, y: 0, width: CGFloat.infinity, height: 10)))
XCTAssertFalse(AXHelper.rectHasFiniteComponents(CGRect(x: 0, y: CGFloat.nan, width: 10, height: CGFloat.nan)))
}

func test_validatedCocoaTextRect_collapsesNonFiniteRectToZero() {
// A NaN/Inf AX rect must collapse to .zero, never propagate toward NSWindow.setFrame (a crash).
let nan = CGRect(x: CGFloat.nan, y: 10, width: 20, height: 14)
XCTAssertEqual(AXHelper.validatedCocoaTextRect(fromAccessibilityRect: nan, anchorFrame: nil), .zero)
XCTAssertEqual(AXHelper.cocoaRect(fromAccessibilityRect: nan), .zero)
}
Comment on lines +159 to +164
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 CGFloat.infinity and CGRect.null not covered by collapse test

test_validatedCocoaTextRect_collapsesNonFiniteRectToZero only exercises a NaN input. Adding a CGFloat.infinity case and a CGRect.null case (which now also returns .zero due to the new finite guard) would round out coverage and document the newly observed behavior for CGRect.null.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

}