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

- Agent input aimed at any Desktop Tools process, including another build of it, or sent while one of its prompts or menus is open, is refused with `SELF_TARGET_REFUSED`, so only the user can answer its prompts or choose Pause All Input. Agents can still observe them. ([#120](https://github.com/githubnext/desktop-tools/pull/120))

### Fixed

- `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))

## [0.13.1] - 2026-10-17

### Fixed
Expand Down
19 changes: 19 additions & 0 deletions dist/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ const patches = [
"peekaboo-secure-subrole.patch",
"peekaboo-web-accessibility-enhanced-ui.patch",
"peekaboo-caller-admission.patch",
"peekaboo-clipboard-image-export-allowlist.patch",
"peekaboo-paste-window-release.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 Expand Up @@ -145,6 +147,23 @@ try {
finally {
rmSync(temporary, { recursive: true, force: true });
}
// The embedded host silently drops operations outside Peekaboo's embedded allowlist, so a new operation
// reached clients only as "not supported". Fail the build instead.
const operationList = (source, start) => {
const from = source.indexOf(start);
if (from < 0)
throw new Error(`Cannot find ${start} in the native sources`);
const block = source.slice(from + start.length, source.indexOf("]", from + start.length));
return new Set([...block.matchAll(/\.(\w+)/g)].map((match) => match[1]));
};
const policy = readFileSync(join(checkoutPath, "Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperation+Policy.swift"), "utf8");
const remote = operationList(policy, "remoteDefaultAllowlist: Set<PeekabooBridgeOperation> = [");
const excluded = operationList(policy, "remoteDefaultAllowlist.subtracting([");
const requested = operationList(readFileSync(join(native, "sources", "runtime", "runtime.swift"), "utf8"), "allowedOperations: [");
const dropped = [...requested].filter((name) => !remote.has(name) || excluded.has(name));
if (dropped.length) {
throw new Error(`The embedded host would drop these allowed operations: ${dropped.join(", ")}`);
}
const build = [
"xcrun",
"swift",
Expand Down
25 changes: 19 additions & 6 deletions dist/wait.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,23 @@ async function listWindows(reads, pid, signal) {
// Accessibility found every window only when no row or warning says its enumeration was cut short.
const accessible = !rows.some((row) => row.observation_reason === "accessibility_enumeration_incomplete")
&& !(data.inventory_warnings ?? []).some((warning) => /accessibility|timed out/i.test(warning));
return { rows, complete: data.inventory_completeness === "complete", accessible };
return { rows, complete: data.inventory_completeness === "complete", accessible, hidden: data.app_hidden === true };
}
/**
* Whether a window counts as open. Off-screen windows on no Space, such as the Finder's menu-bar strips, never
* do. Neither does one off screen on its display's current Space while its app isn't hidden, such as the
* ordered-out Spotlight panel that macOS 27 keeps listed.
*/
function windowShown(row, appHidden) {
if (row.is_on_screen === true || row.is_minimized === true)
return true;
const spaces = Array.isArray(row.space_ids) ? row.space_ids : [];
if (!spaces.length)
return false;
const current = row.display_current_space_id;
if (typeof current !== "number" || appHidden || row.display_asleep === true)
return true;
return !spaces.includes(current);
}
/**
* The window's desktop_windows row, or why it is certainly gone: unlisted in a complete inventory, or its
Expand Down Expand Up @@ -178,12 +194,9 @@ function pollWindowAppears(reads, request) {
const errors = [];
for (const pid of pids) {
try {
const { rows } = await listWindows(reads, pid, signal);
const { rows, hidden } = await listWindows(reads, pid, signal);
for (const row of rows) {
// Off-screen windows on no Space, such as the Finder's menu-bar strips, are never a window appearing.
const shown = row.is_on_screen === true || row.is_minimized === true
|| (Array.isArray(row.space_ids) && row.space_ids.length > 0);
if (!shown)
if (!windowShown(row, hidden))
continue;
const title = String(row.window_title ?? "");
const fits = request.title !== undefined
Expand Down
2 changes: 1 addition & 1 deletion docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Captures go through ScreenCaptureKit, which every Peekaboo process for the user

`desktop_wait` replaces polling `desktop_inspect` or `desktop_windows` after slow work such as a launch, a page load, an export or a dialog opening. Each call waits for one condition (`until`):

- `window_appears`: an app, by `bundle_id` or `pid`, has a window that is on screen, minimized or on a Space, optionally with an exact `title` or a `title_contains` substring. `window_gone`: the window `pid` and `window` is no longer listed, or its app quit.
- `window_appears`: an app, by `bundle_id` or `pid`, has a window that is on screen, minimized, or on another Space or in a hidden app (a window macOS keeps listed but ordered out on the current Space, such as the Spotlight panel, doesn't count), optionally with an exact `title` or a `title_contains` substring. `window_gone`: the window `pid` and `window` is no longer listed, or its app quit.
- `dialog_appears`, `dialog_gone`: `desktop_dialog` finds a dialog, alert or sheet in app `pid`, or in its window `window`, or finds none.
- `element_appears`, `element_gone`: an element of window `pid` and `window` matches `element`, an exact role and at least one of an exact title, value or identifier.
- `text_changes`, `text_equals`, `text_contains`: the window's text, a `scope`'s text, or the value of the one element `element` matches, differs from the first poll, equals `text` or contains it.
Expand Down
8 changes: 8 additions & 0 deletions native/patches/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -591,3 +591,11 @@ the update.
- **Target.** The target is the resolved window's owner, else the scope's process, else the first process identity, window identity or action receipt found in the request by reflection (at most six levels deep), else the owner of the window of the snapshot the request names. `snapshotWindow` becomes internal for that.
- **Handshake.** The hook is also asked at the handshake, as `permissionsStatus`, so a refused client learns why before it asks for anything; the refusal is a `permissionDenied` envelope and the client gets no operations.
- **Tests.** The admission test checks the client pid, a new test checks targets from a scope and from a request's process identity, and another checks a handshake refusal. No operation is added.

`peekaboo-clipboard-image-export-allowlist.patch` adds `clipboardImageExport` to the remote default allowlist, next to `clipboardImageRead`. The embedded host intersects its allowed operations with the embedded allowlist, which is built from that list, so the export was dropped and every `save_path` read was refused as not supported. The build now fails if an operation the runtime allows is missing from the embedded allowlist.
- **Tests.** The embedded runtime test expects the export in the embedded allowlist.

`peekaboo-paste-window-release.patch` lets a closed receiver window resolve a pending clipboard paste. The reservation names the receiver's process and window, and before this only observed consumption or that process quitting released it, so a paste the receiver never took (TextEdit holds an image paste into an `.rtf` document behind its convert sheet) blocked every clipboard write until the app quit.
- **Release.** Admission also releases the reservation when the window server no longer lists the window, or lists it for another process. An unavailable window list proves nothing.
- **Refusal.** The hint now names closing that window as a way out.
- **Tests.** `ClipboardPasteTransactionGateTests` covers a closed window, a reused window number, a live window and an unavailable list. No operation is added.
25 changes: 25 additions & 0 deletions native/patches/peekaboo-clipboard-image-export-allowlist.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
diff --git a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperation+Policy.swift b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperation+Policy.swift
index 943448ed0..ad5b3949f 100644
--- a/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperation+Policy.swift
+++ b/Core/PeekabooCore/Sources/PeekabooBridge/PeekabooBridgeOperation+Policy.swift
@@ -29,7 +29,7 @@ extension PeekabooBridgeOperation {

/// Operations enabled by default for remote helper hosts.
public static let remoteDefaultAllowlist: Set<PeekabooBridgeOperation> = [
- .literalInsert, .attachmentInsert, .clipboardTextRead, .clipboardImageRead, .clipboardFilesRead, .clipboardTextWrite, .clipboardImageWrite, .clipboardFilesWrite,
+ .literalInsert, .attachmentInsert, .clipboardTextRead, .clipboardImageRead, .clipboardImageExport, .clipboardFilesRead, .clipboardTextWrite, .clipboardImageWrite, .clipboardFilesWrite,
.permissionsStatus,
.requestPostEventPermission,
.daemonStatus,
diff --git a/Core/PeekabooCore/Tests/PeekabooBridgeTests/PeekabooEmbeddedBridgeRuntimeTests.swift b/Core/PeekabooCore/Tests/PeekabooBridgeTests/PeekabooEmbeddedBridgeRuntimeTests.swift
index c209238f3..32331dec2 100644
--- a/Core/PeekabooCore/Tests/PeekabooBridgeTests/PeekabooEmbeddedBridgeRuntimeTests.swift
+++ b/Core/PeekabooCore/Tests/PeekabooBridgeTests/PeekabooEmbeddedBridgeRuntimeTests.swift
@@ -58,6 +58,7 @@ struct PeekabooEmbeddedBridgeRuntimeTests {
#expect(!operations.contains(.agentExecutionTrace))
#expect(!operations.contains(._appleScriptProbe))
#expect(operations.contains(.desktopObservation))
+ #expect(operations.contains(.clipboardImageExport))
#expect(operations.contains(.exactWindowTargetedClick))
#expect(operations.contains(.backgroundDialogClickButton))

65 changes: 65 additions & 0 deletions native/patches/peekaboo-paste-window-release.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
diff --git a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/ClipboardPasteTransactionGate.swift b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/ClipboardPasteTransactionGate.swift
index f58724700..be9f173f4 100644
--- a/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/ClipboardPasteTransactionGate.swift
+++ b/Core/PeekabooAutomationKit/Sources/PeekabooAutomationKit/Services/System/ClipboardPasteTransactionGate.swift
@@ -1,3 +1,4 @@
+import CoreGraphics
import Darwin
import Foundation
import PeekabooFoundation
@@ -213,7 +214,7 @@ public enum ClipboardPasteTransactionGate {
if self.pendingProof?.path == transaction.path { self.pendingProof = nil }
return
}
- var resolved = self.processGenerationTerminated(reservation)
+ var resolved = self.processGenerationTerminated(reservation) || self.receiverWindowClosed(reservation)
if !resolved, let proof = self.pendingProof,
proof.path == transaction.path, proof.id == reservation.id
{
@@ -226,7 +227,20 @@ public enum ClipboardPasteTransactionGate {
throw DesktopActionFailure.preDispatchRefusal(
reason: .targetUnavailable,
message: "A previous clipboard paste to process \(reservation.pid), window \(reservation.windowID) remains unresolved.",
- hint: "No new clipboard mutation was sent. Inspect the original receiver; its observed consumption or original process termination must resolve ownership first.")
+ hint: "No new clipboard mutation was sent. Inspect the original receiver; its observed consumption, closing that window, or the original process quitting resolves ownership.")
+ }
+
+ /// A closed receiver window can no longer consume the pending paste, so it resolves ownership like
+ /// process termination does. An unavailable window list proves nothing.
+ private static func receiverWindowClosed(_ reservation: Reservation) -> Bool {
+ let info = CGWindowListCopyWindowInfo(.optionIncludingWindow, CGWindowID(reservation.windowID))
+ return self.windowClosed(pid: reservation.pid, windowInfo: info as? [[String: Any]])
+ }
+
+ static func windowClosed(pid: Int32, windowInfo: [[String: Any]]?) -> Bool {
+ guard let windowInfo else { return false }
+ guard let owner = windowInfo.first?[kCGWindowOwnerPID as String] as? Int else { return true }
+ return owner != Int(pid)
}

private static func processGenerationTerminated(_ reservation: Reservation) -> Bool {
diff --git a/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/ClipboardPasteTransactionGateTests.swift b/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/ClipboardPasteTransactionGateTests.swift
index 152a1b3f8..a3faa3d3a 100644
--- a/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/ClipboardPasteTransactionGateTests.swift
+++ b/Core/PeekabooAutomationKit/Tests/PeekabooAutomationKitTests/ClipboardPasteTransactionGateTests.swift
@@ -1,3 +1,4 @@
+import CoreGraphics
import Darwin
import Foundation
import PeekabooFoundation
@@ -12,6 +13,15 @@ struct ClipboardPasteTransactionGateTests {
#expect(ClipboardPasteTransactionGate.processStartIdentity(-1) == nil)
}

+ @Test
+ func `A closed or reused receiver window resolves a paste, an unavailable list does not`() {
+ let owner = kCGWindowOwnerPID as String
+ #expect(ClipboardPasteTransactionGate.windowClosed(pid: 42, windowInfo: []))
+ #expect(ClipboardPasteTransactionGate.windowClosed(pid: 42, windowInfo: [[owner: 43]]))
+ #expect(!ClipboardPasteTransactionGate.windowClosed(pid: 42, windowInfo: [[owner: 42]]))
+ #expect(!ClipboardPasteTransactionGate.windowClosed(pid: 42, windowInfo: nil))
+ }
+
@Test
@MainActor
func `Transaction waits for an independently held process lock`() async throws {
Loading
Loading