Skip to content

Commit abb4e87

Browse files
authored
fix(cli): default clicks to background delivery (#168)
1 parent c15ff18 commit abb4e87

27 files changed

Lines changed: 263 additions & 96 deletions

Apps/CLI/Sources/PeekabooCLI/Commands/Base/CommanderBinder.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,9 @@ extension CommanderBindableValues {
279279
options.noAutoFocus = self.flag("noAutoFocus")
280280
options.spaceSwitch = self.flag("spaceSwitch")
281281
options.bringToCurrentSpace = self.flag("bringToCurrentSpace")
282-
options.focusBackground = includeBackgroundDelivery && self.flag("focusBackground")
282+
if includeBackgroundDelivery && self.flag("focusBackground") {
283+
options.focusBackground = true
284+
}
283285
if let timeout: TimeInterval = try decodeOption("focusTimeoutSeconds", as: TimeInterval.self) {
284286
options.focusTimeoutSeconds = timeout
285287
}

Apps/CLI/Sources/PeekabooCLI/Commands/Core/LearnCommand.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ struct LearnCommand {
126126
5. Recover from errors by trying alternative interactions (menus, hotkeys).
127127
6. Common workflows:
128128
- Screenshot: `image` with `--app` or `--mode screen`.
129-
- Typing: `click` the field, then `type` the text.
129+
- Typing: `click --foreground` the field, then `type` the text.
130130
- Menus: `menu click --path ...`.
131131
- Keyboard shortcuts: `hotkey`.
132132
""", to: &output)

Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/ClickCommand+CommanderMetadata.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ extension ClickCommand: CommanderBindableCommand {
3131
}
3232
self.double = values.flag("double")
3333
self.right = values.flag("right")
34+
self.foreground = values.flag("foreground")
3435
self.focusOptions = try values.makeFocusOptions(includeBackgroundDelivery: true)
3536
}
3637
}
@@ -83,6 +84,11 @@ extension ClickCommand: CommanderSignatureProviding {
8384
help: "Right-click (secondary click)",
8485
long: "right"
8586
),
87+
.commandFlag(
88+
"foreground",
89+
help: "Focus target and send a foreground mouse click",
90+
long: "foreground"
91+
),
8692
.commandFlag(
8793
"globalCoords",
8894
help: "Treat --coords as global screen coordinates even with target options",

Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/ClickCommand+Output.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ struct ClickResult: Codable {
1414
let inputCoordinates: [String: Double]?
1515
let screenCoordinates: [String: Double]?
1616
let targetPoint: InteractionTargetPointDiagnostics?
17+
let deliveryMode: String?
1718

1819
init(
1920
success: Bool,
@@ -27,7 +28,8 @@ struct ClickResult: Codable {
2728
coordinateSpace: String? = nil,
2829
inputCoordinates: CGPoint? = nil,
2930
screenCoordinates: CGPoint? = nil,
30-
targetPoint: InteractionTargetPointDiagnostics? = nil
31+
targetPoint: InteractionTargetPointDiagnostics? = nil,
32+
deliveryMode: String? = nil
3133
) {
3234
self.success = success
3335
self.clickedElement = clickedElement
@@ -41,5 +43,6 @@ struct ClickResult: Codable {
4143
self.inputCoordinates = inputCoordinates.map { ["x": $0.x, "y": $0.y] }
4244
self.screenCoordinates = screenCoordinates.map { ["x": $0.x, "y": $0.y] }
4345
self.targetPoint = targetPoint
46+
self.deliveryMode = deliveryMode
4447
}
4548
}

Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/ClickCommand+Validation.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ extension ClickCommand {
2525
if self.globalCoords && self.coords == nil {
2626
throw ValidationError("--global-coords requires --coords")
2727
}
28+
29+
if self.foreground && self.focusOptions.backgroundDeliveryExplicitlyRequested {
30+
throw ValidationError("--foreground cannot be combined with --focus-background")
31+
}
32+
33+
if self.focusOptions.backgroundDeliveryExplicitlyRequested &&
34+
self.focusOptions.hasForegroundFocusOverrides {
35+
throw ValidationError("--focus-background cannot be combined with focus options")
36+
}
2837
}
2938

3039
func formatElementInfo(_ element: DetectedElement) -> String {

Apps/CLI/Sources/PeekabooCLI/Commands/Interaction/ClickCommand.swift

Lines changed: 83 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ struct ClickCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConf
3737
@Flag(help: "Right-click (secondary click)")
3838
var right = false
3939

40+
@Flag(help: "Focus target and send a foreground mouse click")
41+
var foreground = false
42+
4043
@OptionGroup var focusOptions: FocusCommandOptions
4144

4245
@RuntimeStorage private var runtime: CommandRuntime?
@@ -65,6 +68,20 @@ struct ClickCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConf
6568
self.runtime?.configuration.jsonOutput ?? self.runtimeOptions.jsonOutput
6669
}
6770

71+
private var deliveryMode: ClickDeliveryMode {
72+
if self.focusOptions.backgroundDeliveryExplicitlyRequested {
73+
return .background
74+
}
75+
if self.foreground || self.focusOptions.hasForegroundFocusOverrides {
76+
return .foreground
77+
}
78+
return .background
79+
}
80+
81+
private var usesBackgroundDelivery: Bool {
82+
self.deliveryMode == .background
83+
}
84+
6885
@MainActor
6986
mutating func run(using runtime: CommandRuntime) async throws {
7087
self.runtime = runtime
@@ -106,7 +123,7 @@ struct ClickCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConf
106123
// InputDriver.click() sends a CGEvent at screen-absolute coordinates,
107124
// so if the target window is not frontmost, the click will land on
108125
// whatever window is at that position (see #90).
109-
if !self.focusOptions.focusBackground {
126+
if !self.usesBackgroundDelivery {
110127
try await self.verifyFocusForCoordinateClick(coordinateResolution: resolvedCoordinates)
111128
}
112129

@@ -126,7 +143,7 @@ struct ClickCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConf
126143
let elementId = self.on ?? self.id
127144

128145
if let elementId {
129-
if !self.focusOptions.focusBackground {
146+
if !self.usesBackgroundDelivery {
130147
observation = try await InteractionObservationRefresher.refreshForMissingElementsIfNeeded(
131148
observation,
132149
elementIds: [elementId],
@@ -139,7 +156,7 @@ struct ClickCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConf
139156
activeSnapshotId = observation.snapshotId ?? ""
140157

141158
clickTarget = .elementId(elementId)
142-
if self.focusOptions.focusBackground {
159+
if self.usesBackgroundDelivery {
143160
let element = try await self.cachedElementById(elementId, observation: observation)
144161
waitResult = WaitForElementResult(found: true, element: element, waitTime: 0)
145162
} else {
@@ -157,13 +174,13 @@ struct ClickCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConf
157174
}
158175

159176
} else if let searchQuery = query {
160-
if !self.focusOptions.focusBackground {
177+
if !self.usesBackgroundDelivery {
161178
observation = try await self.refreshObservationIfQueryMissing(observation, query: searchQuery)
162179
}
163180
observationForInvalidation = observation
164181
activeSnapshotId = observation.snapshotId ?? ""
165182

166-
if self.focusOptions.focusBackground {
183+
if self.usesBackgroundDelivery {
167184
let element = try await self.cachedElementMatching(searchQuery, observation: observation)
168185
clickTarget = .elementId(element.id)
169186
waitResult = WaitForElementResult(found: true, element: element, waitTime: 0)
@@ -194,7 +211,12 @@ struct ClickCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConf
194211

195212
// Determine click type
196213
let clickType: ClickType = self.right ? .right : (self.double ? .double : .single)
197-
try await self.performClick(clickTarget, clickType: clickType, snapshotId: activeSnapshotId)
214+
try await self.performClick(
215+
clickTarget,
216+
clickType: clickType,
217+
snapshotId: activeSnapshotId,
218+
coordinateResolution: coordinateResolution
219+
)
198220

199221
// Brief delay to ensure click is processed
200222
try await Task.sleep(nanoseconds: 20_000_000) // 0.02 seconds
@@ -224,7 +246,8 @@ struct ClickCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConf
224246
coordinateSpace: coordinateResolution?.coordinateSpace.rawValue,
225247
inputCoordinates: coordinateResolution?.inputPoint,
226248
screenCoordinates: coordinateResolution?.screenPoint,
227-
targetPoint: details.targetPointDiagnostics
249+
targetPoint: details.targetPointDiagnostics,
250+
deliveryMode: self.deliveryMode.rawValue
228251
)
229252

230253
if let observationForInvalidation {
@@ -312,7 +335,7 @@ struct ClickCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConf
312335
return "window \(windowID)"
313336
}
314337

315-
guard self.focusOptions.focusBackground else {
338+
guard self.usesBackgroundDelivery else {
316339
return await self.frontmostApplicationName()
317340
}
318341

@@ -329,6 +352,14 @@ struct ClickCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConf
329352
guard !snapshotId.isEmpty,
330353
let snapshot = try? await self.services.snapshots.getUIAutomationSnapshot(snapshotId: snapshotId)
331354
else {
355+
if let detectionResult = try? await self.services.snapshots.getDetectionResult(snapshotId: snapshotId) {
356+
if let applicationName = detectionResult.metadata.windowContext?.applicationName {
357+
return applicationName
358+
}
359+
if let processId = detectionResult.metadata.windowContext?.applicationProcessId {
360+
return await self.applicationName(processIdentifier: processId) ?? "PID \(processId)"
361+
}
362+
}
332363
return await self.frontmostApplicationName()
333364
}
334365

@@ -354,8 +385,8 @@ struct ClickCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConf
354385
output(result) {
355386
print("✅ Click successful")
356387
print("🎯 App: \(result.targetApp)")
357-
if self.focusOptions.focusBackground {
358-
print("🎯 Mode: background")
388+
if let deliveryMode = result.deliveryMode {
389+
print("🎯 Mode: \(deliveryMode)")
359390
}
360391
if let coordinateSpace = result.coordinateSpace {
361392
print("🎯 Coordinate space: \(coordinateSpace)")
@@ -456,15 +487,23 @@ struct ClickCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConf
456487
return score
457488
}
458489

459-
private func performClick(_ target: ClickTarget, clickType: ClickType, snapshotId: String) async throws {
490+
private func performClick(
491+
_ target: ClickTarget,
492+
clickType: ClickType,
493+
snapshotId: String,
494+
coordinateResolution: InteractionCoordinateResolution?
495+
) async throws {
460496
let effectiveSnapshotId: String? = if case .coordinates = target {
461497
nil
462498
} else {
463499
snapshotId.isEmpty ? nil : snapshotId
464500
}
465501

466-
if self.focusOptions.focusBackground {
467-
let pid = try await self.resolveBackgroundClickProcessIdentifier(snapshotId: effectiveSnapshotId)
502+
if self.usesBackgroundDelivery {
503+
let pid = try await self.resolveBackgroundClickProcessIdentifier(
504+
snapshotId: effectiveSnapshotId,
505+
coordinateResolution: coordinateResolution
506+
)
468507
try await AutomationServiceBridge.click(
469508
automation: self.services.automation,
470509
target: target,
@@ -486,7 +525,7 @@ struct ClickCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConf
486525
snapshotId: String?,
487526
coordinateResolution: InteractionCoordinateResolution? = nil
488527
) async throws {
489-
if self.focusOptions.focusBackground {
528+
if self.usesBackgroundDelivery {
490529
try self.validateBackgroundClickOptions()
491530
return
492531
}
@@ -523,17 +562,22 @@ struct ClickCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConf
523562
}
524563

525564
private func validateBackgroundClickOptions() throws {
526-
if self.focusOptions.focusTimeoutSeconds != nil ||
527-
self.focusOptions.focusRetryCount != nil ||
528-
self.focusOptions.spaceSwitch ||
529-
self.focusOptions.bringToCurrentSpace {
565+
if self.foreground, self.focusOptions.backgroundDeliveryExplicitlyRequested {
566+
throw ValidationError("--foreground cannot be combined with --focus-background")
567+
}
568+
569+
if self.focusOptions.backgroundDeliveryExplicitlyRequested &&
570+
self.focusOptions.hasForegroundFocusOverrides {
530571
throw ValidationError("--focus-background cannot be combined with focus options")
531572
}
532573
}
533574

534-
private func resolveBackgroundClickProcessIdentifier(snapshotId: String?) async throws -> pid_t {
575+
private func resolveBackgroundClickProcessIdentifier(
576+
snapshotId: String?,
577+
coordinateResolution: InteractionCoordinateResolution?
578+
) async throws -> pid_t {
535579
if self.target.pid != nil, self.target.app != nil {
536-
throw ValidationError("--focus-background accepts one process target: use --app or --pid")
580+
throw ValidationError("Background click accepts one process target: use --app or --pid")
537581
}
538582

539583
if let pid = self.target.pid {
@@ -549,14 +593,32 @@ struct ClickCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConf
549593
return pid_t(app.processIdentifier)
550594
}
551595

596+
if let processId = coordinateResolution?.targetProcessIdentifier {
597+
return pid_t(processId)
598+
}
599+
552600
if let snapshotId,
553601
let snapshot = try? await self.services.snapshots.getUIAutomationSnapshot(snapshotId: snapshotId),
554602
let processId = snapshot.applicationProcessId {
555603
return pid_t(processId)
556604
}
557605

558-
throw ValidationError("--focus-background requires --app, --pid, or a snapshot with process metadata")
606+
if let snapshotId,
607+
let detectionResult = try? await self.services.snapshots.getDetectionResult(snapshotId: snapshotId),
608+
let processId = detectionResult.metadata.windowContext?.applicationProcessId {
609+
return pid_t(processId)
610+
}
611+
612+
throw ValidationError(
613+
"Background click requires --app, --pid, --window-id, or a snapshot with process metadata; " +
614+
"use --foreground for foreground screen clicks"
615+
)
559616
}
560617

561618
// Error handling is provided by ErrorHandlingCommand protocol
562619
}
620+
621+
private enum ClickDeliveryMode: String {
622+
case background
623+
case foreground
624+
}

Apps/CLI/Sources/PeekabooCLI/Commands/Shared/FocusCommandOptions+CommanderMetadata.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ extension FocusCommandOptions {
2828
if includeBackgroundDelivery {
2929
flags.append(.commandFlag(
3030
"focusBackground",
31-
help: "Send input to the target process without focusing it",
31+
help: "Send input to the target process without focusing it (default for click)",
3232
long: "focus-background"
3333
))
3434
}

Apps/CLI/Sources/PeekabooCLI/Commands/Shared/FocusCommandOptions.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ struct FocusCommandOptions: CommanderParsable, FocusOptionsProtocol {
2626
set { self.focusBackgroundStorage = newValue }
2727
}
2828

29+
var backgroundDeliveryExplicitlyRequested: Bool {
30+
self.focusBackgroundStorage == true
31+
}
32+
33+
var hasForegroundFocusOverrides: Bool {
34+
self.noAutoFocus ||
35+
self.focusTimeoutSeconds != nil ||
36+
self.focusRetryCount != nil ||
37+
self.spaceSwitch ||
38+
self.bringToCurrentSpace
39+
}
40+
2941
init() {}
3042

3143
// MARK: FocusOptionsProtocol

Apps/CLI/Tests/CLIAutomationTests/ClickCommandFocusTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ struct ClickCommandFocusTests {
2121
let result = try await self.runPeekabooCommand(["click", "--help"])
2222
let output = result.combinedOutput
2323

24+
#expect(output.contains("--foreground"))
2425
#expect(output.contains("--no-auto-focus"))
2526
#expect(output.contains("--focus-timeout-seconds"))
2627
#expect(output.contains("--focus-retry-count"))

0 commit comments

Comments
 (0)