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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Notable changes to Desktop Tools, newest first. Each release's GitHub release no

### Fixed

- `desktop_click` with `modifiers`, such as Command-click or Shift-click, no longer fails its cleanup every time with `DESKTOP_ACTION_FAILED` and "cursor restoration … could not be verified": the host read the pointer back before its restoring move had landed. It reports `cursor_restoration` again and stays `unknown` with `DESKTOP_ACTION_UNVERIFIED`, since a modifier click's effect can't be confirmed. ([#128](https://github.com/githubnext/desktop-tools/pull/128))
- `desktop_clipboard_read` with `save_path` saves images again; the host never enabled image export, so every such read was refused. The build now fails if the host would drop an operation the app allows. ([#125](https://github.com/githubnext/desktop-tools/pull/125))
- A clipboard paste whose receiver never took it, such as an image pasted into a TextEdit `.rtf` document, no longer blocks every clipboard write and paste until that app quits: closing the receiving window releases it too, and the refusal says so. ([#125](https://github.com/githubnext/desktop-tools/pull/125))
- `window_appears` no longer counts a window macOS keeps listed but ordered out on the current Space as open, so `expect` with `window_appears` for Spotlight works on macOS 27, where Siri AI (`com.apple.campo`) hosts the panel. ([#125](https://github.com/githubnext/desktop-tools/pull/125))
Expand Down
1 change: 1 addition & 0 deletions dist/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const patches = [
"peekaboo-paste-window-release.patch",
"peekaboo-layout-shortcuts.patch",
"peekaboo-focus-budget.patch",
"peekaboo-cursor-readback.patch",
].map((name) => join(native, "patches", name));
function spawn(command, env) {
return spawnSync(command[0], command.slice(1), { encoding: "utf8", env: env ?? process.env });
Expand Down
4 changes: 4 additions & 0 deletions native/patches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -607,3 +607,7 @@ the update.
- **Why a budget cut is safe.** The match is an identity with the element the app itself reports as focused, not an inference from what was seen. Elements past the budget were not read, so they cannot be a candidate; the candidates that were read are compared just as in a complete tree. A deadline or a failed read can leave a candidate's reading wrong, so either still suppresses the tie-break, as does the application-scoped fallback.
- **Change.** `DetectionTruncationInfo.hasIncompleteReads` (deadline or incomplete read) replaces `isTruncated` in the worker's eligibility and in the result builder. Focused text selection still needs an untruncated tree.
- **Tests.** Upstream's suppression tests keep the cached, application-scoped, deadline and incomplete-read cases; a new test attaches the corroborated focus for each budget kind. No operation is added.

`peekaboo-cursor-readback.patch` waits for a restored pointer before calling the restoration unverified. After a foreground modifier click, `CursorRestorationOwnership.restore` moves the pointer back by posting a mouse-moved event to the HID event tap and then reads the location. The window server applies a posted event after the post returns, so the read came straight after it and still found the click point. Every Command-click and Shift-click failed its cleanup with "The physical cursor restoration was dispatched but could not be verified" ([#127](https://github.com/githubnext/desktop-tools/issues/127)).
- **Readback.** After the move, the location is read up to 20 times, 10 ms apart, and the restoration counts once it reads the original point. The ownership checks before the move are unchanged. A pointer that never arrives, such as one the user moves meanwhile, is still an indeterminate failure, now after at most about 190 ms.
- **Tests.** The restoration tests cover arrival on the first, fifth and last read, and a pointer that never arrives after the bounded wait. No operation is added.
101 changes: 101 additions & 0 deletions native/patches/peekaboo-cursor-readback.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/SyntheticInputDriver.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/SyntheticInputDriver.swift
index 8f07ac605..df11f0520 100644
--- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/SyntheticInputDriver.swift
+++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/SyntheticInputDriver.swift
@@ -241,11 +241,16 @@ enum CursorRestorationOwnership {
let activityToken: SharedInputActivityToken
}

+ /// How long a posted move may take to reach the window server before its readback counts as failed.
+ nonisolated static let readbackAttempts = 20
+ nonisolated static let readbackInterval: TimeInterval = 0.01
+
static func restore(
_ receipt: Receipt,
currentActivity: () -> SharedInputActivityToken,
currentLocation: () -> CGPoint?,
- move: (CGPoint) throws -> Void) throws -> SharedDesktopRestorationStatus
+ move: (CGPoint) throws -> Void,
+ pause: (TimeInterval) -> Void = { Thread.sleep(forTimeInterval: $0) }) throws -> SharedDesktopRestorationStatus
{
let original = receipt.original
let lastWritten = receipt.lastWritten
@@ -268,7 +273,17 @@ enum CursorRestorationOwnership {
return .preservedNewerState
}
try move(original)
- guard currentLocation().map({ self.pointsMatch($0, original) }) == true else {
+ // The move is posted to the HID event tap, and the window server applies it after the post returns, so
+ // an immediate read still finds the click point. Only a pointer that never arrives is unverified.
+ var arrived = false
+ for attempt in 0..<self.readbackAttempts {
+ if attempt > 0 { pause(self.readbackInterval) }
+ if currentLocation().map({ self.pointsMatch($0, original) }) == true {
+ arrived = true
+ break
+ }
+ }
+ guard arrived else {
throw DesktopActionFailure.indeterminate(
delivery: .init(mechanism: .globalEvents, mode: .foreground),
evidence: .completionUnknown,
diff --git a/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/ForegroundModifierClickExecutorTests+Restoration.swift b/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/ForegroundModifierClickExecutorTests+Restoration.swift
index 05114c0c1..2026d6ea7 100644
--- a/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/ForegroundModifierClickExecutorTests+Restoration.swift
+++ b/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/ForegroundModifierClickExecutorTests+Restoration.swift
@@ -96,6 +96,55 @@ extension ForegroundModifierClickExecutorTests {
}
}

+ @Test(arguments: [1, 5, CursorRestorationOwnership.readbackAttempts])
+ func `restoration waits for the posted move to reach the window server`(readsUntilArrival: Int) throws {
+ let token = SharedInputActivityToken.trackedZero
+ let original = CGPoint(x: 10, y: 10)
+ let lastWritten = CGPoint(x: 20, y: 20)
+ var moved = false
+ var readsAfterMove = 0
+ var pauses: [TimeInterval] = []
+
+ let status = try CursorRestorationOwnership.restore(
+ CursorRestorationOwnership.Receipt(original: original, lastWritten: lastWritten, activityToken: token),
+ currentActivity: { token },
+ currentLocation: {
+ guard moved else { return lastWritten }
+ readsAfterMove += 1
+ return readsAfterMove >= readsUntilArrival ? original : lastWritten
+ },
+ move: { _ in moved = true },
+ pause: { pauses.append($0) })
+
+ #expect(status == .restored)
+ #expect(readsAfterMove == readsUntilArrival)
+ #expect(pauses == Array(repeating: CursorRestorationOwnership.readbackInterval, count: readsUntilArrival - 1))
+ }
+
+ @Test
+ func `a pointer that never arrives stays unverified after a bounded wait`() {
+ let token = SharedInputActivityToken.trackedZero
+ let lastWritten = CGPoint(x: 20, y: 20)
+ var readsAfterMove = 0
+ var moved = false
+ var pauses = 0
+
+ #expect(throws: DesktopActionFailure.self) {
+ _ = try CursorRestorationOwnership.restore(
+ CursorRestorationOwnership.Receipt(
+ original: CGPoint(x: 10, y: 10), lastWritten: lastWritten, activityToken: token),
+ currentActivity: { token },
+ currentLocation: {
+ if moved { readsAfterMove += 1 }
+ return lastWritten
+ },
+ move: { _ in moved = true },
+ pause: { _ in pauses += 1 })
+ }
+ #expect(readsAfterMove == CursorRestorationOwnership.readbackAttempts)
+ #expect(pauses == CursorRestorationOwnership.readbackAttempts - 1)
+ }
+
@Test(arguments: SharedDesktopInterruption.allCases)
func `newer shared input suppresses cursor and focus cleanup`(
_ interruption: SharedDesktopInterruption) async throws
1 change: 1 addition & 0 deletions src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const patches = [
"peekaboo-paste-window-release.patch",
"peekaboo-layout-shortcuts.patch",
"peekaboo-focus-budget.patch",
"peekaboo-cursor-readback.patch",
].map((name) => join(native, "patches", name));

function spawn(command: string[], env?: NodeJS.ProcessEnv) {
Expand Down
Loading