import AppKit
import AXorcist
import Commander
import CoreGraphics
import Foundation
import PeekabooCore
import PeekabooFoundation
/// Performs swipe gestures using intelligent element finding and service-based architecture.
@available(macOS 14.0, *)
@MainActor
struct SwipeCommand: ErrorHandlingCommand, OutputFormattable, RuntimeOptionsConfigurable {
@Option(help: "Source element ID")
var from: String?
@Option(help: "Source coordinates (x,y)")
var fromCoords: String?
@Option(help: "Destination element ID")
var to: String?
@Option(help: "Destination coordinates (x,y)")
var toCoords: String?
@Option(help: "Session ID (uses latest if not specified)")
var session: String?
@Option(help: "Duration of the swipe in milliseconds")
var duration: Int?
@Option(help: "Number of intermediate points for smooth movement")
var steps: Int?
@Option(help: "Movement profile (linear or human)")
var profile: String?
@Flag(help: "Use right mouse button for drag")
var rightButton = false
@RuntimeStorage private var runtime: CommandRuntime?
var runtimeOptions = CommandRuntimeOptions()
private var resolvedRuntime: CommandRuntime {
guard let runtime else {
preconditionFailure("CommandRuntime must be configured before accessing runtime resources")
}
return runtime
}
private var services: any PeekabooServiceProviding { self.resolvedRuntime.services }
private var logger: Logger { self.resolvedRuntime.logger }
var outputLogger: Logger { self.logger }
var jsonOutput: Bool { self.runtime?.configuration.jsonOutput ?? self.runtimeOptions.jsonOutput }
@MainActor
mutating func run(using runtime: CommandRuntime) async throws {
self.runtime = runtime
let startTime = Date()
self.logger.setJsonOutputMode(self.jsonOutput)
do {
// Validate inputs
guard self.from != nil || self.fromCoords != nil, self.to != nil || self.toCoords != nil else {
throw ValidationError(
"Must specify both source (--from or --from-coords) and destination (--to or --to-coords)"
)
}
// Note: Right-button swipe is not supported in the current implementation
if self.rightButton {
throw ValidationError(
"Right-button swipe is not currently supported. " +
"Please use the standard swipe command for right-button gestures."
)
}
if let profileName = self.profile?.lowercased(),
CursorMovementProfileSelection(rawValue: profileName) == nil {
throw ValidationError("Invalid profile '\(profileName)'. Use 'linear' or 'human'.")
}
// Determine session ID - use provided or get most recent
let sessionId: String? = if let providedSession = session {
providedSession
} else {
await self.services.sessions.getMostRecentSession()
}
// Get source and destination points
let sourcePoint = try await resolvePoint(
elementId: from,
coords: fromCoords,
sessionId: sessionId,
description: "from",
waitTimeout: 5.0
)
let destPoint = try await resolvePoint(
elementId: to,
coords: toCoords,
sessionId: sessionId,
description: "to",
waitTimeout: 5.0
)
let distance = hypot(destPoint.x - sourcePoint.x, destPoint.y - sourcePoint.y)
let profileSelection = CursorMovementProfileSelection(
rawValue: (self.profile ?? "linear").lowercased()
) ?? .linear
let movement = CursorMovementResolver.resolve(
selection: profileSelection,
durationOverride: self.duration,
stepsOverride: self.steps,
baseSmooth: true,
distance: distance,
defaultDuration: 500,
defaultSteps: 20
)
// Perform swipe using UIAutomationService
try await AutomationServiceBridge.swipe(
automation: self.services.automation,
from: sourcePoint,
to: destPoint,
duration: movement.duration,
steps: movement.steps,
profile: movement.profile
)
AutomationEventLogger.log(
.gesture,
"swipe from=(\(Int(sourcePoint.x)),\(Int(sourcePoint.y))) to=(\(Int(destPoint.x)),\(Int(destPoint.y))) "
+ "profile=\(movement.profileName) steps=\(movement.steps) session=\(sessionId ?? "latest")"
)
// Small delay to ensure swipe is processed
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
let outputPayload = SwipeResult(
success: true,
fromLocation: ["x": sourcePoint.x, "y": sourcePoint.y],
toLocation: ["x": destPoint.x, "y": destPoint.y],
distance: distance,
duration: movement.duration,
steps: movement.steps,
profile: movement.profileName,
executionTime: Date().timeIntervalSince(startTime)
)
output(outputPayload) {
print("✅ Swipe completed")
print("📍 From: (\(Int(sourcePoint.x)), \(Int(sourcePoint.y)))")
print("📍 To: (\(Int(destPoint.x)), \(Int(destPoint.y)))")
print("📏 Distance: \(Int(distance)) pixels")
print("🧭 Profile: \(movement.profileName.capitalized)")
print("⏱️ Duration: \(movement.duration)ms with \(movement.steps) steps")
print("⏱️ Completed in \(String(format: "%.2f", Date().timeIntervalSince(startTime)))s")
}
} catch {
self.handleError(error)
throw ExitCode.failure
}
}
private func resolvePoint(
elementId: String?,
coords: String?,
sessionId: String?,
description: String,
waitTimeout: TimeInterval
) async throws -> CGPoint {
if let coordString = coords {
// Parse coordinates
let parts = coordString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
guard parts.count == 2,
let x = Double(parts[0]),
let y = Double(parts[1])
else {
throw ValidationError("Invalid coordinates format: '\(coordString)'. Expected 'x,y'")
}
return CGPoint(x: x, y: y)
} else if let element = elementId, let activeSessionId = sessionId {
// Resolve from session using waitForElement
let target = ClickTarget.elementId(element)
let waitResult = try await AutomationServiceBridge.waitForElement(
automation: self.services.automation,
target: target,
timeout: waitTimeout,
sessionId: activeSessionId
)
if !waitResult.found {
throw PeekabooError.elementNotFound("Element with ID '\(element)' not found")
}
guard let foundElement = waitResult.element else {
throw PeekabooError.elementNotFound("Element '\(element)' found but has no bounds")
}
// Return center of element
return CGPoint(
x: foundElement.bounds.origin.x + foundElement.bounds.width / 2,
y: foundElement.bounds.origin.y + foundElement.bounds.height / 2
)
} else if elementId != nil {
throw ValidationError("Session ID required when using element IDs")
} else {
throw ValidationError("No \(description) point specified")
}
}
}
// MARK: - JSON Output Structure
struct SwipeResult: Codable {
let success: Bool
let fromLocation: [String: Double]
let toLocation: [String: Double]
let distance: Double
let duration: Int
let steps: Int
let profile: String
let executionTime: TimeInterval
}
// MARK: - Conformances
@MainActor
extension SwipeCommand: ParsableCommand {
nonisolated(unsafe) static var commandDescription: CommandDescription {
MainActorCommandDescription.describe {
CommandDescription(
commandName: "swipe",
abstract: "Perform swipe gestures",
discussion: """
Performs a drag/swipe gesture between two points or elements.
Useful for drag-and-drop operations and gesture-based interactions.
EXAMPLES:
# Swipe between UI elements
peekaboo swipe --from B1 --to B5 --session-id 12345
# Swipe with coordinates
peekaboo swipe --from-coords 100,200 --to-coords 300,400
# Mixed mode: element to coordinates
peekaboo swipe --from T1 --to-coords 500,300 --duration 1000
# Slow swipe for precise gesture
peekaboo swipe --from-coords 50,50 --to-coords 400,400 --duration 2000
USAGE:
You can specify source and destination using either:
- Element IDs from a previous 'see' command
- Direct coordinates
- A mix of both
The swipe includes a configurable duration to control the
speed of the drag gesture.
""",
version: "2.0.0",
showHelpOnEmptyInvocation: true
)
}
}
}
extension SwipeCommand: AsyncRuntimeCommand {}
@MainActor
extension SwipeCommand: CommanderBindableCommand {
mutating func applyCommanderValues(_ values: CommanderBindableValues) throws {
self.from = values.singleOption("from")
self.fromCoords = values.singleOption("fromCoords")
self.to = values.singleOption("to")
self.toCoords = values.singleOption("toCoords")
self.session = values.singleOption("session")
if let duration: Int = try values.decodeOption("duration", as: Int.self) {
self.duration = duration
}
if let steps: Int = try values.decodeOption("steps", as: Int.self) {
self.steps = steps
}
self.profile = values.singleOption("profile")
self.rightButton = values.flag("rightButton")
}
}