mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-22 13:31:16 +00:00
* fix(macos): harden Quick Chat capture — drop AX-role text, restore bar after area capture, guard overlays post-permission * chore(i18n): refresh native inventory line numbers after capture hardening
425 lines
16 KiB
Swift
425 lines
16 KiB
Swift
import AppKit
|
|
@preconcurrency import ApplicationServices
|
|
import Foundation
|
|
|
|
struct QuickChatTextContext: Equatable, Sendable {
|
|
let appName: String
|
|
let windowTitle: String
|
|
let text: String
|
|
|
|
var characterCount: Int {
|
|
self.text.count
|
|
}
|
|
}
|
|
|
|
struct QuickChatTextCollectionLimits: Equatable, Sendable {
|
|
static let standard = QuickChatTextCollectionLimits(
|
|
maximumDepth: 12,
|
|
maximumElements: 800,
|
|
maximumCharacters: 20000)
|
|
|
|
let maximumDepth: Int
|
|
let maximumElements: Int
|
|
let maximumCharacters: Int
|
|
}
|
|
|
|
struct QuickChatTextCollection: Equatable, Sendable {
|
|
let text: String
|
|
let visitedElementCount: Int
|
|
let textEntryCount: Int
|
|
let wasTruncated: Bool
|
|
}
|
|
|
|
struct QuickChatTextTreeChildren: Sendable {
|
|
let nodes: [any QuickChatTextTreeNode]
|
|
let wasTruncated: Bool
|
|
}
|
|
|
|
protocol QuickChatTextTreeNode: Sendable {
|
|
var identity: UInt64 { get }
|
|
func stringValue() -> String?
|
|
func computedName() -> String?
|
|
func children(limit: Int) -> QuickChatTextTreeChildren
|
|
}
|
|
|
|
private enum QuickChatCaptureRaceResult: Sendable {
|
|
case snapshot(String, QuickChatTextCollection)
|
|
case timedOut
|
|
case cancelled
|
|
}
|
|
|
|
/// Synchronous one-shot arbitration lets a cancellation handler settle the AX race
|
|
/// immediately, including when cancellation wins before the continuation is installed.
|
|
private final class QuickChatCaptureRace: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var result: QuickChatCaptureRaceResult?
|
|
private var continuation: CheckedContinuation<QuickChatCaptureRaceResult, Never>?
|
|
|
|
func wait() async -> QuickChatCaptureRaceResult {
|
|
await withCheckedContinuation { continuation in
|
|
self.lock.lock()
|
|
if let result = self.result {
|
|
self.lock.unlock()
|
|
continuation.resume(returning: result)
|
|
} else {
|
|
self.continuation = continuation
|
|
self.lock.unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func resolve(_ result: QuickChatCaptureRaceResult) -> Bool {
|
|
self.lock.lock()
|
|
guard self.result == nil else {
|
|
self.lock.unlock()
|
|
return false
|
|
}
|
|
self.result = result
|
|
let continuation = self.continuation
|
|
self.continuation = nil
|
|
self.lock.unlock()
|
|
continuation?.resume(returning: result)
|
|
return true
|
|
}
|
|
}
|
|
|
|
enum QuickChatFocusedTextCollector {
|
|
static let truncationMarker = String(localized: "… [truncated]")
|
|
|
|
static func collect(
|
|
root: any QuickChatTextTreeNode,
|
|
limits: QuickChatTextCollectionLimits = .standard,
|
|
deadline: ContinuousClock.Instant? = nil,
|
|
isCancelled: () -> Bool = { false }) -> QuickChatTextCollection
|
|
{
|
|
let maximumDepth = max(0, limits.maximumDepth)
|
|
let maximumElements = max(1, limits.maximumElements)
|
|
let maximumCharacters = max(1, limits.maximumCharacters)
|
|
var stack: [(node: any QuickChatTextTreeNode, depth: Int, parentTexts: [String])] = [(root, 0, [])]
|
|
var visitedNodeIDs = Set<UInt64>()
|
|
var rendered = ""
|
|
var visitedElementCount = 0
|
|
var textEntryCount = 0
|
|
var wasStructurallyTruncated = false
|
|
var wasTextTruncated = false
|
|
|
|
traversal: while let next = stack.popLast() {
|
|
// Unresponsive AX targets can stall per-message; a wall-clock deadline and
|
|
// cooperative cancellation keep the walk bounded regardless of app health.
|
|
if isCancelled() || deadline.map({ ContinuousClock.now >= $0 }) == true {
|
|
wasStructurallyTruncated = true
|
|
break
|
|
}
|
|
guard visitedNodeIDs.insert(next.node.identity).inserted else { continue }
|
|
guard visitedElementCount < maximumElements else {
|
|
wasStructurallyTruncated = true
|
|
break
|
|
}
|
|
visitedElementCount += 1
|
|
|
|
// Suppress only the parent/child echo (an element repeating its ancestor's
|
|
// text) and same-node repeats; equal text from siblings (table cells,
|
|
// repeated lines) is real document content and must be preserved.
|
|
var ownTexts: [String] = []
|
|
for rawCandidate in [next.node.stringValue(), next.node.computedName()] {
|
|
guard let candidate = Self.normalized(rawCandidate),
|
|
!next.parentTexts.contains(candidate),
|
|
!ownTexts.contains(candidate)
|
|
else { continue }
|
|
ownTexts.append(candidate)
|
|
let piece = rendered.isEmpty ? candidate : "\n\(candidate)"
|
|
let remaining = maximumCharacters + 1 - rendered.count
|
|
guard remaining > 0 else {
|
|
wasTextTruncated = true
|
|
break traversal
|
|
}
|
|
rendered.append(contentsOf: piece.prefix(remaining))
|
|
textEntryCount += 1
|
|
if piece.count >= remaining {
|
|
wasTextTruncated = true
|
|
break traversal
|
|
}
|
|
}
|
|
|
|
if next.depth >= maximumDepth {
|
|
let overflow = next.node.children(limit: 1)
|
|
if !overflow.nodes.isEmpty || overflow.wasTruncated {
|
|
wasStructurallyTruncated = true
|
|
}
|
|
continue
|
|
}
|
|
|
|
let remainingElements = maximumElements - visitedElementCount
|
|
let childResult = next.node.children(limit: max(1, remainingElements))
|
|
if childResult.wasTruncated {
|
|
wasStructurallyTruncated = true
|
|
}
|
|
let descendantTexts = next.parentTexts + ownTexts.filter { !next.parentTexts.contains($0) }
|
|
for child in childResult.nodes.reversed() {
|
|
stack.append((child, next.depth + 1, descendantTexts))
|
|
}
|
|
}
|
|
|
|
if !stack.isEmpty {
|
|
wasStructurallyTruncated = true
|
|
}
|
|
let wasTruncated = wasStructurallyTruncated || wasTextTruncated
|
|
if wasTruncated {
|
|
rendered = Self.appendingTruncationMarker(to: rendered, maximumCharacters: maximumCharacters)
|
|
}
|
|
return QuickChatTextCollection(
|
|
text: rendered,
|
|
visitedElementCount: visitedElementCount,
|
|
textEntryCount: textEntryCount,
|
|
wasTruncated: wasTruncated)
|
|
}
|
|
|
|
private static func normalized(_ value: String?) -> String? {
|
|
guard let value else { return nil }
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return trimmed.isEmpty ? nil : trimmed
|
|
}
|
|
|
|
private static func appendingTruncationMarker(to text: String, maximumCharacters: Int) -> String {
|
|
guard maximumCharacters > self.truncationMarker.count else {
|
|
return String(self.truncationMarker.prefix(maximumCharacters))
|
|
}
|
|
let bodyLimit = maximumCharacters - Self.truncationMarker.count
|
|
return String(text.prefix(bodyLimit)) + Self.truncationMarker
|
|
}
|
|
}
|
|
|
|
enum QuickChatTextContextCaptureOutcome: Sendable {
|
|
case captured(QuickChatTextContext)
|
|
case failed(String)
|
|
case cancelled
|
|
}
|
|
|
|
@MainActor
|
|
enum QuickChatFocusedTextCaptureService {
|
|
static func frontmostApplicationName() -> String {
|
|
NSWorkspace.shared.frontmostApplication?.localizedName?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.nonEmpty ?? String(localized: "focused app")
|
|
}
|
|
|
|
static func capture() async -> QuickChatTextContextCaptureOutcome {
|
|
guard let application = NSWorkspace.shared.frontmostApplication else {
|
|
return .failed(String(localized: "No focused application is available."))
|
|
}
|
|
let appName = application.localizedName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
|
?? application.bundleIdentifier
|
|
?? String(localized: "Focused app")
|
|
guard application.processIdentifier != getpid(),
|
|
application.bundleIdentifier != Bundle.main.bundleIdentifier
|
|
else {
|
|
return .failed(String(localized: "Focus another app before attaching its text."))
|
|
}
|
|
|
|
let hasPermission = await PermissionManager.status([.accessibility])[.accessibility] == true
|
|
guard !Task.isCancelled else { return .cancelled }
|
|
guard hasPermission else {
|
|
guard self.confirmAccessibilityRequest(appName: appName) else { return .cancelled }
|
|
guard !Task.isCancelled else { return .cancelled }
|
|
let result = await PermissionManager.ensure([.accessibility], interactive: true)
|
|
guard !Task.isCancelled else { return .cancelled }
|
|
guard result[.accessibility] == true else {
|
|
return .failed(String(localized: "Accessibility access is required to attach text from \(appName)."))
|
|
}
|
|
return await self.capture(application: application, appName: appName)
|
|
}
|
|
return await self.capture(application: application, appName: appName)
|
|
}
|
|
|
|
private static func capture(
|
|
application: NSRunningApplication,
|
|
appName: String) async -> QuickChatTextContextCaptureOutcome
|
|
{
|
|
guard !Task.isCancelled else { return .cancelled }
|
|
let appElement = AXUIElementCreateApplication(application.processIdentifier)
|
|
// Bound the very first read too; the focused-window copy below otherwise waits
|
|
// for the system default (~6s) on a hung target.
|
|
AXUIElementSetMessagingTimeout(appElement, 1.0)
|
|
var focusedWindowValue: CFTypeRef?
|
|
let focusedWindowError = AXUIElementCopyAttributeValue(
|
|
appElement,
|
|
kAXFocusedWindowAttribute as CFString,
|
|
&focusedWindowValue)
|
|
guard focusedWindowError == .success,
|
|
let focusedWindowValue,
|
|
CFGetTypeID(focusedWindowValue) == AXUIElementGetTypeID()
|
|
else {
|
|
return .failed(String(localized: "No focused window is available in \(appName)."))
|
|
}
|
|
let focusedWindow = unsafeDowncast(focusedWindowValue, to: AXUIElement.self)
|
|
// A hung target app would otherwise block each AX message for the system default
|
|
// (~6s); a short per-message timeout keeps worst-case walks near the deadline.
|
|
let root = QuickChatAXTextTreeNode(element: focusedWindow)
|
|
|
|
// AX reads are synchronous and can be expensive. Keep the bounded walk off the
|
|
// main actor; this adapter never logs or persists attribute values. The walk
|
|
// itself checks cancellation and a 3s wall-clock deadline per iteration.
|
|
let walk = Task.detached(priority: .userInitiated) {
|
|
let title = root.title()?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
|
?? String(localized: "Focused Window")
|
|
let collection = QuickChatFocusedTextCollector.collect(
|
|
root: root,
|
|
deadline: ContinuousClock.now.advanced(by: .seconds(3)),
|
|
isCancelled: { Task.isCancelled })
|
|
return (title, collection)
|
|
}
|
|
// Hard outer bound: a hung target can stall individual AX reads past any
|
|
// cooperative check. A structured group would JOIN the losing child (and thus
|
|
// still wait for the walk), so the race is unstructured: first result wins the
|
|
// continuation, the abandoned walk is cancelled and its result discarded.
|
|
let race = QuickChatCaptureRace()
|
|
Task.detached {
|
|
let value = await walk.value
|
|
race.resolve(.snapshot(value.0, value.1))
|
|
}
|
|
let timeout = Task.detached {
|
|
try? await Task.sleep(for: .seconds(4))
|
|
if race.resolve(.timedOut) {
|
|
walk.cancel()
|
|
}
|
|
}
|
|
let result = await withTaskCancellationHandler {
|
|
await race.wait()
|
|
} onCancel: {
|
|
walk.cancel()
|
|
race.resolve(.cancelled)
|
|
}
|
|
timeout.cancel()
|
|
|
|
switch result {
|
|
case .cancelled:
|
|
return .cancelled
|
|
case .timedOut:
|
|
walk.cancel()
|
|
return .failed(String(localized: "\(appName) is not responding to Accessibility requests."))
|
|
case let .snapshot(title, collection):
|
|
guard collection.textEntryCount > 0 else {
|
|
return .failed(String(localized: "No readable text was found in \(appName)."))
|
|
}
|
|
return .captured(QuickChatTextContext(
|
|
appName: appName,
|
|
windowTitle: title,
|
|
text: collection.text))
|
|
}
|
|
}
|
|
|
|
private static func confirmAccessibilityRequest(appName: String) -> Bool {
|
|
let alert = NSAlert()
|
|
alert.messageText = String(localized: "Allow OpenClaw to read text from \(appName)")
|
|
alert.informativeText = String(localized: "Attaching focused-window text uses macOS Accessibility access.")
|
|
alert.addButton(withTitle: String(localized: "Grant Access"))
|
|
alert.addButton(withTitle: String(localized: "Cancel"))
|
|
// User-initiated confirmation owns the only path that may trigger the TCC prompt.
|
|
return alert.runModal() == .alertFirstButtonReturn
|
|
}
|
|
}
|
|
|
|
private struct QuickChatAXTextTreeNode: QuickChatTextTreeNode, Sendable {
|
|
let element: AXUIElement
|
|
|
|
init(element: AXUIElement) {
|
|
// Messaging timeouts are per element reference; every wrapped descendant needs
|
|
// its own or an unresponsive target stalls each read for the system default.
|
|
AXUIElementSetMessagingTimeout(element, 1.0)
|
|
self.element = element
|
|
}
|
|
|
|
var identity: UInt64 {
|
|
UInt64(CFHash(self.element))
|
|
}
|
|
|
|
func stringValue() -> String? {
|
|
self.stringAttribute(kAXValueAttribute)
|
|
}
|
|
|
|
func computedName() -> String? {
|
|
let candidates = [
|
|
kAXTitleAttribute,
|
|
kAXValueAttribute,
|
|
kAXIdentifierAttribute,
|
|
kAXDescriptionAttribute,
|
|
kAXHelpAttribute,
|
|
kAXPlaceholderValueAttribute,
|
|
]
|
|
for attribute in candidates {
|
|
if let value = self.stringAttribute(attribute)?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!value.isEmpty
|
|
{
|
|
return value
|
|
}
|
|
}
|
|
// Deliberately no AX-role fallback: a role name like "Window"/"Group" is structure,
|
|
// not readable content. Emitting it would count toward textEntryCount and let a
|
|
// canvas/image-only window send a chip of role words instead of failing with
|
|
// "No readable text".
|
|
return nil
|
|
}
|
|
|
|
func children(limit: Int) -> QuickChatTextTreeChildren {
|
|
let attributes = [
|
|
kAXChildrenAttribute,
|
|
kAXVisibleChildrenAttribute,
|
|
"AXChildrenInNavigationOrder",
|
|
kAXRowsAttribute,
|
|
kAXContentsAttribute,
|
|
]
|
|
let resolvedLimit = max(1, limit)
|
|
var nodes: [any QuickChatTextTreeNode] = []
|
|
var seen = Set<UInt64>()
|
|
var wasTruncated = false
|
|
|
|
for attribute in attributes {
|
|
var count: CFIndex = 0
|
|
guard AXUIElementGetAttributeValueCount(
|
|
self.element,
|
|
attribute as CFString,
|
|
&count) == .success,
|
|
count > 0
|
|
else { continue }
|
|
let remaining = resolvedLimit - nodes.count
|
|
guard remaining > 0 else {
|
|
wasTruncated = true
|
|
break
|
|
}
|
|
if count > remaining {
|
|
wasTruncated = true
|
|
}
|
|
var values: CFArray?
|
|
guard AXUIElementCopyAttributeValues(
|
|
self.element,
|
|
attribute as CFString,
|
|
0,
|
|
min(count, remaining),
|
|
&values) == .success,
|
|
let elements = values as? [AXUIElement]
|
|
else { continue }
|
|
for element in elements {
|
|
let node = QuickChatAXTextTreeNode(element: element)
|
|
if seen.insert(node.identity).inserted {
|
|
nodes.append(node)
|
|
}
|
|
}
|
|
}
|
|
return QuickChatTextTreeChildren(nodes: nodes, wasTruncated: wasTruncated)
|
|
}
|
|
|
|
func title() -> String? {
|
|
self.stringAttribute(kAXTitleAttribute)
|
|
}
|
|
|
|
private func stringAttribute(_ attribute: String) -> String? {
|
|
var value: CFTypeRef?
|
|
guard AXUIElementCopyAttributeValue(
|
|
self.element,
|
|
attribute as CFString,
|
|
&value) == .success
|
|
else { return nil }
|
|
return value as? String
|
|
}
|
|
}
|