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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5D5AB34JDWZ565DVTAB86HD
anchor: '@file'
created: 2026-10-20T17:21:33Z
norm: '1'
sig: 27abcba5e975f16e
body_hash: c30ee390a7c45ef8
raw_hash: 179f2239638369fc
lines: 1-589
---

the receiver check comes before requireKeyboardFocus so a background web field gets PASTE_UNVERIFIABLE_RECEIVER, as on Node, not a focus refusal
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5D5AB2Y625F1Q2BN8VF36VS
anchor: '@file'
created: 2026-10-20T17:21:33Z
norm: '1'
sig: 1574ff353918c008
body_hash: 334362b01e56eab9
raw_hash: 3dd4db75427ff129
lines: 1-182
---

Safari on macOS 27 dropped AXStart/EndTextMarkerForTextMarkerRange; bounds(of:) reads both ends from the AXTextMarkerRange itself
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: '@file'
created: 2026-10-20T16:41:34Z
norm: '1'
sig: 4949218de757741f
body_hash: 1b14fb93f58c56c0
raw_hash: 1a6e762fc5d44e10
body_hash: f04bf517db564cdd
raw_hash: 4b9cffb06024b51f
vouched: 2026-10-20T17:21:46Z
lines: 1-1211
---

Expand Down
20 changes: 14 additions & 6 deletions native2/Sources/DesktopMac/Paste.swift
Original file line number Diff line number Diff line change
Expand Up @@ -330,12 +330,18 @@ extension Mac {
/// a paste only when the caller lets the clipboard stay replaced.
public static func pasteAttachment(_ payload: PastePayload, replace: Bool, target: KeyboardTarget) throws -> PasteReport {
guard target.focusedRole != nil else { throw pasteRefusal("Attachment insertion requires an exact focused receiver.") }
let finderFiles: Bool = if case .files = payload {
NSRunningApplication(processIdentifier: target.window.pid)?.bundleIdentifier == "com.apple.finder"
} else { false }
// A receiver the snapshot already shows can't show the attachment is refused as that, before keyboard focus
// is checked, so the refusal names what would stop the paste anyway.
guard finderFiles || target.focusedRole == "AXTextArea" || replace else { throw unverifiableReceiver() }
return try ClipboardGate.exclusive { transaction in
let focused = try requireKeyboardFocus(target)
if secureFocus(focused) { throw secureFocusRefusal }
var folder: FolderExpectation?
var expectation: PasteExpectation?
if case let .files(_, paths) = payload, NSRunningApplication(processIdentifier: target.window.pid)?.bundleIdentifier == "com.apple.finder" {
if finderFiles, case let .files(_, paths) = payload {
folder = try finderFolder(focused, role: target.focusedRole, paths: paths)
} else if target.focusedRole == "AXTextArea", !isWebField(focused), let baseline = ReceiverState.read(focused, target: target),
let range = Range(NSRange(location: baseline.location, length: baseline.length), in: baseline.text)
Expand All @@ -344,11 +350,7 @@ extension Mac {
guard text.utf16.count <= 65_536 else { throw pasteRefusal("The intended edit exceeds the complete text verification limit.") }
expectation = PasteExpectation(baseline: baseline, text: text, location: baseline.location + payload.count, attachments: payload.count)
}
guard expectation != nil || folder != nil || replace else {
var refusal = Refusal("PASTE_UNVERIFIABLE_RECEIVER", attachmentPasteRefusal.message, hint: attachmentPasteRefusal.hint, fromHost: false)
refusal.reason = "operation_unsupported"
throw refusal
}
guard expectation != nil || folder != nil || replace else { throw unverifiableReceiver() }
let write: TemporaryWrite
do { write = try TemporaryWrite(replacing: replace) } catch let error as TemporaryWriteError {
throw pasteRefusal("Attachment insertion could not establish its read-only preflight: \(error.description)")
Expand All @@ -357,6 +359,12 @@ extension Mac {
}
}

private static func unverifiableReceiver() -> Refusal {
var refusal = Refusal("PASTE_UNVERIFIABLE_RECEIVER", attachmentPasteRefusal.message, hint: attachmentPasteRefusal.hint, fromHost: false)
refusal.reason = "operation_unsupported"
return refusal
}

/// One temporary paste into the exact receiver, then the wait for its evidence and the clipboard's cleanup.
private static func paste(
_ payload: PastePayload, write: TemporaryWrite, expectation: PasteExpectation?, folder: FolderExpectation?, replace: Bool,
Expand Down
47 changes: 45 additions & 2 deletions native2/Sources/DesktopMac/Text.swift
Original file line number Diff line number Diff line change
Expand Up @@ -389,8 +389,13 @@ extension Mac {
) throws -> (Delivery, SelectionResult) {
AXUIElementSetMessagingTimeout(element, 1)
let unavailable = Refusal("DESKTOP_ACTION_FAILED", "The target text or selection is unreadable or changed; observe it again.")
guard AX.string(element, kAXRoleAttribute) != "AXSecureTextField", AX.string(element, kAXSubroleAttribute) != "AXSecureTextField",
settable(element, kAXSelectedTextRangeAttribute)
guard AX.string(element, kAXRoleAttribute) != "AXSecureTextField", AX.string(element, kAXSubroleAttribute) != "AXSecureTextField"
else { throw unsupported("The nonsecure target must expose a settable AXSelectedTextRange.") }
// Web text that isn't editable has no settable range, but its web area selects by text markers.
if !settable(element, kAXSelectedTextRangeAttribute), let markers = AXWebTextMarkers(element: element) {
return try selectWebText(markers, text: text, prefix: prefix, suffix: suffix, kind: kind, window: window)
}
guard settable(element, kAXSelectedTextRangeAttribute)
else { throw unsupported("The nonsecure target must expose a settable AXSelectedTextRange.") }
func state() -> (String, CFRange?)? {
guard let text = AX.string(element, kAXValueAttribute) else { return nil }
Expand Down Expand Up @@ -431,6 +436,44 @@ extension Mac {
}
}

extension Mac {
static func selectWebText(
_ markers: any WebTextMarkerAccess, text: String, prefix: String?, suffix: String?, kind: SelectionKind, window: ClickWindow
) throws -> (Delivery, SelectionResult) {
try window.check()
guard let span = markers.span() else {
throw Refusal("DESKTOP_ACTION_FAILED", "The web text's markers are unreadable; observe it again.")
}
let selection = try resolveSelection(text, prefix: prefix, suffix: suffix, kind: kind, in: span.text)
let range = selection.selected
if WebTextSelection.matches(range, in: span, markers: markers) {
return (Delivery(state: .confirmedNoChange, mechanism: nil, evidence: "verified_no_change", units: nil), selection)
}
guard let target = WebTextSelection.locate(range, in: span, markers: markers) else {
var refusal = Refusal(
"DESKTOP_ACTION_FAILED",
"The web text's markers could not be matched to this text within the step limit; nothing was selected.",
hint: "Select within a smaller element from the inspection, or read the text with desktop_read.")
refusal.reason = "operation_unsupported"
throw refusal
}
try window.check()
guard markers.select(target) else { throw unsupported("The web area did not accept a text marker selection.") }
let deadline = ContinuousClock.now.advanced(by: .seconds(1))
repeat {
if WebTextSelection.matches(range, in: span, markers: markers) {
return (Delivery(state: .confirmedChange, mechanism: accessibilityValue, evidence: "verified_change", units: 1), selection)
}
Thread.sleep(forTimeInterval: 0.05)
} while ContinuousClock.now < deadline
throw Indeterminate(
Delivery(state: .indeterminate, mechanism: accessibilityValue, evidence: "completion_unknown", units: 1),
"The web area accepted the selection, but it did not read back as the requested text.",
hint: "Observe the page before deciding whether to retry the selection."
)
}
}

// MARK: - Typed insertion

extension Mac {
Expand Down
182 changes: 182 additions & 0 deletions native2/Sources/DesktopMac/WebText.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import ApplicationServices
import Foundation

// Ported from Peekaboo's WebTextSelection.swift: web text that isn't editable has no settable AXSelectedTextRange,
// but its web area selects by WebKit's and Chromium's text markers.

/// Text markers for one element's text, read through its web area. Markers are opaque values that only the web area
/// that issued them can interpret.
protocol WebTextMarkerAccess {
/// The element's own text, with the markers at its start and end.
func span() -> WebTextSpan?
func marker(atIndex index: Int) -> CFTypeRef?
func index(of marker: CFTypeRef) -> Int?
func next(after marker: CFTypeRef) -> CFTypeRef?
func range(from start: CFTypeRef, to end: CFTypeRef) -> CFTypeRef?
func string(for range: CFTypeRef) -> String?
func bounds(of range: CFTypeRef) -> (start: CFTypeRef, end: CFTypeRef)?
func selectedRange() -> CFTypeRef?
func select(_ range: CFTypeRef) -> Bool
}

struct WebTextSpan {
let text: String
let start: CFTypeRef
let end: CFTypeRef
}

/// Maps a UTF-16 range of an element's text onto markers, and reads a selection back as that range.
enum WebTextSelection {
/// Marker stepping is a fallback for engines whose index markers disagree with the text; it stops at whichever
/// limit comes first, so a long page never walks its whole text.
static let maximumSteps = 2048
static let stepTimeBudget: Duration = .milliseconds(300)

static func locate(_ range: CFRange, in span: WebTextSpan, markers: any WebTextMarkerAccess) -> CFTypeRef? {
// An engine can answer NSNotFound for a marker's index; stepping below doesn't need one.
if let base = markers.index(of: span.start), (0...Int(Int32.max)).contains(base),
let start = markers.marker(atIndex: base + range.location),
let end = markers.marker(atIndex: base + range.location + range.length),
let candidate = markers.range(from: start, to: end),
covers(candidate, start: start, end: end, range: range, in: span, markers: markers)
{
return candidate
}
// One marker step moves one character as the engine counts it; the readback below decides.
let text = span.text.utf16
guard let prefix = String(text.prefix(range.location)),
let selected = String(text.dropFirst(range.location).prefix(range.length))
else { return nil }
let startSteps = prefix.count
let endSteps = startSteps + selected.count
guard endSteps <= maximumSteps else { return nil }
let deadline = ContinuousClock.now.advanced(by: stepTimeBudget)
var current = span.start
var start: CFTypeRef? = startSteps == 0 ? current : nil
var step = 0
while step < endSteps {
guard ContinuousClock.now < deadline, let next = markers.next(after: current) else { return nil }
current = next
step += 1
if step == startSteps { start = current }
}
guard let start, let candidate = markers.range(from: start, to: current),
covers(candidate, start: start, end: current, range: range, in: span, markers: markers)
else { return nil }
return candidate
}

/// Whether the web area's selection is exactly this range of the element's text.
static func matches(_ range: CFRange, in span: WebTextSpan, markers: any WebTextMarkerAccess) -> Bool {
guard let selected = markers.selectedRange(), let bounds = markers.bounds(of: selected) else { return false }
return covers(selected, start: bounds.start, end: bounds.end, range: range, in: span, markers: markers)
}

/// The marker range holds the requested text, with exactly the text before and after it in the element on
/// either side, so it can be neither another occurrence nor a range reaching outside the element.
private static func covers(
_ candidate: CFTypeRef, start: CFTypeRef, end: CFTypeRef, range: CFRange, in span: WebTextSpan,
markers: any WebTextMarkerAccess
) -> Bool {
let text = span.text.utf16
let total = text.count
guard range.location + range.length <= total,
let selected = markers.string(for: candidate),
selected.utf16.elementsEqual(text.dropFirst(range.location).prefix(range.length)),
let before = markers.range(from: span.start, to: start),
markers.string(for: before)?.utf16.count == range.location,
let after = markers.range(from: end, to: span.end),
markers.string(for: after)?.utf16.count == total - range.location - range.length
else { return false }
return true
}
}

/// Text markers through the element's nearest AXWebArea, with short messaging timeouts.
struct AXWebTextMarkers: WebTextMarkerAccess {
let webArea: AXUIElement
let element: AXUIElement

/// Finds the web area holding element, or nil when its ancestry leaves web content first.
init?(element: AXUIElement) {
var current = element
for _ in 0..<64 {
AXUIElementSetMessagingTimeout(current, 0.25)
var role: CFTypeRef?
guard AXUIElementCopyAttributeValue(current, kAXRoleAttribute as CFString, &role) == .success,
let role = role as? String
else { return nil }
if role == "AXWebArea" {
webArea = current
self.element = element
return
}
if role == kAXWindowRole as String || role == kAXApplicationRole as String { return nil }
var parent: CFTypeRef?
guard AXUIElementCopyAttributeValue(current, kAXParentAttribute as CFString, &parent) == .success,
let parent, CFGetTypeID(parent) == AXUIElementGetTypeID()
else { return nil }
current = unsafeDowncast(parent, to: AXUIElement.self)
}
return nil
}

func span() -> WebTextSpan? {
guard let range = copy("AXTextMarkerRangeForUIElement", element),
let text = string(for: range),
let bounds = bounds(of: range)
else { return nil }
return WebTextSpan(text: text, start: bounds.start, end: bounds.end)
}

func marker(atIndex index: Int) -> CFTypeRef? {
guard index >= 0 else { return nil }
return copy("AXTextMarkerForIndex", NSNumber(value: index))
}

func index(of marker: CFTypeRef) -> Int? {
(copy("AXIndexForTextMarker", marker) as? NSNumber)?.intValue
}

func next(after marker: CFTypeRef) -> CFTypeRef? {
copy("AXNextTextMarkerForTextMarker", marker)
}

func range(from start: CFTypeRef, to end: CFTypeRef) -> CFTypeRef? {
copy("AXTextMarkerRangeForUnorderedTextMarkers", [start, end] as CFArray)
}

func string(for range: CFTypeRef) -> String? {
copy("AXStringForTextMarkerRange", range) as? String
}

func bounds(of range: CFTypeRef) -> (start: CFTypeRef, end: CFTypeRef)? {
// Safari on macOS 27 dropped these parameterized attributes; the range itself still holds both ends.
if let start = copy("AXStartTextMarkerForTextMarkerRange", range),
let end = copy("AXEndTextMarkerForTextMarkerRange", range)
{
return (start, end)
}
guard CFGetTypeID(range) == AXTextMarkerRangeGetTypeID() else { return nil }
let markers = unsafeDowncast(range, to: AXTextMarkerRange.self)
return (AXTextMarkerRangeCopyStartMarker(markers), AXTextMarkerRangeCopyEndMarker(markers))
}

func selectedRange() -> CFTypeRef? {
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(webArea, "AXSelectedTextMarkerRange" as CFString, &value) == .success
else { return nil }
return value
}

func select(_ range: CFTypeRef) -> Bool {
AXUIElementSetAttributeValue(webArea, "AXSelectedTextMarkerRange" as CFString, range) == .success
}

private func copy(_ attribute: String, _ parameter: CFTypeRef) -> CFTypeRef? {
var value: CFTypeRef?
guard AXUIElementCopyParameterizedAttributeValue(webArea, attribute as CFString, parameter, &value) == .success
else { return nil }
return value
}
}
7 changes: 5 additions & 2 deletions scripts/live-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -859,10 +859,13 @@ check(88, "desktop_select selects a phrase in a Safari paragraph, and Command-C
const result = await m.call("desktop_select", { snapshot: snap, element: prose.id, text: "brown fox jumps" });
if (result.outcome !== "completed") return expect(false, evidence(result, "selection"));
const fresh = await s.inspect(win);
await m.call("desktop_key", { snapshot: fresh.snap, key: "c", modifiers: ["command"] });
const copy = await m.call("desktop_key", { snapshot: fresh.snap, key: "c", modifiers: ["command"] });
await sleep(300);
const clip = await m.call("desktop_clipboard_read", { format: "text" });
return expect(clip.data.text === "brown fox jumps", `${evidence(result, "selection")}; clipboard ${brief(clip.data.text, 60)}`);
return expect(
clip.data.text === "brown fox jumps",
`${evidence(result, "selection")}; Command-C ${evidence(copy)}; clipboard ${brief(clip.data.text, 60)}`,
);
});

check(88, "desktop_select selects a link's text", async (s, m) => {
Expand Down
Loading