Skip to main content
Glama
SwipeCommand.swift10.8 kB
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") } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/steipete/Peekaboo'

If you have feedback or need assistance with the MCP directory API, please join our Discord server