beatmatchr/Desktop/YabaiPro/Sources/YabaiCommandRunner.swift
BusyBee3333 7694d965c9 feat: Add structured signal editor with app dropdown and action builder
- Add AppDiscovery provider for running app enumeration
- Implement AppDropdownView with auto-launch functionality
- Create SignalAction models for 40+ yabai commands
- Build ActionBuilderView with nested parameter controls
- Add LiveShellPreview for real-time shell command generation
- Implement ActionValidator for conflict detection
- Add migration parser for existing raw action strings
- Include feature flag for safe rollout
- Maintain full backward compatibility
2025-12-31 01:44:13 -05:00

803 lines
27 KiB
Swift

//
// YabaiCommandRunner.swift
// YabaiPro
//
// Created by Jake Shore
// Copyright © 2024 Jake Shore. All rights reserved.
//
import Foundation
class YabaiCommandRunner {
static let shared = YabaiCommandRunner()
// MARK: - Event Subscription
private var eventStream: AsyncThrowingStream<YabaiEvent, Error>?
private var eventContinuation: AsyncThrowingStream<YabaiEvent, Error>.Continuation?
private var lastWindowStates: [YabaiWindowJSON] = []
private var isSubscribed = false
// MARK: - Basic Command Execution
func run(command: String) async throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
// Set up proper environment with Homebrew PATH
var environment = ProcessInfo.processInfo.environment
if let existingPath = environment["PATH"] {
environment["PATH"] = "/opt/homebrew/bin:/opt/homebrew/sbin:" + existingPath
} else {
environment["PATH"] = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin"
}
process.environment = environment
process.arguments = ["-c", command]
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
try process.run()
process.waitUntilExit()
// Check for errors
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
if !errorData.isEmpty {
let errorString = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown error"
if process.terminationStatus != 0 {
throw CommandError.executionFailed(command: command, error: errorString)
}
}
}
// MARK: - JSON Query Methods
func queryJSON(_ query: String) async throws -> Data {
let command = "yabai -m query --\(query)"
// Retry a few times because yabai may occasionally return empty/truncated output
let maxAttempts = 5
for attempt in 1...maxAttempts {
let data = try await runCaptureStdout(command)
if data.count > 0 {
if let first = String(data: data.prefix(1), encoding: .utf8),
first == "[" || first == "{" {
return data
} else {
print("⚠️ yabai query returned unexpected prefix: '\(String(describing: String(data: data.prefix(1), encoding: .utf8)))'; attempt \(attempt)/\(maxAttempts)")
}
} else {
print("⚠️ yabai query returned empty output; attempt \(attempt)/\(maxAttempts)")
}
try await Task.sleep(nanoseconds: UInt64(50_000_000 * attempt)) // backoff
}
// Final attempt - return whatever we get (caller will handle parse error)
return try await runCaptureStdout(command)
}
func queryWindows() async throws -> Data {
try await queryJSON("windows")
}
func querySpaces() async throws -> Data {
try await queryJSON("spaces")
}
func queryDisplays() async throws -> Data {
try await queryJSON("displays")
}
func queryWindow(id: UInt32) async throws -> Data {
try await queryJSON("windows --window \(id)")
}
func querySpace(index: UInt32) async throws -> Data {
try await queryJSON("spaces --space \(index)")
}
func queryDisplay(index: UInt32) async throws -> Data {
try await queryJSON("displays --display \(index)")
}
// MARK: - Window Operations
func focusWindow(direction: WindowDirection) async throws {
try await run(command: "yabai -m window --focus \(direction.rawValue)")
}
func swapWindow(direction: WindowDirection) async throws {
try await run(command: "yabai -m window --swap \(direction.rawValue)")
}
func warpWindow(direction: WindowDirection) async throws {
try await run(command: "yabai -m window --warp \(direction.rawValue)")
}
func toggleWindowFloat() async throws {
try await run(command: "yabai -m window --toggle float")
}
func toggleWindowFullscreen() async throws {
try await run(command: "yabai -m window --toggle zoom-fullscreen")
}
// MARK: - Window Stacking Commands
func stackWindow(direction: WindowDirection) async throws {
try await run(command: "yabai -m window --stack \(direction.rawValue)")
}
func toggleWindowStack() async throws {
try await run(command: "yabai -m window --toggle stack")
}
func unstackWindow() async throws {
try await run(command: "yabai -m window --toggle float") // Unstacks by floating
}
// MARK: - Window Insertion Commands
func insertWindow(at position: WindowInsertPosition) async throws {
try await run(command: "yabai -m window --insert \(position.rawValue)")
}
func toggleWindowSplit() async throws {
try await run(command: "yabai -m window --toggle split")
}
// MARK: - Grid & Layout Commands
func gridWindow(grid: WindowGrid) async throws {
let gridString = "\(grid.rows):\(grid.cols):\(grid.startX):\(grid.startY):\(grid.width):\(grid.height)"
try await run(command: "yabai -m window --grid \(gridString)")
}
func toggleWindowNativeFullscreen() async throws {
try await run(command: "yabai -m window --toggle native-fullscreen")
}
func toggleWindowExposé() async throws {
try await run(command: "yabai -m window --toggle exposé")
}
func toggleWindowPip() async throws {
try await run(command: "yabai -m window --toggle pip")
}
// MARK: - Window Movement & Zoom
func moveWindowToSpace(spaceIndex: UInt32) async throws {
try await run(command: "yabai -m window --space \(spaceIndex)")
}
func moveWindowToDisplay(displayIndex: UInt32) async throws {
try await run(command: "yabai -m window --display \(displayIndex)")
}
func toggleWindowZoomParent() async throws {
try await run(command: "yabai -m window --toggle zoom-parent")
}
func toggleWindowZoomFull() async throws { // Already exists, but keeping for completeness
try await run(command: "yabai -m window --toggle zoom-fullscreen")
}
// MARK: - Advanced Window Positioning
func moveWindowAbsolute(x: Double, y: Double) async throws {
try await run(command: "yabai -m window --move abs:\(Int(x)):\(Int(y))")
}
func moveWindowRelative(deltaX: Double, deltaY: Double) async throws {
try await run(command: "yabai -m window --move rel:\(Int(deltaX)):\(Int(deltaY))")
}
func resizeWindowAbsolute(width: Double, height: Double) async throws {
try await run(command: "yabai -m window --resize abs:\(Int(width)):\(Int(height))")
}
func resizeWindowRelative(deltaWidth: Double, deltaHeight: Double) async throws {
try await run(command: "yabai -m window --resize rel:\(Int(deltaWidth)):\(Int(deltaHeight))")
}
func resizeWindow(id: UInt32, width: Double, height: Double) async throws {
try await run(command: "yabai -m window \(id) --resize abs:\(Int(width)):\(Int(height))")
}
func moveWindow(id: UInt32, x: Double?, y: Double?) async throws {
var args = [String]()
if let x = x {
args.append("x:\(Int(x))")
}
if let y = y {
args.append("y:\(Int(y))")
}
if !args.isEmpty {
try await run(command: "yabai -m window \(id) --move abs:\(args.joined(separator: ":"))")
}
}
// MARK: - Cursor + Brave Development Layouts
func setupCursorBraveLayout() async throws {
// This will position windows for Cursor + Brave development workflow
// Note: Requires windows to be focused first, then positioned
print("💡 Cursor + Brave layout setup ready - focus windows and use positioning commands")
}
/// Positions Cursor as main editor (left 2/3) with Brave on right
func applyCursorBraveMainLayout() async throws {
// Note: This assumes you have Cursor and Brave windows open
// You'd need to focus each window first, then call these functions
print("🎯 Main Layout: Focus Cursor window, then use 'Resize Editor Size' and 'Move to Top-Left'")
print("🌐 Focus Brave window, then use 'Resize Sidebar' and 'Move to Top-Right'")
}
/// Positions windows for research + coding workflow
func applyCursorBraveResearchLayout() async throws {
print("📚 Research Layout: Cursor (70% left), Brave (30% right)")
print("💡 Focus Cursor: 'Resize Editor Size' at position (50,50)")
print("🌐 Focus Brave: 'Resize Sidebar' at position (1370,50)")
}
/// Positions windows for API development
func applyCursorBraveAPILayout() async throws {
print("🔗 API Layout: Cursor (main), Brave (docs + testing split)")
print("💻 Focus Cursor: Editor size at (100,100)")
print("📖 Focus Brave docs: Sidebar at (1320,100)")
print("🧪 Focus Brave testing: Sidebar at (1320,520)")
}
// MARK: - Space Operations
func focusSpace(index: UInt32) async throws {
try await run(command: "yabai -m space --focus \(index)")
}
func balanceSpace() async throws {
try await run(command: "yabai -m space --balance")
}
func mirrorSpace(axis: MirrorAxis) async throws {
try await run(command: "yabai -m space --mirror \(axis.rawValue)-axis")
}
func rotateSpace(degrees: Int) async throws {
try await run(command: "yabai -m space --rotate \(degrees)")
}
// MARK: - Space Creation & Destruction
func createSpace() async throws -> UInt32 {
let output = try await runCaptureStdout("yabai -m space --create")
// Parse the new space index from output
guard let outputStr = String(data: output, encoding: .utf8),
let spaceIndex = UInt32(outputStr.trimmingCharacters(in: .whitespacesAndNewlines)) else {
throw CommandError.invalidOutput
}
return spaceIndex
}
func destroySpace(index: UInt32) async throws {
try await run(command: "yabai -m space --destroy \(index)")
}
// MARK: - Space Movement & Labeling
func moveSpace(fromIndex: UInt32, toIndex: UInt32) async throws {
try await run(command: "yabai -m space \(fromIndex) --move \(toIndex)")
}
func labelSpace(index: UInt32, label: String) async throws {
try await run(command: "yabai -m space \(index) --label \"\(label)\"")
}
func moveSpaceToDisplay(spaceIndex: UInt32, displayIndex: UInt32) async throws {
try await run(command: "yabai -m space \(spaceIndex) --display \(displayIndex)")
}
// MARK: - Space Layout Management
func setSpaceLayout(index: UInt32, layout: SpaceLayout) async throws {
try await run(command: "yabai -m space \(index) --layout \(layout.rawValue)")
}
func rotateSpace(index: UInt32, degrees: Int) async throws { // Per-space
try await run(command: "yabai -m space \(index) --rotate \(degrees)")
}
func mirrorSpace(index: UInt32, axis: MirrorAxis) async throws { // Per-space
try await run(command: "yabai -m space \(index) --mirror \(axis.rawValue)-axis")
}
func balanceSpace(index: UInt32) async throws { // Per-space
try await run(command: "yabai -m space \(index) --balance")
}
// MARK: - Space Padding & Gaps
func setSpacePadding(index: UInt32, padding: SpacePadding) async throws {
let paddingStr = "\(Int(padding.top)):\(Int(padding.bottom)):\(Int(padding.left)):\(Int(padding.right))"
try await run(command: "yabai -m space \(index) --padding abs:\(paddingStr)")
}
func setSpaceGap(index: UInt32, gap: Double) async throws {
try await run(command: "yabai -m space \(index) --gap abs:\(Int(gap))")
}
// MARK: - Display Operations
func focusDisplay(index: UInt32) async throws {
try await run(command: "yabai -m display --focus \(index)")
}
func balanceDisplay() async throws {
try await run(command: "yabai -m display --balance")
}
// MARK: - Advanced Display Focus
func focusDisplayNext() async throws {
try await run(command: "yabai -m display --focus next")
}
func focusDisplayPrev() async throws {
try await run(command: "yabai -m display --focus prev")
}
func focusDisplayRecent() async throws {
try await run(command: "yabai -m display --focus recent")
}
func focusDisplayMouse() async throws {
try await run(command: "yabai -m display --focus mouse")
}
// MARK: - Display Space Management
func balanceDisplay(index: UInt32) async throws { // Per-display
try await run(command: "yabai -m display \(index) --balance")
}
// MARK: - Config Operations
func setConfig(_ key: String, value: String) async throws {
try await run(command: "yabai -m config \(key) \(value)")
}
// MARK: - Utility Methods
func runCaptureStdout(_ command: String) async throws -> Data {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
// Set up proper environment with Homebrew PATH
var environment = ProcessInfo.processInfo.environment
if let existingPath = environment["PATH"] {
environment["PATH"] = "/opt/homebrew/bin:/opt/homebrew/sbin:" + existingPath
} else {
environment["PATH"] = "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin"
}
process.environment = environment
process.arguments = ["-c", command]
let outputPipe = Pipe()
let errorPipe = Pipe()
process.standardOutput = outputPipe
process.standardError = errorPipe
try process.run()
process.waitUntilExit()
// Check for errors first
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
if !errorData.isEmpty {
let errorString = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown error"
if process.terminationStatus != 0 {
throw CommandError.executionFailed(command: command, error: errorString)
}
}
// Return stdout
return outputPipe.fileHandleForReading.readDataToEndOfFile()
}
func isYabaiRunning() async -> Bool {
do {
try await run(command: "pgrep -x yabai > /dev/null")
return true
} catch {
return false
}
}
// MARK: - Event Subscription
func subscribeToYabaiEvents() -> AsyncThrowingStream<YabaiEvent, Error> {
if eventStream == nil {
eventStream = AsyncThrowingStream { continuation in
self.eventContinuation = continuation
Task {
do {
try await self.startEventSubscription()
} catch {
continuation.finish(throwing: error)
}
}
}
}
return eventStream!
}
private func startEventSubscription() async throws {
print("🎧 Starting yabai event subscription...")
// NOTE: registering yabai signals via `yabai -m signal --add` can fail across yabai versions;
// to maximize compatibility we skip adding signals and rely on robust polling below.
isSubscribed = true
print("✅ Yabai event polling started (signals not registered to maximize compatibility)")
// Start monitoring for changes via polling
// This is more reliable than trying to parse signal outputs
while true {
do {
let currentWindowsData = try await queryWindows()
let currentWindows = try JSONDecoder().decode([YabaiWindowJSON].self, from: currentWindowsData)
// Detect changes and generate events
let events = detectWindowChanges(from: lastWindowStates, to: currentWindows)
for event in events {
eventContinuation?.yield(event)
}
lastWindowStates = currentWindows
try await Task.sleep(nanoseconds: 50_000_000) // 50ms polling interval
} catch {
print("Error in event monitoring: \(error)")
try await Task.sleep(nanoseconds: 500_000_000) // 500ms on error
}
}
}
private func detectWindowChanges(from oldWindows: [YabaiWindowJSON], to newWindows: [YabaiWindowJSON]) -> [YabaiEvent] {
var events: [YabaiEvent] = []
let oldIds = Set(oldWindows.map { $0.id })
let newIds = Set(newWindows.map { $0.id })
// Detect new windows
let createdIds = newIds.subtracting(oldIds)
for id in createdIds {
if let _ = newWindows.first(where: { $0.id == id }) {
events.append(YabaiEvent(type: "window_create", payload: ["id": AnyCodable(id)]))
}
}
// Detect destroyed windows
let destroyedIds = oldIds.subtracting(newIds)
for id in destroyedIds {
events.append(YabaiEvent(type: "window_destroy", payload: ["id": AnyCodable(id)]))
}
// Detect focus changes
let oldFocused = oldWindows.first(where: { $0.hasFocus })?.id
let newFocused = newWindows.first(where: { $0.hasFocus })?.id
if oldFocused != newFocused && newFocused != nil {
events.append(YabaiEvent(type: "window_focus", payload: ["id": AnyCodable(newFocused!)]))
}
// Detect position/size changes (simplified - in practice you'd compare frames)
for newWindow in newWindows {
if let oldWindow = oldWindows.first(where: { $0.id == newWindow.id }) {
if abs(oldWindow.frame.x - newWindow.frame.x) > 1 ||
abs(oldWindow.frame.y - newWindow.frame.y) > 1 ||
abs(oldWindow.frame.w - newWindow.frame.w) > 1 ||
abs(oldWindow.frame.h - newWindow.frame.h) > 1 {
events.append(YabaiEvent(type: "window_move", payload: ["id": AnyCodable(newWindow.id)]))
}
}
}
return events
}
}
// MARK: - Yabai Validator
class YabaiValidator {
private let commandRunner = YabaiCommandRunner()
private let scriptingAdditionManager = YabaiScriptingAdditionManager.shared
private let permissionsManager = PermissionsManager.shared
func validateWindowId(_ id: UInt32) async -> Bool {
do {
_ = try await commandRunner.queryWindow(id: id)
return true
} catch {
return false
}
}
func validateSpaceIndex(_ index: UInt32) async -> Bool {
do {
let spaces = try await commandRunner.querySpaces()
let spaceArray = try JSONDecoder().decode([ValidationSpaceInfo].self, from: spaces)
return spaceArray.contains { $0.index == index }
} catch {
return false
}
}
func validateDisplayIndex(_ index: UInt32) async -> Bool {
do {
let displays = try await commandRunner.queryDisplays()
let displayArray = try JSONDecoder().decode([ValidationDisplayInfo].self, from: displays)
return displayArray.contains { $0.index == index }
} catch {
return false
}
}
func validateCommandPrerequisites(for command: YabaiCommand) async -> [YabaiError] {
var errors: [YabaiError] = []
switch command {
case .windowOpacity, .windowShadow:
if !(await scriptingAdditionManager.isScriptingAdditionLoaded()) {
errors.append(.scriptingAdditionRequired(feature: "Window appearance features"))
}
case .focusFollowsMouse:
if !permissionsManager.hasAccessibilityPermission {
errors.append(.permissionDenied(permission: "Accessibility"))
}
}
return errors
}
}
// MARK: - Yabai Event System
struct YabaiEvent: Codable {
let type: String
let payload: [String: AnyCodable]
enum EventType: String {
case windowFocus = "window_focus"
case windowMove = "window_move"
case windowResize = "window_resize"
case windowCreate = "window_create"
case windowDestroy = "window_destroy"
case windowFloat = "window_float"
case windowMinimize = "window_minimize"
case spaceChange = "space_change"
case displayChange = "display_change"
}
var eventType: EventType? {
EventType(rawValue: type)
}
var windowId: UInt32? {
payload["id"]?.value as? UInt32
}
var spaceId: UInt32? {
payload["space"]?.value as? UInt32 ?? payload["space_id"]?.value as? UInt32
}
var displayId: UInt32? {
payload["display"]?.value as? UInt32 ?? payload["display_id"]?.value as? UInt32
}
}
// Type-erased wrapper for Any values in Codable
struct AnyCodable: Codable {
let value: Any
init(_ value: Any) {
self.value = value
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let intValue = try? container.decode(Int.self) {
value = intValue
} else if let doubleValue = try? container.decode(Double.self) {
value = doubleValue
} else if let stringValue = try? container.decode(String.self) {
value = stringValue
} else if let boolValue = try? container.decode(Bool.self) {
value = boolValue
} else if let arrayValue = try? container.decode([AnyCodable].self) {
value = arrayValue
} else if let dictValue = try? container.decode([String: AnyCodable].self) {
value = dictValue
} else {
value = NSNull()
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch value {
case let intValue as Int:
try container.encode(intValue)
case let doubleValue as Double:
try container.encode(doubleValue)
case let stringValue as String:
try container.encode(stringValue)
case let boolValue as Bool:
try container.encode(boolValue)
case let arrayValue as [AnyCodable]:
try container.encode(arrayValue)
case let dictValue as [String: AnyCodable]:
try container.encode(dictValue)
default:
try container.encodeNil()
}
}
}
struct YabaiWindowJSON: Codable {
let id: UInt32
let frame: Frame
let hasFocus: Bool
struct Frame: Codable {
let x: Double
let y: Double
let w: Double
let h: Double
}
enum CodingKeys: String, CodingKey {
case id
case frame
case hasFocus = "has-focus"
}
}
// MARK: - Supporting Types for Validation
enum YabaiCommand {
case windowOpacity, windowShadow, focusFollowsMouse
// Add other commands as needed
}
struct ValidationSpaceInfo: Codable {
let index: UInt32
let focused: Int
let type: String
}
struct ValidationDisplayInfo: Codable {
let index: UInt32
// Add other display properties as needed
}
// MARK: - Supporting Types
enum WindowDirection: String {
case north, east, south, west, prev, next, first, last
}
enum MirrorAxis: String {
case x, y
}
enum WindowInsertPosition: String {
case north, east, south, west
}
struct WindowGrid {
let rows: Int
let cols: Int
let startX: Int
let startY: Int
let width: Int
let height: Int
}
enum SpaceLayout: String {
case bsp, stack, float
}
struct SpacePadding {
let top: Double
let bottom: Double
let left: Double
let right: Double
}
enum YabaiError: LocalizedError {
case commandFailed(command: String, error: String)
case invalidWindowId(id: UInt32)
case invalidSpaceIndex(index: UInt32)
case invalidDisplayIndex(index: UInt32)
case scriptingAdditionRequired(feature: String)
case sipRestriction(feature: String)
case permissionDenied(permission: String)
case executionFailed(command: String, error: String)
case invalidJSON(data: Data)
case yabaiNotRunning
case invalidOutput
var errorDescription: String? {
switch self {
case .commandFailed(let command, let error):
return "Command failed: \(command)\nError: \(error)"
case .invalidWindowId(let id):
return "Invalid window ID: \(id)"
case .invalidSpaceIndex(let index):
return "Invalid space index: \(index)"
case .invalidDisplayIndex(let index):
return "Invalid display index: \(index)"
case .scriptingAdditionRequired(let feature):
return "\(feature) requires scripting addition. Run 'sudo yabai --load-sa'"
case .sipRestriction(let feature):
return "\(feature) is restricted by System Integrity Protection"
case .permissionDenied(let permission):
return "\(permission) permission required"
case .executionFailed(let command, let error):
return "Command failed: \(command)\nError: \(error)"
case .invalidJSON:
return "Failed to parse JSON response from yabai"
case .yabaiNotRunning:
return "yabai is not running"
case .invalidOutput:
return "Failed to parse command output"
}
}
var recoverySuggestion: String? {
switch self {
case .scriptingAdditionRequired:
return "Load scripting addition with 'sudo yabai --load-sa' and restart YabaiPro"
case .sipRestriction:
return "Disable SIP temporarily or use SIP-compatible features"
case .permissionDenied(let permission):
return "Grant \(permission) permission in System Settings > Privacy & Security"
case .yabaiNotRunning:
return "Start yabai with 'brew services start yabai' or 'yabai --start-service'"
default:
return nil
}
}
}
enum CommandError: Error, LocalizedError {
case executionFailed(command: String, error: String)
case invalidJSON(data: Data)
case yabaiNotRunning
case invalidOutput
var errorDescription: String? {
switch self {
case .executionFailed(let command, let error):
return "Command failed: \(command)\nError: \(error)"
case .invalidJSON:
return "Failed to parse JSON response from yabai"
case .yabaiNotRunning:
return "yabai is not running"
case .invalidOutput:
return "Failed to parse command output"
}
}
var recoverySuggestion: String? {
switch self {
case .yabaiNotRunning:
return "Start yabai with 'brew services start yabai' or 'yabai --start-service'"
default:
return nil
}
}
}