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

## [Unreleased]

### Fixed

- The agent cursor sits directly above the window the agent is using instead of above every window, so a window covering that app covers the cursor too. Input outside any one window, such as on the Dock or the menu bar, still shows it on top.
- The agent window outline and its label stay on the display when the window fills it; they were drawn just outside the window and ran off the screen's edges.
- `desktop_close` on a Finder window is `completed` again instead of `unknown`: Accessibility lists Finder's desktop as a window without an ID, which made every close unverifiable on macOS 27, even after the window was gone.
- `desktop_wait` and `expect` app conditions with `bundle_id` find the app when many apps are running; the app list cut to fit a result could drop it, and a cut list no longer counts as proof that an app quit.
- `desktop_read` with `table: true` finds the column titles of web tables in Safari 27, which reports `<th>` cells as plain cells.
- `desktop_select` in Safari 27 web text no longer crashes the Desktop Tools app, and finds text bounds without the text marker attributes Safari dropped.
- Pasting files with `desktop_insert` into a Finder window on macOS 27 finds the window's folder from its path bar, since Finder no longer reports it otherwise; without the path bar, the refusal says to turn on View > Show Path Bar.
- `desktop_set_value` on a Safari 27 date input is refused before input, with a hint to type the date, instead of `unknown`; it accepts an ISO 8601 date where a date can be set.
- Management failures, such as an unverified `desktop_close`, include the native `cause`, as action failures do.

## [0.14.0] - 2026-10-17

### Added
Expand Down
5 changes: 5 additions & 0 deletions dist/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ const patches = [
"peekaboo-focus-budget.patch",
"peekaboo-cursor-readback.patch",
"peekaboo-secure-fill.patch",
"peekaboo-web-table-headers.patch",
"peekaboo-web-text-markers.patch",
"peekaboo-finder-path-bar.patch",
"peekaboo-date-settable.patch",
"peekaboo-close-window-server-absence.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
2 changes: 2 additions & 0 deletions dist/table.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export type TableElement = {
selected?: boolean | null;
disclosure_level?: number | null;
disclosing?: boolean | null;
/** A web table lists this cell among its column headers. */
column_header?: boolean | null;
};
type TableRow = {
index: number;
Expand Down
5 changes: 3 additions & 2 deletions dist/table.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,9 +118,10 @@ function tableLayout(elements) {
}
}
if (!columns && rows.length) {
// A web table's header cells say so in their role description.
// A web table's header cells say so: the table lists them, or, before macOS 27, their role description.
const first = inside.filter((index) => parents[index] === rows[0]);
if (first.length && first.every((index) => /header/i.test(elements[index].role_description ?? ""))) {
const header = (element) => element.column_header === true || /header/i.test(element.role_description ?? "");
if (first.length && first.every((index) => header(elements[index]))) {
columns = first.map(cellText);
rows = rows.slice(1);
}
Expand Down
7 changes: 5 additions & 2 deletions dist/wait.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,12 @@ function validateMatch(element, forText) {
}
/** Apps with the bundle ID or PID, from one complete-or-partial inventory. */
async function findApps(reads, request, signal) {
const data = parseResult(await reads.observe({ op: "apps" }, signal));
// With many apps running, the whole list is cut to fit a result, so a bundle ID is matched natively first.
const query = request.pid === undefined ? request.bundle_id : undefined;
const data = parseResult(await reads.observe({ op: "apps", ...(query === undefined ? {} : { query }) }, signal));
const rows = data.apps.filter((app) => request.pid !== undefined ? app.pid === request.pid : app.bundle_id === request.bundle_id);
return { rows, complete: data.inventory_completeness === "complete" };
// A cut list may have dropped the app, so it never proves the app is gone.
return { rows, complete: data.inventory_completeness === "complete" && data.truncated !== true };
}
async function listWindows(reads, pid, signal) {
const data = parseResult(await reads.observe({ op: "windows", pid }, signal));
Expand Down
47 changes: 47 additions & 0 deletions native/patches/peekaboo-close-window-server-absence.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/WindowManagementService+StateOperations.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/WindowManagementService+StateOperations.swift
index d0c57fa06..54d4df044 100644
--- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/WindowManagementService+StateOperations.swift
+++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/WindowManagementService+StateOperations.swift
@@ -858,7 +858,9 @@ func pinnedWindowClosePresenceDisposition(
case .present: .present
case .missing: .missing
case .replacement: .replacement
- case .unverifiable: .unverifiable
+ // An on-screen window keeps its window server entry until it closes, so that entry decides when AX
+ // lists windows without IDs, as Finder's desktop window does: one with other bounds is still animating.
+ case .unverifiable: expectedMinimized ? .unverifiable : windowServerEntryPresent ? .present : .missing
}
}
guard expectedMinimized || windowServerEntryPresent else {
diff --git a/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/WindowStateMutationPolicyTests.swift b/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/WindowStateMutationPolicyTests.swift
index 5e912832f..3edff9b6e 100644
--- a/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/WindowStateMutationPolicyTests.swift
+++ b/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/WindowStateMutationPolicyTests.swift
@@ -106,6 +106,27 @@ struct WindowStateMutationPolicyTests {
expectedMinimized: true) == .unverifiable)
}

+ @Test
+ func `on-screen close follows its window server entry over an incomplete AX scan`() {
+ #expect(pinnedWindowClosePresenceDisposition(
+ windowServerEntryPresent: false,
+ minimizedAXPresence: .unverifiable,
+ expectedMinimized: false) == .missing)
+ #expect(pinnedWindowClosePresenceDisposition(
+ windowServerEntryPresent: false,
+ minimizedAXPresence: .present,
+ expectedMinimized: false) == .present)
+ #expect(pinnedWindowClosePresenceDisposition(
+ windowServerEntryPresent: true,
+ windowServerEntryMatchesReceipt: false,
+ minimizedAXPresence: .unverifiable,
+ expectedMinimized: false) == .present)
+ #expect(pinnedWindowClosePresenceDisposition(
+ windowServerEntryPresent: false,
+ minimizedAXPresence: .unverifiable,
+ expectedMinimized: true) == .unverifiable)
+ }
+
@Test
func `successful minimized close accepts complete AX disappearance scan`() {
let presence = pinnedMinimizedWindowAXPresence(
14 changes: 14 additions & 0 deletions native/patches/peekaboo-date-settable.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/ElementClassifier.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/ElementClassifier.swift
index 5cdd4d47b..1a8d7a9e4 100644
--- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/ElementClassifier.swift
+++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/ElementClassifier.swift
@@ -56,7 +56,8 @@ import PeekabooFoundation
"axmenuitem", "axtab",
]
private static let valueMetadataRoles: Set<String> = [
- "axcheckbox", "axcombobox", "axdatefield", "axincrementor", "axpopupbutton",
+ // Date and time controls too, so a reader can refuse one that won't take a date, such as Safari's.
+ "axcheckbox", "axcombobox", "axdatefield", "axdatetimearea", "axtimefield", "axincrementor", "axpopupbutton",
"axradiobutton", "axscrollbar", "axsearchfield", "axsecuretextfield", "axslider",
"axswitch", "axtextarea", "axtextfield",
]
74 changes: 74 additions & 0 deletions native/patches/peekaboo-finder-path-bar.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/LiteralInsert.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/LiteralInsert.swift
index 9d12aa6c0..d1bb94505 100644
--- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/LiteralInsert.swift
+++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/LiteralInsert.swift
@@ -466,6 +466,54 @@ extension UIAutomationService: LiteralInsertServiceProtocol, AttachmentInsertSer
return result
}

+ /// The folder in a Finder window's path bar: a list whose items are file URLs. The bar ends at the selected
+ /// item when there is one, so the folder is its last item named as the window is. Items are file reference
+ /// URLs, resolved to a path here. Nil when the path bar is hidden.
+ private nonisolated static func finderPathBarFolder(_ window: AXUIElement) -> URL? {
+ func children(_ element: AXUIElement) -> [AXUIElement] {
+ AXUIElementSetMessagingTimeout(element, 0.2)
+ var value: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &value) == .success
+ else { return [] }
+ return (value as? [AXUIElement]) ?? []
+ }
+ func string(_ element: AXUIElement, _ attribute: String) -> String? {
+ var value: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else { return nil }
+ return value as? String
+ }
+ func fileURL(_ element: AXUIElement) -> URL? {
+ var value: CFTypeRef?
+ guard AXUIElementCopyAttributeValue(element, "AXURL" as CFString, &value) == .success,
+ let url = value as? URL, url.isFileURL
+ else { return nil }
+ return (url as NSURL).filePathURL
+ }
+ // The path bar sits a few levels under the window, after the sidebar and file list.
+ var level = [window]
+ var visited = 0
+ for _ in 0..<4 {
+ var next: [AXUIElement] = []
+ for element in level {
+ for child in children(element) {
+ visited += 1
+ if visited > 200 { return nil }
+ if string(child, kAXRoleAttribute as String) == kAXListRole as String,
+ let title = string(window, kAXTitleAttribute as String), !title.isEmpty
+ {
+ let items = children(child)
+ if items.last.flatMap(fileURL) != nil {
+ return items.last(where: { string($0, kAXValueAttribute as String) == title }).flatMap(fileURL)
+ }
+ }
+ next.append(child)
+ }
+ }
+ level = next
+ }
+ return nil
+ }
+
private static func isFinder(_ target: UIAutomationTarget.ExactWindow) -> Bool {
NSRunningApplication(processIdentifier: target.identity.ownerProcessIdentifier)?.bundleIdentifier
== "com.apple.finder"
@@ -502,11 +550,12 @@ extension UIAutomationService: LiteralInsertServiceProtocol, AttachmentInsertSer
if let url = value as? URL { return url.absoluteString }
if let text = value as? String { return text }
}
- return nil
+ // On macOS 27 a Finder window's AXDocument is empty; its path bar's last item still names the folder.
+ return Self.finderPathBarFolder(windowElement)?.absoluteString
}
guard let location, let url = URL(string: location), url.isFileURL else {
throw Self.literalInsertRefusal(
- "This Finder window doesn't report its folder, so the pasted files couldn't be found afterwards. Nothing was pasted.")
+ "This Finder window doesn't report its folder, so the pasted files couldn't be found afterwards. Nothing was pasted. Turn on View > Show Path Bar in Finder, which shows the folder, and try again.")
}
let folder = url.standardizedFileURL
var isDirectory: ObjCBool = false
38 changes: 38 additions & 0 deletions native/patches/peekaboo-web-table-headers.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/DetachedAXObservationWorker.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/DetachedAXObservationWorker.swift
index dd1d99036..cad7ad3f0 100644
--- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/DetachedAXObservationWorker.swift
+++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/DetachedAXObservationWorker.swift
@@ -629,6 +629,14 @@ enum DetachedAXObservationWorker {
if let source = request.source {
attributes["source"] = source
}
+ if normalizedRole == "axtable" {
+ // A web table names its header cells here; their role description no longer says so on macOS 27.
+ if let headers = self.rawAttribute("AXColumnHeaderUIElements", of: element) as? [AXUIElement] {
+ state.columnHeaders.append(contentsOf: headers.prefix(Self.maximumColumnHeaders))
+ }
+ } else if normalizedRole == "axcell", state.columnHeaders.contains(where: { CFEqual($0, element) }) {
+ attributes["columnHeader"] = "true"
+ }
if normalizedRole == "axrow" {
// Outline rows are flat siblings; only their disclosure level shows the hierarchy.
attributes.merge(
@@ -974,6 +982,9 @@ extension DetachedAXObservationWorker {
return attributes
}

+ /// Header cells kept per table; a wider header is matched only this far.
+ static let maximumColumnHeaders = 64
+
static func booleanAttributeValue(_ value: Any?) -> Bool? {
self.boolValue(value)
}
@@ -1212,6 +1223,8 @@ private struct TraversalState {
var incompleteAccessibilityRead = false
/// Only Notification Center's custom action names are listed; elsewhere they are arbitrary app text.
var listsCustomActions = false
+ /// The header cells that tables seen so far name, so each cell is matched without another read.
+ var columnHeaders: [AXUIElement] = []

var truncationInfo: DetectionTruncationInfo? {
guard self.maxDepthReached || self.maxElementCountReached || self.maxChildrenPerNodeReached ||
34 changes: 34 additions & 0 deletions native/patches/peekaboo-web-text-markers.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/WebTextSelection.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/WebTextSelection.swift
index b435a0486..0aef4acbe 100644
--- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/WebTextSelection.swift
+++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/UI/WebTextSelection.swift
@@ -36,7 +36,8 @@ enum WebTextSelectionPlanner {
in span: WebTextSpan,
markers: any WebTextMarkerAccess) -> CFTypeRef?
{
- if let base = markers.index(of: span.start),
+ // 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),
@@ -162,10 +163,15 @@ struct AXWebTextMarkers: WebTextMarkerAccess {
}

func bounds(of range: CFTypeRef) -> (start: CFTypeRef, end: CFTypeRef)? {
- guard let start = self.copy("AXStartTextMarkerForTextMarkerRange", range),
- let end = self.copy("AXEndTextMarkerForTextMarkerRange", range)
- else { return nil }
- return (start, end)
+ // Safari on macOS 27 dropped these parameterized attributes; the range itself still holds both ends.
+ if let start = self.copy("AXStartTextMarkerForTextMarkerRange", range),
+ let end = self.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? {
5 changes: 4 additions & 1 deletion native/sources/client/actions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,11 @@ private struct ActionRequest: Decodable {
break
case let .double(number)? where number.isFinite:
break
// An ISO 8601 date, which the element check accepts only for date and time controls.
case .string?:
break
default:
throw ActionError("Set a finite number or true/false; use desktop_type for text.")
throw ActionError("Set a finite number, true/false or an ISO 8601 date; use desktop_type for text.")
}
}
case .type, .select:
Expand Down
3 changes: 2 additions & 1 deletion native/sources/client/management.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ private struct ManagementMessage: Encodable {
let code: String
let message: String
let hint: String?
var cause: String?
}

private struct ManagementResult: Encodable {
Expand Down Expand Up @@ -314,7 +315,7 @@ func nativeManagement(_ client: PeekabooBridgeClient, handshake: PeekabooBridgeH
receipt = Receipt(pid: closing.ownerProcessIdentifier, window_id: closing.windowID, process_start_identity_decimal: String(closing.ownerProcessStartIdentity))
}
result.requires_fresh_observation = failure.outcome.dispatchState.mutationDispatched || failure.outcome.escalation == .refreshTarget
result.error = ManagementMessage(code: failure.standardErrorCode?.rawValue ?? "DESKTOP_ACTION_FAILED", message: failure.message, hint: failure.hint)
result.error = ManagementMessage(code: failure.standardErrorCode?.rawValue ?? "DESKTOP_ACTION_FAILED", message: failure.message, hint: failure.hint, cause: failure.causeDescription)
} catch {
result.outcome = invoked ? "unknown" : "refused"
result.requires_fresh_observation = invoked
Expand Down
5 changes: 4 additions & 1 deletion native/sources/client/table.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ private struct TableElement: Encodable {
let selected: Bool?
let disclosure_level: Int?
let disclosing: Bool?
/// A web table names this cell as one of its column headers.
let column_header: Bool?
}

private struct TableScope: Encodable {
Expand Down Expand Up @@ -124,7 +126,8 @@ func nativeTable(_ client: PeekabooBridgeClient) async throws -> Data {
bounds: Bounds(element.bounds),
selected: element.isSelected,
disclosure_level: attributes["disclosureLevel"].flatMap(Int.init),
disclosing: attributes["disclosing"].map { $0 == "true" }
disclosing: attributes["disclosing"].map { $0 == "true" },
column_header: attributes["columnHeader"] == "true" ? true : nil
)
bytes += try JSONEncoder().encode(row).count + 1
guard bytes <= tableOutputLimit else {
Expand Down
Loading
Loading