- 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
803 lines
27 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|