Skip to content
Open
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
21 changes: 15 additions & 6 deletions Sources/Fluid/Services/TextSelectionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ final class TextSelectionService {
return nil
}

guard range.location != kCFNotFound, range.length > 0 else {
guard range.location >= 0, range.location != NSNotFound, range.length > 0 else {
self.diag("Selected range empty (location=\(range.location), length=\(range.length))")
return nil
}
Expand All @@ -121,19 +121,28 @@ final class TextSelectionService {
}

let nsText = fullText as NSString
guard range.location >= 0,
range.length > 0,
range.location + range.length <= nsText.length
else {
guard let safeRange = Self.boundedSelectionRange(range, textLength: nsText.length) else {
self.diag("Selected range out of bounds (textLen=\(nsText.length), location=\(range.location), length=\(range.length))")
return nil
}

let extracted = nsText.substring(with: NSRange(location: range.location, length: range.length))
let extracted = nsText.substring(with: safeRange)
self.diag("Selected range extraction succeeded (chars=\(extracted.count))")
return extracted
}

/// Overflow-safe bounds validation for an AX-derived selection range against `textLength`.
/// Returns the in-bounds `NSRange`, or nil when the range is invalid or out of bounds.
/// macOS 26 can report `kAXSelectedTextRangeAttribute` with `location == NSNotFound` (`Int.max`);
/// using `addingReportingOverflow` here prevents the `location + length` integer-overflow trap
/// such a value would otherwise cause (the same #319 crash class as TypingService).
static func boundedSelectionRange(_ range: CFRange, textLength: Int) -> NSRange? {
guard range.location >= 0, range.location != NSNotFound, range.length > 0 else { return nil }
let (rangeEnd, overflowed) = range.location.addingReportingOverflow(range.length)
guard !overflowed, rangeEnd <= textLength else { return nil }
return NSRange(location: range.location, length: range.length)
}

private func describe(_ error: AXError) -> String {
switch error {
case .success: return "success"
Expand Down
59 changes: 44 additions & 15 deletions Sources/Fluid/Services/TypingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,12 @@ final class TypingService {

var range = CFRange()
let ok = AXValueGetValue(unsafeBitCast(axValue, to: AXValue.self), .cfRange, &range)
// Return the raw range. macOS 26 can report a sentinel `{location: NSNotFound, length: 0}`,
// but both callers handle it safely: `insertTextAtCursorUsingSelectedRange` clamps the
// location to the field length (inserting at the end, preserving contents), and the deferred
// verification path runs it through the overflow-safe `caretMovedExpectedDistance`. Rejecting
// the sentinel in this shared getter instead caused approach-0 insertion to bail to a
// whole-field overwrite (#319 regression), so sanitization stays out of the getter.
return ok ? range : nil
}

Expand Down Expand Up @@ -1062,13 +1068,9 @@ final class TypingService {

if let before = snapshot.appScriptSelectedRange,
let after = current.appScriptSelectedRange,
after.length == 0
Self.caretMovedExpectedDistance(before: before, after: after, expectedLength: expectedLength, tolerance: tolerance)
{
let expectedCaretLocation = before.location + expectedLength
let caretDelta = abs(after.location - expectedCaretLocation)
if caretDelta <= tolerance {
return .appScriptCaretMovedExpectedDistance
}
return .appScriptCaretMovedExpectedDistance
}

if let currentValue = current.value,
Expand All @@ -1080,19 +1082,33 @@ final class TypingService {

if let before = snapshot.selectedRange,
let after = current.selectedRange,
after.length == 0
Self.caretMovedExpectedDistance(before: before, after: after, expectedLength: expectedLength, tolerance: tolerance)
{
let expectedCaretLocation = before.location + expectedLength
let caretDelta = abs(after.location - expectedCaretLocation)
if caretDelta <= tolerance {
return .caretMovedExpectedDistance
}
return .caretMovedExpectedDistance
}
}

return .timeout
}

/// Returns true when `after`'s collapsed caret sits within `tolerance` of where inserting
/// `expectedLength` characters at `before` should have left it. Overflow-safe: a selection
/// `location` of `NSNotFound`/`Int.max` (which macOS 26 can report) no longer traps on the
/// `before.location + expectedLength` addition that crashed the deferred verification path (#319).
static func caretMovedExpectedDistance(
before: CFRange,
after: CFRange,
expectedLength: Int,
tolerance: Int
) -> Bool {
guard after.length == 0, tolerance >= 0 else { return false }
let (expectedCaretLocation, addOverflowed) = before.location.addingReportingOverflow(expectedLength)
guard !addOverflowed else { return false }
let (caretDelta, subOverflowed) = after.location.subtractingReportingOverflow(expectedCaretLocation)
guard !subOverflowed else { return false }
return caretDelta.magnitude <= UInt(tolerance)
}

private func captureAppScriptTextSnapshot(forBundleIdentifier bundleIdentifier: String?) -> AppScriptTextSnapshot? {
switch bundleIdentifier {
case "com.apple.dt.Xcode":
Expand Down Expand Up @@ -1173,9 +1189,10 @@ final class TypingService {
let currentNSString = currentValue as NSString
let maxLen = currentNSString.length

let safeLoc = max(0, min(range.location, maxLen))
let safeLen = max(0, min(range.length, maxLen - safeLoc))
range = CFRange(location: safeLoc, length: safeLen)
// Clamp the AX-reported range into the field. A macOS 26 sentinel location of NSNotFound
// (Int.max) clamps to the end of the field, so the text is inserted at the end rather than
// triggering a bail-out (and the whole-field overwrite that approach 1 would perform). #319.
range = Self.clampedInsertionRange(range, textLength: maxLen)

let mutable = NSMutableString(string: currentValue)
mutable.replaceCharacters(in: NSRange(location: range.location, length: range.length), with: text)
Expand All @@ -1198,6 +1215,18 @@ final class TypingService {
return true
}

/// Clamps an AX-derived selection range into a field of `textLength` UTF-16 units.
/// A sentinel location of `NSNotFound` (`Int.max`), which macOS 26 can report when there is no
/// valid caret, clamps to the end of the field (`{textLength, 0}`) so insertion appends rather
/// than bailing out. The returned location/length are always within `0...textLength`, so callers
/// can safely add a (bounded) inserted length without overflowing (#319).
static func clampedInsertionRange(_ range: CFRange, textLength: Int) -> CFRange {
let safeMax = max(0, textLength)
let safeLoc = max(0, min(range.location, safeMax))
let safeLen = max(0, min(range.length, safeMax - safeLoc))
return CFRange(location: safeLoc, length: safeLen)
}

// Why is it working now? And why is it not working now?
private func setTextViaValue(_ element: AXUIElement, _ text: String) -> Bool {
let cfText = text as CFString
Expand Down
77 changes: 77 additions & 0 deletions Tests/FluidDictationIntegrationTests/DictationE2ETests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,83 @@ final class DictationE2ETests: XCTestCase {
XCTAssertFalse(SimpleUpdater.isRollbackVersion(nil, differentFrom: "1.5.11-beta.3"))
}

// MARK: - TypingService selection-range safety (#319)

func testClampedInsertionRange_nsNotFoundLocationClampsToEnd() {
// Regression for #319: macOS 26 can report {location: NSNotFound, length: 0}. The AX-direct
// insertion path must clamp that sentinel to the end of the field ({textLength, 0}) so the
// dictated text is inserted at the end, NOT bail (which would fall through to a whole-field
// overwrite via setTextViaValue and lose the field's existing contents).
let clamped = TypingService.clampedInsertionRange(CFRange(location: NSNotFound, length: 0), textLength: 42)
XCTAssertEqual(clamped.location, 42)
XCTAssertEqual(clamped.length, 0)
}

func testClampedInsertionRange_clampsLocationAndLengthIntoBounds() {
// Over-long location/length get clamped to stay inside the field.
let clamped = TypingService.clampedInsertionRange(CFRange(location: 100, length: 50), textLength: 20)
XCTAssertEqual(clamped.location, 20)
XCTAssertEqual(clamped.length, 0)
}

func testClampedInsertionRange_passesValidRangeUnchanged() {
let clamped = TypingService.clampedInsertionRange(CFRange(location: 5, length: 3), textLength: 20)
XCTAssertEqual(clamped.location, 5)
XCTAssertEqual(clamped.length, 3)
}

func testCaretMovedExpectedDistance_nsNotFoundLocationDoesNotTrap() {
// Regression for #319: before.location == NSNotFound (Int.max) must not trap on
// `before.location + expectedLength`; it should simply report "not moved as expected".
let before = CFRange(location: NSNotFound, length: 0)
let after = CFRange(location: 5, length: 0)
XCTAssertFalse(
TypingService.caretMovedExpectedDistance(before: before, after: after, expectedLength: 5, tolerance: 2)
)
}

func testCaretMovedExpectedDistance_withinToleranceReturnsTrue() {
let before = CFRange(location: 10, length: 0)
let after = CFRange(location: 16, length: 0) // expected 15, delta 1 <= tolerance 2
XCTAssertTrue(
TypingService.caretMovedExpectedDistance(before: before, after: after, expectedLength: 5, tolerance: 2)
)
}

func testCaretMovedExpectedDistance_outsideToleranceReturnsFalse() {
let before = CFRange(location: 10, length: 0)
let after = CFRange(location: 40, length: 0) // expected 15, delta 25 > tolerance 2
XCTAssertFalse(
TypingService.caretMovedExpectedDistance(before: before, after: after, expectedLength: 5, tolerance: 2)
)
}

func testCaretMovedExpectedDistance_nonCollapsedSelectionReturnsFalse() {
let before = CFRange(location: 10, length: 0)
let after = CFRange(location: 15, length: 3) // length != 0 => not a settled caret
XCTAssertFalse(
TypingService.caretMovedExpectedDistance(before: before, after: after, expectedLength: 5, tolerance: 2)
)
}

func testBoundedSelectionRange_rejectsNSNotFoundLocationWithPositiveLength() {
// Regression for the #319 crash class in TextSelectionService: a {location: NSNotFound, length: >0}
// range slips past a `!= kCFNotFound` guard and must not trap on `location + length`.
XCTAssertNil(TextSelectionService.boundedSelectionRange(CFRange(location: NSNotFound, length: 4), textLength: 100))
}

func testBoundedSelectionRange_rejectsOutOfBoundsAndEmpty() {
XCTAssertNil(TextSelectionService.boundedSelectionRange(CFRange(location: 98, length: 5), textLength: 100))
XCTAssertNil(TextSelectionService.boundedSelectionRange(CFRange(location: 10, length: 0), textLength: 100))
XCTAssertNil(TextSelectionService.boundedSelectionRange(CFRange(location: -1, length: 4), textLength: 100))
}

func testBoundedSelectionRange_acceptsValidRange() {
let valid = TextSelectionService.boundedSelectionRange(CFRange(location: 10, length: 5), textLength: 100)
XCTAssertEqual(valid?.location, 10)
XCTAssertEqual(valid?.length, 5)
}

private static func modelDirectoryForRun() -> URL {
// Use a stable path on CI so GitHub Actions cache can speed up runs.
if ProcessInfo.processInfo.environment["GITHUB_ACTIONS"] == "true" ||
Expand Down
Loading