diff --git a/.lane/memory/native2/Sources/DesktopMac/Paste.swift/01M5D5AB34JDWZ565DVTAB86HD-the-receiver-check-comes-bef.md b/.lane/memory/native2/Sources/DesktopMac/Paste.swift/01M5D5AB34JDWZ565DVTAB86HD-the-receiver-check-comes-bef.md new file mode 100644 index 0000000..8daaa9e --- /dev/null +++ b/.lane/memory/native2/Sources/DesktopMac/Paste.swift/01M5D5AB34JDWZ565DVTAB86HD-the-receiver-check-comes-bef.md @@ -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 diff --git a/.lane/memory/native2/Sources/DesktopMac/WebText.swift/01M5D5AB2Y625F1Q2BN8VF36VS-safari-on-macos-27-dropped-a.md b/.lane/memory/native2/Sources/DesktopMac/WebText.swift/01M5D5AB2Y625F1Q2BN8VF36VS-safari-on-macos-27-dropped-a.md new file mode 100644 index 0000000..8dbee2b --- /dev/null +++ b/.lane/memory/native2/Sources/DesktopMac/WebText.swift/01M5D5AB2Y625F1Q2BN8VF36VS-safari-on-macos-27-dropped-a.md @@ -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 diff --git a/.lane/memory/scripts/live-check.mjs/01M5D313RP70TNMETX3F6PX3PD-one-argument-perl-system-spl.md b/.lane/memory/scripts/live-check.mjs/01M5D313RP70TNMETX3F6PX3PD-one-argument-perl-system-spl.md index 287487b..76f1d8d 100644 --- a/.lane/memory/scripts/live-check.mjs/01M5D313RP70TNMETX3F6PX3PD-one-argument-perl-system-spl.md +++ b/.lane/memory/scripts/live-check.mjs/01M5D313RP70TNMETX3F6PX3PD-one-argument-perl-system-spl.md @@ -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 --- diff --git a/native2/Sources/DesktopMac/Paste.swift b/native2/Sources/DesktopMac/Paste.swift index 231d478..17572c7 100644 --- a/native2/Sources/DesktopMac/Paste.swift +++ b/native2/Sources/DesktopMac/Paste.swift @@ -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) @@ -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)") @@ -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, diff --git a/native2/Sources/DesktopMac/Text.swift b/native2/Sources/DesktopMac/Text.swift index 1f9fdd5..9a7b3c6 100644 --- a/native2/Sources/DesktopMac/Text.swift +++ b/native2/Sources/DesktopMac/Text.swift @@ -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 } @@ -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 { diff --git a/native2/Sources/DesktopMac/WebText.swift b/native2/Sources/DesktopMac/WebText.swift new file mode 100644 index 0000000..75e5de6 --- /dev/null +++ b/native2/Sources/DesktopMac/WebText.swift @@ -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 + } +} diff --git a/scripts/live-check.mjs b/scripts/live-check.mjs index 720c781..310a64c 100644 --- a/scripts/live-check.mjs +++ b/scripts/live-check.mjs @@ -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) => {