fix(cua): own clipboard transactions on the AppKit main actor
This commit is contained in:
1 parent
e312d6fdf6
commit
e12fe33aaa
5 files changed
+71
-43
No files matched your search
@@ -145,7 +145,7 @@ final class AXAccess {
|
||||
let error = AXUIElementSetAttributeValue(element, "AXValue" as CFString, value as CFString)
|
||||
guard error == .success else { throw failure(error, "set AXValue") }
|
||||
}
|
||||
func paste(_ pid: Int32, maxElements: Int, maxDepth: Int, beforeDispatch: () throws -> Void) throws {
|
||||
func pasteCommand(_ pid: Int32, maxElements: Int, maxDepth: Int) throws -> AXUIElement {
|
||||
guard let menu = try element(AXUIElementCreateApplication(pid), "AXMenuBar") else {
|
||||
throw NativeFailure(code: "UNSUPPORTED", message: "The application does not expose a Paste menu command")
|
||||
}
|
||||
@@ -172,8 +172,7 @@ final class AXAccess {
|
||||
for child in try children(node) { pending.append((child, depth + 1)) }
|
||||
}
|
||||
guard let found else { throw NativeFailure(code: "UNSUPPORTED", message: "No standard Paste command is available") }
|
||||
try beforeDispatch()
|
||||
try perform(found, "AXPress")
|
||||
return found
|
||||
}
|
||||
func selectText(_ element: AXUIElement, text: String, prefix: String?, suffix: String?, mode: String) throws {
|
||||
try requireEnabled(element)
|
||||
|
||||
@@ -47,6 +47,14 @@ struct ForegroundObservation: Sendable {
|
||||
"frontWindowPid": frontWindowPID.map { .number(Double($0)) } ?? .null])
|
||||
}
|
||||
}
|
||||
struct PastePlan: Sendable {
|
||||
let handle: String
|
||||
let text: String
|
||||
let format: String
|
||||
let maxBytes: Int
|
||||
let maxElements: Int
|
||||
let maxDepth: Int
|
||||
}
|
||||
struct ApplicationLaunch: Sendable {
|
||||
let handle: String
|
||||
let application: InstalledApplication
|
||||
@@ -56,7 +64,7 @@ private enum ApplicationTarget {
|
||||
case running(ProcessIdentity)
|
||||
case pending(InstalledApplication, JSONValue)
|
||||
}
|
||||
enum DriverOutcome: Sendable { case result(JSONValue), capture(CaptureTarget), foreground(ForegroundPlan), launch(ApplicationLaunch) }
|
||||
enum DriverOutcome: Sendable { case result(JSONValue), capture(CaptureTarget), foreground(ForegroundPlan), launch(ApplicationLaunch), paste(PastePlan) }
|
||||
|
||||
actor AXWorker {
|
||||
nonisolated let executor = NativeSerialExecutor()
|
||||
@@ -253,16 +261,9 @@ actor AXWorker {
|
||||
let window = try coordinateWindow(identity, params.windowID())
|
||||
return .capture(CaptureTarget(appHandle: handle, process: identity, window: window))
|
||||
case "paste":
|
||||
try ClipboardPaste().perform(text: params.string("text"), format: params.optionalString("format") ?? "text",
|
||||
maxBytes: params.integer("maxClipboardBytes", in: 1024...Int.max),
|
||||
checkCancellation: { try cancellation.check() }) { requireClipboard in
|
||||
try ax().paste(identity.pid, maxElements: params.integer("maxElements", in: 1...10000),
|
||||
maxDepth: params.integer("maxDepth", in: 1...100)) {
|
||||
try cancellation.check()
|
||||
_ = try validateIdentity(handle)
|
||||
try requireClipboard()
|
||||
}
|
||||
}
|
||||
return .paste(PastePlan(handle: handle, text: try params.string("text"), format: try params.optionalString("format") ?? "text",
|
||||
maxBytes: try params.integer("maxClipboardBytes", in: 1024...Int.max),
|
||||
maxElements: try params.integer("maxElements", in: 1...10000), maxDepth: try params.integer("maxDepth", in: 1...100)))
|
||||
case "typeText", "pressKey":
|
||||
let recipient = try ax().keyboardRecipient(identity, catalog: catalog)
|
||||
try NativePlatform.verify(recipient)
|
||||
@@ -329,6 +330,16 @@ actor AXWorker {
|
||||
return .result(Self.actionResult)
|
||||
}
|
||||
|
||||
func performPaste(_ plan: PastePlan, beforeDispatch: @MainActor @Sendable () throws -> Void) async throws {
|
||||
let identity = try validateIdentity(plan.handle)
|
||||
let command = try ax().pasteCommand(identity.pid, maxElements: plan.maxElements, maxDepth: plan.maxDepth)
|
||||
try await beforeDispatch()
|
||||
try cancellation.check()
|
||||
_ = try validateIdentity(plan.handle)
|
||||
try NativePlatform.requireUnlocked(observeSession())
|
||||
try ax().perform(command, "AXPress")
|
||||
}
|
||||
|
||||
private func pendingApplication(_ app: InstalledApplication, handle: String, authorization: JSONValue) -> JSONValue {
|
||||
var value: [String: JSONValue] = ["appHandle": .string(handle), "pid": .null, "name": .string(app.name), "authorization": authorization]
|
||||
if let bundleID = app.bundleID { value["bundleId"] = .string(bundleID) }
|
||||
|
||||
@@ -17,32 +17,31 @@ private final class PasteDataProvider: NSObject, NSPasteboardItemDataProvider, @
|
||||
condition.broadcast()
|
||||
condition.unlock()
|
||||
}
|
||||
func wait(check: () throws -> Void) throws {
|
||||
@MainActor func wait(check: () throws -> Void) async throws {
|
||||
while true {
|
||||
try check()
|
||||
condition.lock()
|
||||
switch delivery {
|
||||
case .supplied: condition.unlock(); return
|
||||
case .failed:
|
||||
condition.unlock()
|
||||
throw NativeFailure(code: "CLIPBOARD_UNAVAILABLE", message: "Requested paste data could not be supplied")
|
||||
case .pending:
|
||||
// A bounded wait lets helper cancellation and a newer clipboard owner interrupt delivery.
|
||||
_ = condition.wait(until: Date(timeIntervalSinceNow: 0.025))
|
||||
condition.unlock()
|
||||
let state = condition.withLock { delivery }
|
||||
switch state {
|
||||
case .supplied: return
|
||||
case .failed: throw NativeFailure(code: "CLIPBOARD_UNAVAILABLE", message: "Requested paste data could not be supplied")
|
||||
case .pending: try await Task.sleep(for: .milliseconds(25))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
final class ClipboardPaste {
|
||||
private struct SavedItem { let values: [(NSPasteboard.PasteboardType, Data)] }
|
||||
@MainActor final class ClipboardPaste {
|
||||
private struct SavedItem: Sendable { let values: [(NSPasteboard.PasteboardType, Data)] }
|
||||
private static let ownerType = NSPasteboard.PasteboardType("org.deepseek-harness.cua.clipboard-owner")
|
||||
private let pasteboard: NSPasteboard
|
||||
private var restorePending: (() throws -> Void)?
|
||||
|
||||
init(pasteboard: NSPasteboard = .general) { self.pasteboard = pasteboard }
|
||||
|
||||
func perform(text: String, format: String, maxBytes: Int, checkCancellation: () throws -> Void = {}, operation: (() throws -> Void) throws -> Void) throws {
|
||||
func perform(text: String, format: String, maxBytes: Int, checkCancellation: () throws -> Void = {},
|
||||
operation: (@escaping @MainActor @Sendable () throws -> Void) async throws -> Void) async throws {
|
||||
try checkCancellation()
|
||||
let type: NSPasteboard.PasteboardType
|
||||
switch format {
|
||||
case "text": type = .string
|
||||
@@ -62,20 +61,25 @@ final class ClipboardPaste {
|
||||
guard pasteboard.changeCount == originalCount else {
|
||||
throw NativeFailure(code: "CLIPBOARD_CHANGED", message: "Clipboard changed while preserving it; paste was not dispatched")
|
||||
}
|
||||
try checkCancellation()
|
||||
let cleared = pasteboard.clearContents()
|
||||
guard pasteboard.writeObjects([item]) else {
|
||||
if pasteboard.changeCount == cleared { try restore(saved) }
|
||||
throw NativeFailure(code: "CLIPBOARD_UNAVAILABLE", message: "Could not publish paste contents")
|
||||
}
|
||||
let expectedCount = pasteboard.changeCount
|
||||
func owned() -> Bool { pasteboard.changeCount == expectedCount && pasteboard.data(forType: Self.ownerType) == owner }
|
||||
func requireOwnership() throws {
|
||||
let owned: @MainActor @Sendable () -> Bool = { [self] in
|
||||
pasteboard.changeCount == expectedCount && pasteboard.data(forType: Self.ownerType) == owner
|
||||
}
|
||||
let requireOwnership: @MainActor @Sendable () throws -> Void = {
|
||||
guard owned() else { throw NativeFailure(code: "CLIPBOARD_CHANGED", message: "Clipboard changed before paste; input was not dispatched") }
|
||||
}
|
||||
restorePending = { [self] in if owned() { try restore(saved) } }
|
||||
defer { restorePending = nil }
|
||||
do {
|
||||
try requireOwnership()
|
||||
try operation(requireOwnership)
|
||||
try provider.wait { try checkCancellation(); try requireOwnership() }
|
||||
try await operation(requireOwnership)
|
||||
try await provider.wait { try checkCancellation(); try requireOwnership() }
|
||||
} catch {
|
||||
if owned() { try restore(saved) }
|
||||
throw error
|
||||
@@ -83,6 +87,11 @@ final class ClipboardPaste {
|
||||
if owned() { try restore(saved) }
|
||||
}
|
||||
|
||||
func cancel() throws {
|
||||
try restorePending?()
|
||||
restorePending = nil
|
||||
}
|
||||
|
||||
private func snapshot(maxBytes: Int) throws -> [SavedItem] {
|
||||
var total = 0
|
||||
return try (pasteboard.pasteboardItems ?? []).map { item in
|
||||
|
||||
@@ -9,6 +9,7 @@ import Darwin
|
||||
private let cancellation: CancellationLatch
|
||||
private let worker: AXWorker
|
||||
private let capture = WindowCapture()
|
||||
private let clipboard = ClipboardPaste()
|
||||
private let input: JSONLInput
|
||||
private let writer: JSONLWriter
|
||||
private let signalQueue = DispatchQueue(label: "dsh.cua.signals")
|
||||
@@ -85,6 +86,12 @@ import Darwin
|
||||
let image = try await self.capture.capture(target, cancellation: self.cancellation)
|
||||
result = try await self.worker.finishCapture(target, image: image)
|
||||
case .foreground(let plan): result = try await self.performForeground(plan, requestID: request.id)
|
||||
case .paste(let plan):
|
||||
try await self.clipboard.perform(text: plan.text, format: plan.format, maxBytes: plan.maxBytes,
|
||||
checkCancellation: { try self.cancellation.check() }) { requireClipboard in
|
||||
try await self.worker.performPaste(plan, beforeDispatch: requireClipboard)
|
||||
}
|
||||
result = AXWorker.actionResult
|
||||
case .launch(let plan):
|
||||
let pid = try await ApplicationDiscovery.launch(plan.application, cancellation: self.cancellation)
|
||||
result = try await self.worker.finishLaunch(plan, pid: pid)
|
||||
@@ -144,6 +151,8 @@ import Darwin
|
||||
guard !stopping else { return }
|
||||
stopping = true
|
||||
cancellation.cancel()
|
||||
do { try clipboard.cancel() }
|
||||
catch { fputs("Clipboard restoration failed during shutdown: \(error.localizedDescription)\n", stderr) }
|
||||
active = nil
|
||||
operation?.cancel()
|
||||
input.stop()
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
import Testing
|
||||
@testable import CuaNative
|
||||
|
||||
@Test func clipboardPasteRestoresEveryOriginalFormatAfterDispatch() throws {
|
||||
@Test @MainActor func clipboardPasteRestoresEveryOriginalFormatAfterDispatch() async throws {
|
||||
let board = NSPasteboard.withUniqueName()
|
||||
defer { board.releaseGlobally() }
|
||||
let item = NSPasteboardItem()
|
||||
@@ -12,7 +12,7 @@ import Testing
|
||||
#expect(item.setString("original", forType: .string))
|
||||
#expect(item.setData(Data([1, 2, 3]), forType: custom))
|
||||
#expect(board.writeObjects([item]))
|
||||
try ClipboardPaste(pasteboard: board).perform(text: "line 1\nline 2\tvalue", format: "text", maxBytes: 1024) { current in
|
||||
try await ClipboardPaste(pasteboard: board).perform(text: "line 1\nline 2\tvalue", format: "text", maxBytes: 1024) { current in
|
||||
try current()
|
||||
#expect(board.string(forType: .string) == "line 1\nline 2\tvalue")
|
||||
}
|
||||
@@ -20,11 +20,11 @@ import Testing
|
||||
#expect(board.data(forType: custom) == Data([1, 2, 3]))
|
||||
}
|
||||
|
||||
@Test func clipboardPastePublishesHTMLWithoutTypingItsMarkup() throws {
|
||||
@Test @MainActor func clipboardPastePublishesHTMLWithoutTypingItsMarkup() async throws {
|
||||
let board = NSPasteboard.withUniqueName()
|
||||
defer { board.releaseGlobally() }
|
||||
let html = "<h1>Title</h1><p><b>Bold</b> <a href=\"https://example.com/\">link</a></p>"
|
||||
try ClipboardPaste(pasteboard: board).perform(text: html, format: "html", maxBytes: 1024) { current in
|
||||
try await ClipboardPaste(pasteboard: board).perform(text: html, format: "html", maxBytes: 1024) { current in
|
||||
try current()
|
||||
#expect(board.data(forType: .html) == Data(html.utf8))
|
||||
#expect(board.string(forType: .string) == nil)
|
||||
@@ -32,12 +32,12 @@ import Testing
|
||||
#expect(board.pasteboardItems?.isEmpty ?? true)
|
||||
}
|
||||
|
||||
@Test func clipboardPastePreservesANewerCopyInsteadOfRestoringOverIt() throws {
|
||||
@Test @MainActor func clipboardPastePreservesANewerCopyInsteadOfRestoringOverIt() async throws {
|
||||
let board = NSPasteboard.withUniqueName()
|
||||
defer { board.releaseGlobally() }
|
||||
#expect(board.setString("original", forType: .string))
|
||||
do {
|
||||
try ClipboardPaste(pasteboard: board).perform(text: "paste", format: "text", maxBytes: 1024) { current in
|
||||
try await ClipboardPaste(pasteboard: board).perform(text: "paste", format: "text", maxBytes: 1024) { current in
|
||||
board.clearContents()
|
||||
#expect(board.setString("new user copy", forType: .string))
|
||||
try current()
|
||||
@@ -47,12 +47,12 @@ import Testing
|
||||
#expect(board.string(forType: .string) == "new user copy")
|
||||
}
|
||||
|
||||
@Test func clipboardPasteRestoresOnCancellationAndRejectsOversizedPreservationBeforeChangingData() throws {
|
||||
@Test @MainActor func clipboardPasteRestoresOnCancellationAndRejectsOversizedPreservationBeforeChangingData() async throws {
|
||||
let board = NSPasteboard.withUniqueName()
|
||||
defer { board.releaseGlobally() }
|
||||
#expect(board.setString("original", forType: .string))
|
||||
do {
|
||||
try ClipboardPaste(pasteboard: board).perform(text: "paste", format: "text", maxBytes: 1024) { _ in
|
||||
try await ClipboardPaste(pasteboard: board).perform(text: "paste", format: "text", maxBytes: 1024) { _ in
|
||||
throw NativeFailure(code: "NATIVE_CANCELLED", message: "fixture cancellation")
|
||||
}
|
||||
Issue.record("Cancelled operation succeeded")
|
||||
@@ -62,7 +62,7 @@ import Testing
|
||||
let large = String(repeating: "x", count: 2048)
|
||||
#expect(board.setString(large, forType: .string))
|
||||
do {
|
||||
try ClipboardPaste(pasteboard: board).perform(text: "paste", format: "text", maxBytes: 1024) { _ in
|
||||
try await ClipboardPaste(pasteboard: board).perform(text: "paste", format: "text", maxBytes: 1024) { _ in
|
||||
Issue.record("Oversized clipboard reached dispatch")
|
||||
}
|
||||
Issue.record("Oversized clipboard was accepted")
|
||||
@@ -70,12 +70,12 @@ import Testing
|
||||
#expect(board.string(forType: .string) == large)
|
||||
}
|
||||
|
||||
@Test func clipboardPasteAwaitsDataConsumptionAndCanCancelBeforeItIsRequested() throws {
|
||||
@Test @MainActor func clipboardPasteAwaitsDataConsumptionAndCanCancelBeforeItIsRequested() async throws {
|
||||
let board = NSPasteboard.withUniqueName()
|
||||
defer { board.releaseGlobally() }
|
||||
#expect(board.setString("original", forType: .string))
|
||||
do {
|
||||
try ClipboardPaste(pasteboard: board).perform(text: "paste", format: "text", maxBytes: 1024,
|
||||
try await ClipboardPaste(pasteboard: board).perform(text: "paste", format: "text", maxBytes: 1024,
|
||||
checkCancellation: { throw NativeFailure(code: "NATIVE_CANCELLED", message: "delivery cancelled") }) { _ in }
|
||||
Issue.record("Unread clipboard data completed without cancellation")
|
||||
} catch let error as NativeFailure { #expect(error.code == "NATIVE_CANCELLED") }
|
||||
|
||||
Reference in new issue
Block a user