From 8eaa508c92a8348e1c437d6f4e28f8c306e783d6 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Wed, 18 Mar 2026 14:16:31 -0600 Subject: [PATCH 1/8] =?UTF-8?q?=E2=9C=A8=20loop://windowlist=20command=20+?= =?UTF-8?q?=20JSON=20results?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/App/AppDelegate.swift | 11 +- Loop/Core/URLCommandHandler.swift | 742 ++++++++++++++---------------- 2 files changed, 362 insertions(+), 391 deletions(-) diff --git a/Loop/App/AppDelegate.swift b/Loop/App/AppDelegate.swift index a31d68da..839894ed 100644 --- a/Loop/App/AppDelegate.swift +++ b/Loop/App/AppDelegate.swift @@ -112,7 +112,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { LogManager.shared.configuration.includeFileAndLineNumber = false } - @objc func handleGetURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent _: NSAppleEventDescriptor) { + @objc func handleGetURLEvent(_ event: NSAppleEventDescriptor, withReplyEvent replyEvent: NSAppleEventDescriptor) { guard let urlString = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue, let url = URL(string: urlString) else { log.info("Failed to get URL from event") @@ -120,7 +120,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } log.info("Received URL: \(url)") - urlCommandHandler.handle(url) + let response = urlCommandHandler.handle(url) + log.info("Response: \(response)") + + // Set reply for callers that support Apple Event replies + replyEvent.setDescriptor( + NSAppleEventDescriptor(string: response), + forKeyword: keyDirectObject + ) } func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { diff --git a/Loop/Core/URLCommandHandler.swift b/Loop/Core/URLCommandHandler.swift index 81fb00c5..360da9ba 100644 --- a/Loop/Core/URLCommandHandler.swift +++ b/Loop/Core/URLCommandHandler.swift @@ -51,18 +51,45 @@ - keybinds (List all custom keybinds) - all (List everything) + 6. Window List Command: + Format: loop://windowlist + Returns a JSON file listing all visible windows with: + - windowID (CGWindowID, use with ?windowID parameter) + - bundleID (App bundle identifier) + - appName (App display name) + - windowTitle (Window title) + - frame (x, y, width, height) + + Targeting a Specific Window: + --------------------------- + Any action command can optionally include a ?windowID= query parameter + to target a specific window instead of the frontmost one. + + Examples: + - loop://direction/right?windowID=1234 + - loop://action/maximize?windowID=1234 + - loop://keybind/myLayout?windowID=1234 + - loop://screen/next?windowID=1234 + Usage Tips: ---------- 1. All commands are case-insensitive 2. Parameters with spaces must be URL encoded - 3. Window commands operate on the frontmost non-terminal window + 3. Window commands operate on the frontmost non-terminal window (unless ?windowID is specified) 4. Use list commands to discover available options + 5. Use loop://windowlist to discover window IDs for targeted actions Examples: -------- # Move current window to right half open "loop://direction/right" + # List all windows to find window IDs + open "loop://windowlist" + + # Move a specific window to the right half + open "loop://direction/right?windowID=1234" + # List all available actions open "loop://list/actions" @@ -79,6 +106,9 @@ # Invalid keybind open "loop://keybind/nonexistent" -> Returns available keybinds + + # Invalid window ID + open "loop://direction/right?windowID=9999" -> Returns error with available windows */ import Defaults @@ -103,6 +133,8 @@ final class URLCommandHandler { case keybind /// List available commands and options case list + /// List all visible windows as JSON + case windowlist /// Human-readable description of each command type var description: String { @@ -112,186 +144,129 @@ final class URLCommandHandler { case .action: "Execute predefined window action" case .keybind: "Execute custom keybind action" case .list: "List available commands" + case .windowlist: "List all visible windows as JSON" } } } // MARK: - Properties - /// Tracks the last active window for context preservation - private var lastActiveWindow: Window? - - /// Timestamp of last window activation - private var lastActiveTime: Date? + // MARK: - JSON Helpers - /// Current command being processed - private var currentCommand: String? - - /// Buffer for collecting output before writing - private var outputBuffer: [String] = [] - - // MARK: - Output Handling - - /// Writes a message to either the buffer (for list commands) or stdout - /// - Parameter message: The message to write - private func writeToOutput(_ message: String) { - // Remove [URLHandler] prefix and clean up the message - let cleanMessage = message.replacingOccurrences(of: "[URLHandler] ", with: "") - - // Skip debug-only messages for regular output - if cleanMessage.hasPrefix("Path components:") || - cleanMessage.hasPrefix("Found") || - cleanMessage.hasPrefix("Window:") || - (cleanMessage.hasPrefix("Processing") && !cleanMessage.contains("command:")) { - log.info(cleanMessage) - return - } - - let output = cleanMessage - if currentCommand?.contains("/list") == true { - outputBuffer.append(output) - } else { - log.info("\(output)") + /// Serializes a response dictionary to a pretty-printed JSON string + private func jsonString(_ dict: [String: Any]) -> String { + guard let data = try? JSONSerialization.data( + withJSONObject: dict, + options: [.prettyPrinted, .sortedKeys] + ) else { + return #"{"success":false,"error":"Failed to serialize response"}"# } - log.info(cleanMessage) + return String(data: data, encoding: .utf8) + ?? #"{"success":false,"error":"Failed to encode response"}"# } - /// Writes a titled list of items to output - /// - Parameters: - /// - title: The title for the list - /// - items: Array of items to list - private func writeList(_ title: String, _ items: [String]) { - let formattedItems = items.map { item in - if item.hasPrefix("\n") { - return item.replacingOccurrences(of: "\n", with: "") - } - return item - } - - if currentCommand?.contains("/list") == true { - outputBuffer.append(title) - outputBuffer.append(contentsOf: formattedItems) - } else { - log.info("\n\(title)") - formattedItems.forEach { log.info("\($0)") } - } - } - - /// Flushes the output buffer to a file for list commands - /// - Note: Due to limitations with terminal output formatting and the complexity of the list output, - /// we use a temporary file to display the formatted list. This allows for proper spacing, - /// sections, and formatting that would be difficult to achieve with direct terminal output. - /// The file is automatically opened and then deleted after 60 seconds to keep the system clean. - private func flushOutput() { - guard currentCommand?.contains("/list") == true, - !outputBuffer.isEmpty else { - outputBuffer.removeAll() - return - } - - // Create a unique temporary file that will be automatically cleaned up - let timestamp = Date().timeIntervalSince1970 - let tempFile = FileManager.default.temporaryDirectory - .appendingPathComponent("loop_output_\(timestamp).txt") - - do { - try outputBuffer.joined(separator: "\n").write(to: tempFile, atomically: true, encoding: .utf8) - NSWorkspace.shared.open(tempFile) - - // Schedule file deletion after a delay - // We use a longer delay (60s) to ensure the user has time to read the content - Task { - try? await Task.sleep(for: .seconds(60)) - - do { - try FileManager.default.removeItem(at: tempFile) - log.info("Cleaned up temporary file: \(tempFile.lastPathComponent)") - } catch { - log.error("Failed to clean up temporary file: \(error.localizedDescription)") - } - } - } catch { - log.error("Failed to write output: \(error.localizedDescription)") - - // Fallback to direct console output if file operations fail - log.info("\(outputBuffer.joined(separator: "\n"))") - } - - outputBuffer.removeAll() + /// Builds a JSON-serializable dictionary for a window + private func windowJSON(_ window: Window) -> [String: Any] { + let app = window.nsRunningApplication + let frame = window.frame + return [ + "windowID": window.cgWindowID, + "bundleID": app?.bundleIdentifier ?? "", + "appName": app?.localizedName ?? "", + "windowTitle": window.title ?? "", + "frame": [ + "x": Int(frame.origin.x), + "y": Int(frame.origin.y), + "width": Int(frame.width), + "height": Int(frame.height) + ] + ] } // MARK: - Public Methods - /// Handles incoming URL scheme requests + /// Handles incoming URL scheme requests and returns a JSON response string /// - Parameter url: The URL to process - /// - Throws: URLError for invalid URLs or commands - func handle(_ url: URL) { - currentCommand = url.absoluteString - writeToOutput("[URLHandler] Processing URL: \(url)") + /// - Returns: A JSON string containing the response + @discardableResult + func handle(_ url: URL) -> String { + log.info("Processing URL: \(url)") guard url.scheme?.lowercased() == "loop" else { - writeToOutput("[URLHandler] Invalid scheme: \(url.scheme ?? "nil")") - writeToOutput("[URLHandler] Required format: loop:///") - return + return jsonString([ + "success": false, + "error": "Invalid scheme: \(url.scheme ?? "nil"). Required: loop://" + ]) } + // Parse optional windowID query parameter for targeting a specific window + let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) + let windowID: CGWindowID? = urlComponents? + .queryItems? + .first(where: { $0.name == "windowID" })? + .value + .flatMap { UInt32($0) } + let components = (url.host.map { [$0] } ?? []) + url.pathComponents.filter { $0 != "/" && !$0.isEmpty } - writeToOutput("[URLHandler] Path components: \(components)") guard let commandString = components.first, let command = Command(rawValue: commandString.lowercased()) else { - writeToOutput("[URLHandler] Invalid command: \(components.first ?? "nil")") - writeToOutput("[URLHandler] Available commands: \(Command.allCases.map(\.rawValue).joined(separator: ", "))") - return + return jsonString([ + "success": false, + "error": "Unknown command: \(components.first ?? "nil")", + "availableCommands": Command.allCases.map(\.rawValue) + ]) } - processCommand(command, Array(components.dropFirst())) + return processCommand(command, Array(components.dropFirst()), windowID: windowID) } // MARK: - Command Processing - /// Processes a command with its parameters + /// Processes a command with its parameters and returns a JSON response string /// - Parameters: /// - command: The command to process /// - parameters: Array of command parameters - private func processCommand(_ command: Command, _ parameters: [String]) { - log.info(command.rawValue) - log.info(parameters.description) + /// - windowID: Optional CGWindowID to target a specific window + /// - Returns: A JSON string containing the response + private func processCommand(_ command: Command, _ parameters: [String], windowID: CGWindowID? = nil) -> String { + log.info("\(command.rawValue) \(parameters)") + let response: [String: Any] switch command { - case .direction: handleDirectionCommand(parameters) - case .screen: handleScreenCommand(parameters) - case .action: handleActionCommand(parameters) - case .keybind: handleKeybindCommand(parameters) - case .list: handleListCommand(parameters) + case .direction: response = handleDirectionCommand(parameters, windowID: windowID) + case .screen: response = handleScreenCommand(parameters, windowID: windowID) + case .action: response = handleActionCommand(parameters, windowID: windowID) + case .keybind: response = handleKeybindCommand(parameters, windowID: windowID) + case .list: response = handleListCommand(parameters) + case .windowlist: response = handleWindowListCommand() } - flushOutput() + return jsonString(response) } /// Handles window direction commands - /// - Parameter parameters: Direction parameters - private func handleDirectionCommand(_ parameters: [String]) { + /// - Parameters: + /// - parameters: Direction parameters + /// - windowID: Optional CGWindowID to target a specific window + /// - Returns: JSON response dictionary + private func handleDirectionCommand(_ parameters: [String], windowID: CGWindowID? = nil) -> [String: Any] { guard let directionStr = parameters.first?.lowercased() else { - writeToOutput("No direction specified") - writeToOutput("Available directions:") - writeToOutput(" Basic: left, right, top, bottom") - writeToOutput(" Full names: \(WindowDirection.allCases.map { $0.rawValue.lowercased() }.joined(separator: ", "))") - return + return [ + "success": false, + "command": "direction", + "error": "No direction specified" + ] } - // If this is a list command, redirect to the action handler + // If this is a list command, redirect to the list handler if directionStr == "list" { - handleListCommand(["actions"]) - return + return handleListCommand(["actions"]) } - writeToOutput("Processing direction: \(directionStr)") - // First check if this is a custom action being called via direction if directionStr.hasPrefix("custom") || directionStr.hasPrefix("stash") { - handleActionCommand(parameters) - return + return handleActionCommand(parameters, windowID: windowID) } let direction: WindowDirection? = WindowDirection.allCases.first { $0.rawValue.lowercased() == directionStr } ?? { @@ -307,137 +282,170 @@ final class URLCommandHandler { }() if let direction { - executeWindowAction(direction) + return executeWindowAction(direction, windowID: windowID) } else { - writeToOutput("Invalid direction: \(directionStr)") - writeToOutput("Available directions:") - writeToOutput(" Basic: left, right, top, bottom") - writeToOutput(" Full names: \(WindowDirection.allCases.map { $0.rawValue.lowercased() }.joined(separator: ", "))") + return [ + "success": false, + "command": "direction", + "parameter": directionStr, + "error": "Invalid direction: \(directionStr)" + ] } } /// Executes a window action for a given direction - /// - Parameter direction: The direction to move/resize the window - private func executeWindowAction(_ direction: WindowDirection) { - writeToOutput("[URLHandler] Executing direction: \(direction.rawValue)") - - let allWindows = WindowUtility.windowList() - writeToOutput("[URLHandler] Found \(allWindows.count) total windows") - - let visibleWindows = allWindows.filter { win in - guard let app = win.nsRunningApplication else { - writeToOutput("[URLHandler] Window has no application: \(win.title ?? "unknown")") - return false - } - - let isLoop = app.bundleIdentifier == Bundle.main.bundleIdentifier - let isRegular = app.activationPolicy == .regular - let isVisible = !win.isApplicationHidden && !win.minimized - - logWindowDetails(win, app, isLoop, isRegular, isVisible) - - return !isLoop && isRegular && isVisible + /// - Parameters: + /// - direction: The direction to move/resize the window + /// - windowID: Optional CGWindowID to target a specific window + /// - Returns: JSON response dictionary + private func executeWindowAction(_ direction: WindowDirection, windowID: CGWindowID? = nil) -> [String: Any] { + log.info("Executing direction: \(direction.rawValue)") + + guard let window = resolveWindow(windowID: windowID) else { + return [ + "success": false, + "command": "direction", + "parameter": direction.rawValue.lowercased(), + "error": windowID != nil + ? "No window found with ID \(windowID!)" + : "No frontmost window found" + ] } - writeToOutput("[URLHandler] Found \(visibleWindows.count) eligible windows") - - guard let window = findTargetWindow(from: visibleWindows), - let screen = NSScreen.main else { - writeToOutput("[URLHandler] No suitable windows or screen found") - return + guard let screen = NSScreen.main else { + return ["success": false, "command": "direction", "error": "No screen found"] } - logSelectedWindow(window, screen) - let action = WindowAction(direction) - writeToOutput("[URLHandler] Resizing window with action: \(direction.rawValue)") - activateAndResizeWindow(window, action, screen) + return [ + "success": true, + "command": "direction", + "action": direction.rawValue, + "window": windowJSON(window) + ] } /// Handles screen management commands - /// - Parameter parameters: Screen command parameters - private func handleScreenCommand(_ parameters: [String]) { - guard let command = parameters.first?.lowercased(), - let window = try? WindowUtility.frontmostWindow() else { - writeToOutput("[URLHandler] No screen command or window") - return + /// - Parameters: + /// - parameters: Screen command parameters + /// - windowID: Optional CGWindowID to target a specific window + /// - Returns: JSON response dictionary + private func handleScreenCommand(_ parameters: [String], windowID: CGWindowID? = nil) -> [String: Any] { + guard let command = parameters.first?.lowercased() else { + return ["success": false, "command": "screen", "error": "No screen command specified"] + } + + guard let window = resolveWindow(windowID: windowID) else { + return [ + "success": false, + "command": "screen", + "parameter": command, + "error": windowID != nil + ? "No window found with ID \(windowID!)" + : "No frontmost window found" + ] } - writeToOutput("[URLHandler] Processing screen command: \(command)") - let direction: WindowDirection = command == "next" ? .nextScreen : .previousScreen moveWindowToScreen(window, direction) + return [ + "success": true, + "command": "screen", + "action": command, + "window": windowJSON(window) + ] } /// Handles predefined window actions - /// - Parameter parameters: Action parameters - private func handleActionCommand(_ parameters: [String]) { + /// - Parameters: + /// - parameters: Action parameters + /// - windowID: Optional CGWindowID to target a specific window + /// - Returns: JSON response dictionary + private func handleActionCommand(_ parameters: [String], windowID: CGWindowID? = nil) -> [String: Any] { guard let actionStr = parameters.first?.lowercased() else { - printAvailableActions() - return + return buildActionsResponse() } // First check for custom actions by name let customKeybinds = Defaults[.keybinds].filter { $0.direction.isCustomizable && $0.name != nil } if let customAction = customKeybinds.first(where: { ($0.name?.lowercased() ?? "") == actionStr }) { - writeToOutput("Executing custom action: \(customAction.name ?? "unnamed")") + if let window = resolveWindow(windowID: windowID), let screen = NSScreen.main { + activateAndResizeWindow(window, customAction, screen) + return [ + "success": true, + "command": "action", + "action": customAction.name ?? actionStr, + "window": windowJSON(window) + ] + } else { + return [ + "success": false, + "command": "action", + "parameter": actionStr, + "error": windowID != nil + ? "No window found with ID \(windowID!)" + : "No suitable window found" + ] + } + } - // Try multiple methods to get the target window - let targetWindow = findTargetWindow(from: WindowUtility.windowList().filter { win in - guard let app = win.nsRunningApplication else { return false } - return app.activationPolicy == .regular && !win.isApplicationHidden && !win.minimized - }) + if actionStr == "list" { + return buildActionsResponse() + } - if let window = targetWindow, - let screen = NSScreen.main { - writeToOutput("Found target window: \(window.title ?? "unknown")") - activateAndResizeWindow(window, customAction, screen) + if let direction = WindowDirection.allCases.first(where: { $0.rawValue.lowercased() == actionStr }) { + if let window = resolveWindow(windowID: windowID), let screen = NSScreen.main { + activateAndResizeWindow(window, .init(direction), screen) + return [ + "success": true, + "command": "action", + "action": direction.rawValue, + "window": windowJSON(window) + ] } else { - writeToOutput("Error: Could not find a suitable window to apply the custom action") + return [ + "success": false, + "command": "action", + "parameter": actionStr, + "error": windowID != nil + ? "No window found with ID \(windowID!)" + : "No suitable window found" + ] } - } else if actionStr == "list" { - // For list command, just show the actions without the invalid message - printAvailableActions() - } else if let direction = WindowDirection.allCases.first(where: { $0.rawValue.lowercased() == actionStr }), - let window = findTargetWindow(from: WindowUtility.windowList()), - let screen = NSScreen.main { - writeToOutput("Executing action: \(direction.rawValue)") - activateAndResizeWindow(window, .init(direction), screen) - } else { - writeToOutput("Invalid action: \(actionStr)") - printAvailableActions() } + + return [ + "success": false, + "command": "action", + "parameter": actionStr, + "error": "Invalid action: \(actionStr)" + ] } - /// Prints all available window actions in categories - private func printAvailableActions() { - var items: [String] = [] + /// Builds a structured response of all available actions grouped by category + /// - Returns: JSON response dictionary with categorized actions + private func buildActionsResponse() -> [String: Any] { + var categories: [[String: Any]] = [] - // Get any custom keybinds with names and custom direction let customKeybinds = Defaults[.keybinds].filter { $0.direction == .custom && $0.name?.isEmpty == false } if !customKeybinds.isEmpty { - items.append("Custom Actions:") - items.append(contentsOf: customKeybinds.compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://action/\(name.lowercased())" - }) - items.append("") + categories.append([ + "category": "Custom Actions", + "actions": customKeybinds.compactMap { $0.name?.lowercased() } + ]) } - // Get any stash keybinds with names and custom direction let stashKeybinds = Defaults[.keybinds].filter { $0.direction == .stash && $0.name?.isEmpty == false } if !stashKeybinds.isEmpty { - items.append("Stash Actions:") - items.append(contentsOf: stashKeybinds.compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://action/\(name.lowercased())" - }) - items.append("") + categories.append([ + "category": "Stash Actions", + "actions": stashKeybinds.compactMap { $0.name?.lowercased() } + ]) } - let categories: [(String, [WindowDirection])] = [ - ("General Actions", Array(WindowDirection.general.dropFirst(3))), // Drop first 3 actions + let builtinCategories: [(String, [WindowDirection])] = [ + ("General Actions", Array(WindowDirection.general.dropFirst(3))), ("Halves", WindowDirection.halves), ("Quarters", WindowDirection.quarters), ("Horizontal Thirds", WindowDirection.horizontalThirds), @@ -450,223 +458,179 @@ final class URLCommandHandler { ("Other", WindowDirection.more) ] - for (title, actions) in categories { - if !actions.isEmpty { - items.append("\(title):") - items.append(contentsOf: actions.map { " • loop://action/\($0.rawValue.lowercased())" }) - items.append("") - } + for (title, actions) in builtinCategories where !actions.isEmpty { + categories.append([ + "category": title, + "actions": actions.map { $0.rawValue.lowercased() } + ]) } - // Remove the last empty line if it exists - if items.last?.isEmpty == true { - items.removeLast() - } - - writeList("", items) + return [ + "success": true, + "command": "list", + "type": "actions", + "categories": categories + ] } /// Handles custom keybind execution - /// - Parameter parameters: Keybind parameters - private func handleKeybindCommand(_ parameters: [String]) { + /// - Parameters: + /// - parameters: Keybind parameters + /// - windowID: Optional CGWindowID to target a specific window + /// - Returns: JSON response dictionary + private func handleKeybindCommand(_ parameters: [String], windowID: CGWindowID? = nil) -> [String: Any] { + let keybinds = Defaults[.keybinds] + guard let keybindName = parameters.first else { - writeToOutput("[URLHandler] No keybind specified") - return + return ["success": false, "command": "keybind", "error": "No keybind specified"] } - let keybinds = Defaults[.keybinds] - if keybindName.lowercased() == "list" { - writeToOutput("[URLHandler] Available keybinds:") - keybinds.compactMap(\.name).forEach { writeToOutput(" - \($0)") } - return + return [ + "success": true, + "command": "list", + "type": "keybinds", + "keybinds": keybinds.compactMap(\.name) + ] } - if let keybind = keybinds.first(where: { $0.name?.lowercased() == keybindName.lowercased() }) { - writeToOutput("[URLHandler] Executing keybind: \(keybind.name ?? "unnamed")") - if let window = WindowUtility.userDefinedTargetWindow(), - let screen = NSScreen.main { - Task { - _ = try await WindowActionEngine.shared.apply( - keybind, - window: window, - screen: screen - ) - } + guard let keybind = keybinds.first(where: { $0.name?.lowercased() == keybindName.lowercased() }) else { + return [ + "success": false, + "command": "keybind", + "parameter": keybindName, + "error": "Keybind not found: \(keybindName)", + "availableKeybinds": keybinds.compactMap(\.name) + ] + } + + guard let window = resolveWindow(windowID: windowID) else { + return [ + "success": false, + "command": "keybind", + "parameter": keybindName, + "error": windowID != nil + ? "No window found with ID \(windowID!)" + : "No frontmost window found" + ] + } + + if let screen = NSScreen.main { + Task { + _ = try await WindowActionEngine.shared.apply( + keybind, window: window, screen: screen + ) } + return [ + "success": true, + "command": "keybind", + "keybind": keybind.name ?? keybindName, + "window": windowJSON(window) + ] } else { - writeToOutput("[URLHandler] Keybind not found: \(keybindName)") - writeToOutput("[URLHandler] Available keybinds:") - keybinds.compactMap(\.name).forEach { writeToOutput(" - \($0)") } + return [ + "success": false, + "command": "keybind", + "parameter": keybindName, + "error": "No suitable window found" + ] } } /// Handles list commands for viewing available options /// - Parameter parameters: List parameters - private func handleListCommand(_ parameters: [String]) { + /// - Returns: JSON response dictionary + private func handleListCommand(_ parameters: [String]) -> [String: Any] { let type = parameters.first?.lowercased() ?? "all" - var items: [String] = [] switch type { case "actions": - items.append("Available Actions:") - // Get any custom keybinds with names and custom direction - let customKeybinds = Defaults[.keybinds].filter { $0.direction == .custom && $0.name?.isEmpty == false } - if !customKeybinds.isEmpty { - items.append("\nCustom Actions:") - items.append(contentsOf: customKeybinds.compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://action/\(name.lowercased())" - }) - } - - // Get any stash keybinds with names and custom direction - let stashKeybinds = Defaults[.keybinds].filter { $0.direction == .stash && $0.name?.isEmpty == false } - if !stashKeybinds.isEmpty { - items.append("\nStash Actions:") - items.append(contentsOf: stashKeybinds.compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://action/\(name.lowercased())" - }) - } - - let categories: [(String, [WindowDirection])] = [ - ("General Actions", Array(WindowDirection.general.dropFirst(3))), - ("Halves", WindowDirection.halves), - ("Quarters", WindowDirection.quarters), - ("Horizontal Thirds", WindowDirection.horizontalThirds), - ("Vertical Thirds", WindowDirection.verticalThirds), - ("Screen Switching", WindowDirection.screenSwitching), - ("Size Adjustment", WindowDirection.sizeAdjustment), - ("Shrink", WindowDirection.shrink), - ("Grow", WindowDirection.grow), - ("Move", WindowDirection.move), - ("Other", WindowDirection.more) - ] - - for (title, actions) in categories { - if !actions.isEmpty { - items.append("\n\(title):") - items.append(contentsOf: actions.map { " • loop://action/\($0.rawValue.lowercased())" }) - } - } + return buildActionsResponse() case "keybinds": - items.append("Available Keybinds:") - items.append(contentsOf: Defaults[.keybinds].compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://keybind/\(name)" - }) + return [ + "success": true, + "command": "list", + "type": "keybinds", + "keybinds": Defaults[.keybinds].compactMap(\.name) + ] default: - items.append("Available Commands:") - - items.append("\nDirection Commands:") - items.append(contentsOf: WindowDirection.allCases.map { " • loop://direction/\($0.rawValue.lowercased())" }) - - items.append("\nScreen Commands:") - items.append(" • loop://screen/next") - items.append(" • loop://screen/previous") - - items.append("\nActions:") - // Get any custom keybinds with names and custom direction - let customKeybinds = Defaults[.keybinds].filter { $0.direction == .custom && $0.name?.isEmpty == false } - if !customKeybinds.isEmpty { - items.append("\nCustom Actions:") - items.append(contentsOf: customKeybinds.compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://action/\(name.lowercased())" - }) - } - - // Get any stash keybinds with names and custom direction - let stashKeybinds = Defaults[.keybinds].filter { $0.direction == .stash && $0.name?.isEmpty == false } - if !stashKeybinds.isEmpty { - items.append("\nStash Actions:") - items.append(contentsOf: stashKeybinds.compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://action/\(name.lowercased())" - }) - } - - items.append("\nKeybind Commands:") - items.append(contentsOf: Defaults[.keybinds].compactMap { keybind in - guard let name = keybind.name else { return nil } - return " • loop://keybind/\(name)" - }) - - items.append("\nList Commands:") - items.append(" • loop://list/actions") - items.append(" • loop://list/keybinds") - items.append(" • loop://list/all") + let actionsResponse = buildActionsResponse() + + return [ + "success": true, + "command": "list", + "type": "all", + "directions": WindowDirection.allCases.map { $0.rawValue.lowercased() }, + "screenCommands": ["next", "previous"], + "actionCategories": actionsResponse["categories"] ?? [], + "keybinds": Defaults[.keybinds].compactMap(\.name), + "commands": Command.allCases.map { [ + "command": $0.rawValue, + "description": $0.description + ] as [String: String] } + ] } - - writeList(type == "all" ? "All Commands" : items.removeFirst(), Array(items)) } - // MARK: - Helper Methods + // MARK: - Window List - /// Finds the most appropriate target window for an action - /// - Parameter visibleWindows: Array of visible windows to choose from - /// - Returns: The most appropriate window or nil if none found - private func findTargetWindow(from visibleWindows: [Window]) -> Window? { - if let targetWindow = WindowUtility.userDefinedTargetWindow() { - writeToOutput("[URLHandler] Using WindowEngine.getTargetWindow(): \(targetWindow.title ?? "unknown")") - return targetWindow + /// Lists all visible windows with their details + /// - Returns: JSON response dictionary + private func handleWindowListCommand() -> [String: Any] { + let visibleWindows = WindowUtility.windowList().filter { win in + guard let app = win.nsRunningApplication else { return false } + return app.bundleIdentifier != Bundle.main.bundleIdentifier + && app.activationPolicy == .regular + && !win.isApplicationHidden + && !win.minimized } - if let lastWindow = lastActiveWindow, - let app = lastWindow.nsRunningApplication, - app.bundleIdentifier != Bundle.main.bundleIdentifier, - !lastWindow.isApplicationHidden, !lastWindow.minimized, - let lastTime = lastActiveTime, - lastTime.timeIntervalSinceNow > -5 { - writeToOutput("[URLHandler] Using last active window: \(lastWindow.title ?? "unknown")") - return lastWindow - } - - return visibleWindows.first + return [ + "success": true, + "command": "windowlist", + "windowCount": visibleWindows.count, + "windows": visibleWindows.map { windowJSON($0) } + ] } - /// Logs window details for debugging - private func logWindowDetails(_ window: Window, _ app: NSRunningApplication, _ isLoop: Bool, _ isRegular: Bool, _ isVisible: Bool) { - writeToOutput("[URLHandler] Window: \(window.title ?? "unknown")") - writeToOutput(" - App: \(app.localizedName ?? "unknown")") - writeToOutput(" - Bundle ID: \(app.bundleIdentifier ?? "unknown")") - writeToOutput(" - Is Loop: \(isLoop)") - writeToOutput(" - Is Regular: \(isRegular)") - writeToOutput(" - Is Visible: \(isVisible)") + // MARK: - Helper Methods + + /// Finds a window by its CGWindowID from the current window list + /// - Parameter windowID: The CGWindowID to search for + /// - Returns: The matching window, or nil if not found + private func findWindowByID(_ windowID: CGWindowID) -> Window? { + WindowUtility.windowList().first { $0.cgWindowID == windowID } } - /// Logs selected window details - private func logSelectedWindow(_ window: Window, _ screen: NSScreen) { - writeToOutput("[URLHandler] Selected window for action:") - writeToOutput(" - Title: \(window.title ?? "unknown")") - writeToOutput(" - App: \(window.nsRunningApplication?.localizedName ?? "unknown")") - writeToOutput(" - Screen: \(screen.localizedName)") - writeToOutput(" - Current Frame: \(window.frame)") + /// Resolves the target window — by ID if specified, otherwise the frontmost window + /// - Parameter windowID: Optional CGWindowID to target a specific window + /// - Returns: The resolved window, or nil if not found + private func resolveWindow(windowID: CGWindowID? = nil) -> Window? { + if let windowID { + return findWindowByID(windowID) + } + return try? WindowUtility.frontmostWindow() } /// Activates and resizes a window private func activateAndResizeWindow(_ window: Window, _ action: WindowAction, _ screen: NSScreen) { - lastActiveWindow = window - lastActiveTime = Date() - if let app = window.nsRunningApplication { - writeToOutput("[URLHandler] Activating application: \(app.localizedName ?? "unknown")") + log.info("Activating application: \(app.localizedName ?? "unknown")") app.activate(options: .activateIgnoringOtherApps) } Task { try? await Task.sleep(for: .seconds(0.1)) - writeToOutput("[URLHandler] Executing resize operation") + log.info("Executing resize: \(action) on \(window.title ?? "unknown")") _ = try await WindowActionEngine.shared.apply( action, window: window, screen: screen ) - writeToOutput("[URLHandler] New window frame: \(window.frame)") + log.info("New window frame: \(window.frame)") } } @@ -676,7 +640,7 @@ final class URLCommandHandler { let targetScreen = direction == .nextScreen ? ScreenUtility.nextScreen(from: currentScreen) : ScreenUtility.previousScreen(from: currentScreen) { - writeToOutput("[URLHandler] Moving window to screen: \(targetScreen.localizedName)") + log.info("Moving window to screen: \(targetScreen.localizedName)") Task { _ = try await WindowActionEngine.shared.apply( .init(direction), @@ -685,7 +649,7 @@ final class URLCommandHandler { ) } } else { - writeToOutput("[URLHandler] Failed to find target screen") + log.error("Failed to find target screen") } } } From 243edfddaf49a620fbc0b96dca8e9d224edc8f0d Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Wed, 18 Mar 2026 14:19:49 -0600 Subject: [PATCH 2/8] =?UTF-8?q?=E2=9C=A8=20loop://screenlist=20api?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop/Core/URLCommandHandler.swift | 348 ++++++++++++++++++++---------- 1 file changed, 231 insertions(+), 117 deletions(-) diff --git a/Loop/Core/URLCommandHandler.swift b/Loop/Core/URLCommandHandler.swift index 360da9ba..a6257748 100644 --- a/Loop/Core/URLCommandHandler.swift +++ b/Loop/Core/URLCommandHandler.swift @@ -53,62 +53,69 @@ 6. Window List Command: Format: loop://windowlist - Returns a JSON file listing all visible windows with: - - windowID (CGWindowID, use with ?windowID parameter) - - bundleID (App bundle identifier) - - appName (App display name) - - windowTitle (Window title) - - frame (x, y, width, height) - - Targeting a Specific Window: - --------------------------- - Any action command can optionally include a ?windowID= query parameter - to target a specific window instead of the frontmost one. + Returns JSON listing all visible windows with: + - windowID, bundleID, appName, windowTitle, frame + + 7. Screen List Command: + Format: loop://screenlist + Returns JSON listing all connected screens with: + - screenID, name, frame, isMain + + Query Parameters: + ---------------- + All action commands support optional query parameters for targeting: + + ?windowID= Target a specific window by CGWindowID + ?bundleID= Target an app by bundle ID (launches if needed) + ?screenID= Target a specific screen by display ID + + Notes: + - windowID and bundleID are mutually exclusive (error if both specified) + - screenID can be combined with either windowID or bundleID + - Use loop://windowlist and loop://screenlist to discover IDs Examples: - loop://direction/right?windowID=1234 - - loop://action/maximize?windowID=1234 - - loop://keybind/myLayout?windowID=1234 - - loop://screen/next?windowID=1234 + - loop://action/maximize?bundleID=com.apple.Safari + - loop://direction/left?screenID=12345 + - loop://action/maximize?bundleID=com.apple.Safari&screenID=12345 Usage Tips: ---------- 1. All commands are case-insensitive - 2. Parameters with spaces must be URL encoded - 3. Window commands operate on the frontmost non-terminal window (unless ?windowID is specified) - 4. Use list commands to discover available options - 5. Use loop://windowlist to discover window IDs for targeted actions + 2. All commands return JSON responses + 3. Window commands operate on the frontmost window by default + 4. Use loop://windowlist and loop://screenlist to discover IDs + 5. Use loop://list/all to discover all available commands Examples: -------- # Move current window to right half open "loop://direction/right" - # List all windows to find window IDs + # List all windows and screens open "loop://windowlist" + open "loop://screenlist" # Move a specific window to the right half open "loop://direction/right?windowID=1234" + # Launch/focus Safari and maximize it on a specific screen + open "loop://action/maximize?bundleID=com.apple.Safari&screenID=12345" + # List all available actions open "loop://list/actions" - # Execute custom keybind - open "loop://keybind/myLayout" - Error Examples: ------------- # Invalid command - open "loop://invalid" -> Returns available commands + open "loop://invalid" -> {"success": false, "error": "Unknown command: invalid"} - # Missing parameter - open "loop://direction" -> Returns available directions + # Both windowID and bundleID + open "loop://direction/right?windowID=1&bundleID=com.x" -> error: mutually exclusive - # Invalid keybind - open "loop://keybind/nonexistent" -> Returns available keybinds - - # Invalid window ID - open "loop://direction/right?windowID=9999" -> Returns error with available windows + # Invalid window/screen ID + open "loop://direction/right?windowID=9999" -> error: No window found with ID 9999 */ import Defaults @@ -135,6 +142,8 @@ final class URLCommandHandler { case list /// List all visible windows as JSON case windowlist + /// List all screens as JSON + case screenlist /// Human-readable description of each command type var description: String { @@ -145,10 +154,18 @@ final class URLCommandHandler { case .keybind: "Execute custom keybind action" case .list: "List available commands" case .windowlist: "List all visible windows as JSON" + case .screenlist: "List all screens as JSON" } } } + /// Parameters parsed from URL query string for targeting specific windows and screens + private struct TargetParams { + var windowID: CGWindowID? + var bundleID: String? + var screenID: CGDirectDisplayID? + } + // MARK: - Properties // MARK: - JSON Helpers @@ -199,13 +216,23 @@ final class URLCommandHandler { ]) } - // Parse optional windowID query parameter for targeting a specific window + // Parse query parameters for targeting specific windows and screens let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) - let windowID: CGWindowID? = urlComponents? - .queryItems? - .first(where: { $0.name == "windowID" })? - .value - .flatMap { UInt32($0) } + let queryItems = urlComponents?.queryItems + + let params = TargetParams( + windowID: queryItems?.first(where: { $0.name == "windowID" })?.value.flatMap { UInt32($0) }, + bundleID: queryItems?.first(where: { $0.name == "bundleID" })?.value, + screenID: queryItems?.first(where: { $0.name == "screenID" })?.value.flatMap { UInt32($0) } + ) + + // windowID and bundleID are mutually exclusive + if params.windowID != nil, params.bundleID != nil { + return jsonString([ + "success": false, + "error": "windowID and bundleID are mutually exclusive" + ]) + } let components = (url.host.map { [$0] } ?? []) + url.pathComponents.filter { $0 != "/" && !$0.isEmpty } @@ -218,7 +245,7 @@ final class URLCommandHandler { ]) } - return processCommand(command, Array(components.dropFirst()), windowID: windowID) + return processCommand(command, Array(components.dropFirst()), params: params) } // MARK: - Command Processing @@ -227,19 +254,20 @@ final class URLCommandHandler { /// - Parameters: /// - command: The command to process /// - parameters: Array of command parameters - /// - windowID: Optional CGWindowID to target a specific window + /// - params: Targeting parameters (windowID/bundleID/screenID) /// - Returns: A JSON string containing the response - private func processCommand(_ command: Command, _ parameters: [String], windowID: CGWindowID? = nil) -> String { + private func processCommand(_ command: Command, _ parameters: [String], params: TargetParams = .init()) -> String { log.info("\(command.rawValue) \(parameters)") let response: [String: Any] switch command { - case .direction: response = handleDirectionCommand(parameters, windowID: windowID) - case .screen: response = handleScreenCommand(parameters, windowID: windowID) - case .action: response = handleActionCommand(parameters, windowID: windowID) - case .keybind: response = handleKeybindCommand(parameters, windowID: windowID) + case .direction: response = handleDirectionCommand(parameters, params: params) + case .screen: response = handleScreenCommand(parameters, params: params) + case .action: response = handleActionCommand(parameters, params: params) + case .keybind: response = handleKeybindCommand(parameters, params: params) case .list: response = handleListCommand(parameters) case .windowlist: response = handleWindowListCommand() + case .screenlist: response = handleScreenListCommand() } return jsonString(response) @@ -250,7 +278,7 @@ final class URLCommandHandler { /// - parameters: Direction parameters /// - windowID: Optional CGWindowID to target a specific window /// - Returns: JSON response dictionary - private func handleDirectionCommand(_ parameters: [String], windowID: CGWindowID? = nil) -> [String: Any] { + private func handleDirectionCommand(_ parameters: [String], params: TargetParams = .init()) -> [String: Any] { guard let directionStr = parameters.first?.lowercased() else { return [ "success": false, @@ -266,7 +294,7 @@ final class URLCommandHandler { // First check if this is a custom action being called via direction if directionStr.hasPrefix("custom") || directionStr.hasPrefix("stash") { - return handleActionCommand(parameters, windowID: windowID) + return handleActionCommand(parameters, params: params) } let direction: WindowDirection? = WindowDirection.allCases.first { $0.rawValue.lowercased() == directionStr } ?? { @@ -282,7 +310,7 @@ final class URLCommandHandler { }() if let direction { - return executeWindowAction(direction, windowID: windowID) + return executeWindowAction(direction, params: params) } else { return [ "success": false, @@ -298,22 +326,24 @@ final class URLCommandHandler { /// - direction: The direction to move/resize the window /// - windowID: Optional CGWindowID to target a specific window /// - Returns: JSON response dictionary - private func executeWindowAction(_ direction: WindowDirection, windowID: CGWindowID? = nil) -> [String: Any] { + private func executeWindowAction(_ direction: WindowDirection, params: TargetParams = .init()) -> [String: Any] { log.info("Executing direction: \(direction.rawValue)") - guard let window = resolveWindow(windowID: windowID) else { + guard let window = resolveWindow(params: params) else { return [ "success": false, "command": "direction", "parameter": direction.rawValue.lowercased(), - "error": windowID != nil - ? "No window found with ID \(windowID!)" - : "No frontmost window found" + "error": windowResolveError(params) ] } - guard let screen = NSScreen.main else { - return ["success": false, "command": "direction", "error": "No screen found"] + guard let screen = resolveScreen(screenID: params.screenID) else { + return [ + "success": false, + "command": "direction", + "error": "No screen found with ID \(params.screenID!)" + ] } let action = WindowAction(direction) @@ -331,19 +361,17 @@ final class URLCommandHandler { /// - parameters: Screen command parameters /// - windowID: Optional CGWindowID to target a specific window /// - Returns: JSON response dictionary - private func handleScreenCommand(_ parameters: [String], windowID: CGWindowID? = nil) -> [String: Any] { + private func handleScreenCommand(_ parameters: [String], params: TargetParams = .init()) -> [String: Any] { guard let command = parameters.first?.lowercased() else { return ["success": false, "command": "screen", "error": "No screen command specified"] } - guard let window = resolveWindow(windowID: windowID) else { + guard let window = resolveWindow(params: params) else { return [ "success": false, "command": "screen", "parameter": command, - "error": windowID != nil - ? "No window found with ID \(windowID!)" - : "No frontmost window found" + "error": windowResolveError(params) ] } @@ -362,7 +390,7 @@ final class URLCommandHandler { /// - parameters: Action parameters /// - windowID: Optional CGWindowID to target a specific window /// - Returns: JSON response dictionary - private func handleActionCommand(_ parameters: [String], windowID: CGWindowID? = nil) -> [String: Any] { + private func handleActionCommand(_ parameters: [String], params: TargetParams = .init()) -> [String: Any] { guard let actionStr = parameters.first?.lowercased() else { return buildActionsResponse() } @@ -370,24 +398,19 @@ final class URLCommandHandler { // First check for custom actions by name let customKeybinds = Defaults[.keybinds].filter { $0.direction.isCustomizable && $0.name != nil } if let customAction = customKeybinds.first(where: { ($0.name?.lowercased() ?? "") == actionStr }) { - if let window = resolveWindow(windowID: windowID), let screen = NSScreen.main { - activateAndResizeWindow(window, customAction, screen) - return [ - "success": true, - "command": "action", - "action": customAction.name ?? actionStr, - "window": windowJSON(window) - ] - } else { - return [ - "success": false, - "command": "action", - "parameter": actionStr, - "error": windowID != nil - ? "No window found with ID \(windowID!)" - : "No suitable window found" - ] + guard let window = resolveWindow(params: params) else { + return ["success": false, "command": "action", "parameter": actionStr, "error": windowResolveError(params)] + } + guard let screen = resolveScreen(screenID: params.screenID) else { + return ["success": false, "command": "action", "error": "No screen found with ID \(params.screenID!)"] } + activateAndResizeWindow(window, customAction, screen) + return [ + "success": true, + "command": "action", + "action": customAction.name ?? actionStr, + "window": windowJSON(window) + ] } if actionStr == "list" { @@ -395,24 +418,19 @@ final class URLCommandHandler { } if let direction = WindowDirection.allCases.first(where: { $0.rawValue.lowercased() == actionStr }) { - if let window = resolveWindow(windowID: windowID), let screen = NSScreen.main { - activateAndResizeWindow(window, .init(direction), screen) - return [ - "success": true, - "command": "action", - "action": direction.rawValue, - "window": windowJSON(window) - ] - } else { - return [ - "success": false, - "command": "action", - "parameter": actionStr, - "error": windowID != nil - ? "No window found with ID \(windowID!)" - : "No suitable window found" - ] + guard let window = resolveWindow(params: params) else { + return ["success": false, "command": "action", "parameter": actionStr, "error": windowResolveError(params)] + } + guard let screen = resolveScreen(screenID: params.screenID) else { + return ["success": false, "command": "action", "error": "No screen found with ID \(params.screenID!)"] } + activateAndResizeWindow(window, .init(direction), screen) + return [ + "success": true, + "command": "action", + "action": direction.rawValue, + "window": windowJSON(window) + ] } return [ @@ -478,7 +496,7 @@ final class URLCommandHandler { /// - parameters: Keybind parameters /// - windowID: Optional CGWindowID to target a specific window /// - Returns: JSON response dictionary - private func handleKeybindCommand(_ parameters: [String], windowID: CGWindowID? = nil) -> [String: Any] { + private func handleKeybindCommand(_ parameters: [String], params: TargetParams = .init()) -> [String: Any] { let keybinds = Defaults[.keybinds] guard let keybindName = parameters.first else { @@ -504,37 +522,34 @@ final class URLCommandHandler { ] } - guard let window = resolveWindow(windowID: windowID) else { + guard let window = resolveWindow(params: params) else { return [ "success": false, "command": "keybind", "parameter": keybindName, - "error": windowID != nil - ? "No window found with ID \(windowID!)" - : "No frontmost window found" + "error": windowResolveError(params) ] } - if let screen = NSScreen.main { - Task { - _ = try await WindowActionEngine.shared.apply( - keybind, window: window, screen: screen - ) - } - return [ - "success": true, - "command": "keybind", - "keybind": keybind.name ?? keybindName, - "window": windowJSON(window) - ] - } else { + guard let screen = resolveScreen(screenID: params.screenID) else { return [ "success": false, "command": "keybind", - "parameter": keybindName, - "error": "No suitable window found" + "error": "No screen found with ID \(params.screenID!)" ] } + + Task { + _ = try await WindowActionEngine.shared.apply( + keybind, window: window, screen: screen + ) + } + return [ + "success": true, + "command": "keybind", + "keybind": keybind.name ?? keybindName, + "window": windowJSON(window) + ] } /// Handles list commands for viewing available options @@ -595,6 +610,32 @@ final class URLCommandHandler { ] } + // MARK: - Screen List + + /// Lists all connected screens with their details + /// - Returns: JSON response dictionary + private func handleScreenListCommand() -> [String: Any] { + let screens = NSScreen.screens + return [ + "success": true, + "command": "screenlist", + "screenCount": screens.count, + "screens": screens.map { screen in + [ + "screenID": screen.displayID ?? 0, + "name": screen.localizedName, + "frame": [ + "x": Int(screen.frame.origin.x), + "y": Int(screen.frame.origin.y), + "width": Int(screen.frame.width), + "height": Int(screen.frame.height) + ], + "isMain": screen == NSScreen.main + ] as [String: Any] + } + ] + } + // MARK: - Helper Methods /// Finds a window by its CGWindowID from the current window list @@ -604,16 +645,89 @@ final class URLCommandHandler { WindowUtility.windowList().first { $0.cgWindowID == windowID } } - /// Resolves the target window — by ID if specified, otherwise the frontmost window - /// - Parameter windowID: Optional CGWindowID to target a specific window + /// Resolves the target window from targeting parameters + /// Priority: windowID > bundleID > frontmost window + /// - Parameter params: Targeting parameters /// - Returns: The resolved window, or nil if not found - private func resolveWindow(windowID: CGWindowID? = nil) -> Window? { - if let windowID { + private func resolveWindow(params: TargetParams = .init()) -> Window? { + if let windowID = params.windowID { return findWindowByID(windowID) } + if let bundleID = params.bundleID { + return resolveWindowByBundleID(bundleID) + } return try? WindowUtility.frontmostWindow() } + /// Resolves a window by bundle ID, launching the app if needed + /// - Parameter bundleID: The bundle identifier of the app + /// - Returns: The app's frontmost window, or nil if not found + private func resolveWindowByBundleID(_ bundleID: String) -> Window? { + // Check if already running + if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bundleID }) { + app.activate(options: .activateIgnoringOtherApps) + // Brief pause to let activation settle + Thread.sleep(forTimeInterval: 0.1) + return try? Window(pid: app.processIdentifier) + } + + // Not running — try to launch it + guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else { + log.error("No app found for bundle ID: \(bundleID)") + return nil + } + + let config = NSWorkspace.OpenConfiguration() + config.activates = true + + let semaphore = DispatchSemaphore(value: 0) + var launchedApp: NSRunningApplication? + NSWorkspace.shared.openApplication(at: appURL, configuration: config) { app, error in + if let error { + Self.log.error("Failed to launch \(bundleID): \(error.localizedDescription)") + } + launchedApp = app + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 5) + + guard let app = launchedApp else { return nil } + + // Wait for the app to create a window (up to 3 seconds) + for _ in 0..<30 { + Thread.sleep(forTimeInterval: 0.1) + if let window = try? Window(pid: app.processIdentifier) { + return window + } + } + + log.error("App launched but no window appeared: \(bundleID)") + return nil + } + + /// Resolves a screen by display ID, falling back to the main screen + /// - Parameter screenID: Optional display ID to target a specific screen + /// - Returns: The resolved screen, or nil if the specified ID was not found + private func resolveScreen(screenID: CGDirectDisplayID? = nil) -> NSScreen? { + if let screenID { + return NSScreen.screens.first { $0.displayID == screenID } + } + return NSScreen.main + } + + /// Builds a human-readable error message for window resolution failure + /// - Parameter params: The targeting parameters that were used + /// - Returns: Error message string + private func windowResolveError(_ params: TargetParams) -> String { + if let windowID = params.windowID { + return "No window found with ID \(windowID)" + } + if let bundleID = params.bundleID { + return "Could not find or launch app: \(bundleID)" + } + return "No frontmost window found" + } + /// Activates and resizes a window private func activateAndResizeWindow(_ window: Window, _ action: WindowAction, _ screen: NSScreen) { if let app = window.nsRunningApplication { From 2b1f77ffea2c6d587522e7c56611dda4cae72bee Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Wed, 18 Mar 2026 16:16:04 -0600 Subject: [PATCH 3/8] =?UTF-8?q?=E2=9C=A8=20Loop=20CLI=20to=20manipulate=20?= =?UTF-8?q?windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop.xcodeproj/project.pbxproj | 126 ++++++++++++++++++ Loop/App/AppDelegate.swift | 5 + Loop/Core/LoopServer.swift | 209 ++++++++++++++++++++++++++++++ Loop/Core/URLCommandHandler.swift | 77 ++++++++--- LoopCLI/main.swift | 176 +++++++++++++++++++++++++ 5 files changed, 576 insertions(+), 17 deletions(-) create mode 100644 Loop/Core/LoopServer.swift create mode 100644 LoopCLI/main.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 849aebc7..185b36d2 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 2AF923902F540B2200F467FD /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AF9238F2F540B2200F467FD /* Scribe */; }; 3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */; }; B1AA00412F30000100AABBCC /* LoopUpdaterHelper in CopyFiles */ = {isa = PBXBuildFile; fileRef = B1AA00012F30000100AABBCC /* LoopUpdaterHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + C1BB00412F30000200AABBCC /* loop-cli in CopyFiles */ = {isa = PBXBuildFile; fileRef = C1BB00012F30000200AABBCC /* loop-cli */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; F06D768A2DFF7A77007EEDA9 /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F06D76892DFF7A77007EEDA9 /* SkyLight.framework */; }; /* End PBXBuildFile section */ @@ -25,6 +26,13 @@ remoteGlobalIDString = B1AA00112F30000100AABBCC; remoteInfo = LoopUpdaterHelper; }; + C1BB00512F30000200AABBCC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = A8E59C2D297F5E9A0064D4BA /* Project object */; + proxyType = 1; + remoteGlobalIDString = C1BB00112F30000200AABBCC; + remoteInfo = LoopCLI; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -38,6 +46,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C1BB00612F30000200AABBCC /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 6; + files = ( + C1BB00412F30000200AABBCC /* loop-cli in CopyFiles */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -48,6 +66,7 @@ A8E59C35297F5E9A0064D4BA /* Loop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Loop.app; sourceTree = BUILT_PRODUCTS_DIR; }; A8E6D1FC2A4155DC005751D4 /* .gitignore */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; B1AA00012F30000100AABBCC /* LoopUpdaterHelper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = LoopUpdaterHelper; sourceTree = BUILT_PRODUCTS_DIR; }; + C1BB00012F30000200AABBCC /* loop-cli */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "loop-cli"; sourceTree = BUILT_PRODUCTS_DIR; }; B1AA00802F30000100AABBCC /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; F06D76892DFF7A77007EEDA9 /* SkyLight.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SkyLight.framework; path = ../../../../System/Library/PrivateFrameworks/SkyLight.framework; sourceTree = ""; }; /* End PBXFileReference section */ @@ -92,6 +111,7 @@ 2A6A87F02F4D20D2004E995D /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2A6A87F22F4D20F4004E995D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; A8C751AC2D7BA98600B58784 /* Loop */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (A8C751FF2D7BA98600B58784 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Loop; sourceTree = ""; }; B1AA00022F30000100AABBCC /* LoopUpdaterHelper */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2A5DD5CC2F5D270C0077AB3C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LoopUpdaterHelper; sourceTree = ""; }; + C1BB00022F30000200AABBCC /* LoopCLI */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopCLI; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -116,6 +136,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C1BB00102F30000200AABBCC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -138,6 +165,7 @@ A894B0C52C4B31AA00B4CE6F /* CONTRIBUTING.md */, A8C751AC2D7BA98600B58784 /* Loop */, B1AA00022F30000100AABBCC /* LoopUpdaterHelper */, + C1BB00022F30000200AABBCC /* LoopCLI */, A8E59C36297F5E9A0064D4BA /* Products */, A883642D298B7288005D6C19 /* Frameworks */, 2A6A87F02F4D20D2004E995D /* Shared */, @@ -149,6 +177,7 @@ children = ( A8E59C35297F5E9A0064D4BA /* Loop.app */, B1AA00012F30000100AABBCC /* LoopUpdaterHelper */, + C1BB00012F30000200AABBCC /* loop-cli */, ); name = Products; sourceTree = ""; @@ -163,12 +192,14 @@ A8E59C31297F5E9A0064D4BA /* Sources */, A8E59C32297F5E9A0064D4BA /* Frameworks */, B1AA00612F30000100AABBCC /* CopyFiles */, + C1BB00612F30000200AABBCC /* CopyFiles */, A8E59C33297F5E9A0064D4BA /* Resources */, ); buildRules = ( ); dependencies = ( B1AA00712F30000100AABBCC /* PBXTargetDependency */, + C1BB00712F30000200AABBCC /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 2A6A87F02F4D20D2004E995D /* Shared */, @@ -209,6 +240,25 @@ productReference = B1AA00012F30000100AABBCC /* LoopUpdaterHelper */; productType = "com.apple.product-type.tool"; }; + C1BB00112F30000200AABBCC /* LoopCLI */ = { + isa = PBXNativeTarget; + buildConfigurationList = C1BB00212F30000200AABBCC /* Build configuration list for PBXNativeTarget "LoopCLI" */; + buildPhases = ( + C1BB00122F30000200AABBCC /* Sources */, + C1BB00102F30000200AABBCC /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + C1BB00022F30000200AABBCC /* LoopCLI */, + ); + name = LoopCLI; + productName = "loop-cli"; + productReference = C1BB00012F30000200AABBCC /* loop-cli */; + productType = "com.apple.product-type.tool"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -225,6 +275,9 @@ B1AA00112F30000100AABBCC = { CreatedOnToolsVersion = 16.0; }; + C1BB00112F30000200AABBCC = { + CreatedOnToolsVersion = 16.0; + }; }; }; buildConfigurationList = A8E59C30297F5E9A0064D4BA /* Build configuration list for PBXProject "Loop" */; @@ -260,6 +313,7 @@ targets = ( A8E59C34297F5E9A0064D4BA /* Loop */, B1AA00112F30000100AABBCC /* LoopUpdaterHelper */, + C1BB00112F30000200AABBCC /* LoopCLI */, ); }; /* End PBXProject section */ @@ -296,6 +350,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C1BB00122F30000200AABBCC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -304,6 +365,11 @@ target = B1AA00112F30000100AABBCC /* LoopUpdaterHelper */; targetProxy = B1AA00512F30000100AABBCC /* PBXContainerItemProxy */; }; + C1BB00712F30000200AABBCC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C1BB00112F30000200AABBCC /* LoopCLI */; + targetProxy = C1BB00512F30000200AABBCC /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -716,6 +782,56 @@ }; name = Development; }; + C1BB00312F30000200AABBCC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5F967GYF84; + ENABLE_HARDENED_RUNTIME = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.CLI; + PRODUCT_NAME = "loop-cli"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + C1BB00322F30000200AABBCC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5F967GYF84; + ENABLE_HARDENED_RUNTIME = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.CLI; + PRODUCT_NAME = "loop-cli"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + C1BB00332F30000200AABBCC /* Development */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 5F967GYF84; + ENABLE_HARDENED_RUNTIME = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.CLI; + PRODUCT_NAME = "loop-cli"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Development; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -749,6 +865,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + C1BB00212F30000200AABBCC /* Build configuration list for PBXNativeTarget "LoopCLI" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C1BB00312F30000200AABBCC /* Debug */, + C1BB00322F30000200AABBCC /* Release */, + C1BB00332F30000200AABBCC /* Development */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/Loop/App/AppDelegate.swift b/Loop/App/AppDelegate.swift index 839894ed..c7999ee5 100644 --- a/Loop/App/AppDelegate.swift +++ b/Loop/App/AppDelegate.swift @@ -13,6 +13,7 @@ import UserNotifications @Loggable final class AppDelegate: NSObject, NSApplicationDelegate { private let urlCommandHandler = URLCommandHandler() + private lazy var loopServer = LoopServer(handler: urlCommandHandler) private var launchedAsLoginItem: Bool { guard let event = NSAppleEventManager.shared().currentAppleEvent else { return false } @@ -69,6 +70,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { forEventClass: AEEventClass(kInternetEventClass), andEventID: AEEventID(kAEGetURL) ) + + // Start the Unix socket server for loop-cli + loopServer.start() } /// Terminates any other running instances of Loop to prevent accessibility permission conflicts. @@ -141,6 +145,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_: Notification) { + loopServer.stop() StashManager.shared.onApplicationWillTerminate() } diff --git a/Loop/Core/LoopServer.swift b/Loop/Core/LoopServer.swift new file mode 100644 index 00000000..8c1d6268 --- /dev/null +++ b/Loop/Core/LoopServer.swift @@ -0,0 +1,209 @@ +// +// LoopServer.swift +// Loop +// +// Created by Kai Azim on 2026-03-18. +// + +import Foundation +import Scribe + +/// Listens on a Unix domain socket for commands from loop-cli. +/// +/// The server accepts connections, reads a raw command string (newline-terminated), +/// dispatches it to `URLCommandHandler.executeRaw()` on the main thread, and writes +/// the JSON response back before closing the connection. +/// +/// Command format: ` [args...] [--window-id ] [--bundle-id ] [--screen-id ]` +/// Example: `direction right --bundle-id com.apple.Safari` +@Loggable +final class LoopServer { + // MARK: - Properties + + private let socketPath: String + private let handler: URLCommandHandler + private var serverFD: Int32 = -1 + private var isRunning = false + + private let acceptQueue = DispatchQueue( + label: "com.MrKai77.Loop.server.accept", + qos: .userInitiated + ) + + private static let maxRequestSize = 4096 + private static let connectionTimeout: TimeInterval = 5 + + // MARK: - Initialization + + init(handler: URLCommandHandler) { + self.handler = handler + self.socketPath = "/tmp/loop-\(getuid()).socket" + } + + // MARK: - Public Methods + + func start() { + // Clean up stale socket from a previous crash + unlink(socketPath) + + // Create socket + serverFD = socket(AF_UNIX, SOCK_STREAM, 0) + guard serverFD >= 0 else { + log.error("Failed to create socket: \(String(cString: strerror(errno)))") + return + } + + // Bind to path + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + + let pathBytes = socketPath.utf8CString + guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { + log.error("Socket path too long: \(socketPath)") + close(serverFD) + serverFD = -1 + return + } + + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in + pathBytes.withUnsafeBufferPointer { src in + _ = memcpy(dest, src.baseAddress!, src.count) + } + } + } + + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + bind(serverFD, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + + guard bindResult == 0 else { + log.error("Failed to bind socket: \(String(cString: strerror(errno)))") + close(serverFD) + serverFD = -1 + return + } + + // Set permissions to owner-only + chmod(socketPath, 0o600) + + // Start listening + guard listen(serverFD, 5) == 0 else { + log.error("Failed to listen on socket: \(String(cString: strerror(errno)))") + close(serverFD) + unlink(socketPath) + serverFD = -1 + return + } + + isRunning = true + log.info("Listening on \(socketPath)") + + // Accept loop on background queue + acceptQueue.async { [weak self] in + self?.acceptLoop() + } + } + + func stop() { + isRunning = false + if serverFD >= 0 { + close(serverFD) + serverFD = -1 + } + unlink(socketPath) + log.info("Server stopped") + } + + // MARK: - Private Methods + + private func acceptLoop() { + while isRunning { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout.size) + + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + accept(serverFD, sockaddrPtr, &clientAddrLen) + } + } + + guard clientFD >= 0 else { + if isRunning { + log.error("Accept failed: \(String(cString: strerror(errno)))") + } + continue + } + + // Set receive timeout + var timeout = timeval(tv_sec: Int(Self.connectionTimeout), tv_usec: 0) + setsockopt(clientFD, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + + handleConnection(clientFD) + } + } + + private func handleConnection(_ clientFD: Int32) { + defer { close(clientFD) } + + // Read request (until newline or max size) + var buffer = [UInt8](repeating: 0, count: Self.maxRequestSize) + var totalRead = 0 + + while totalRead < Self.maxRequestSize { + let bytesRead = read(clientFD, &buffer[totalRead], Self.maxRequestSize - totalRead) + if bytesRead <= 0 { break } + totalRead += bytesRead + + // Check for newline delimiter + if buffer[.. 0 else { + writeResponse(clientFD, #"{"success":false,"error":"Empty request"}"#) + return + } + + // Trim newline and parse + let requestString = String(bytes: buffer[.. String { @@ -226,6 +227,61 @@ final class URLCommandHandler { screenID: queryItems?.first(where: { $0.name == "screenID" })?.value.flatMap { UInt32($0) } ) + let components = (url.host.map { [$0] } ?? []) + url.pathComponents.filter { $0 != "/" && !$0.isEmpty } + + return execute(components, params: params) + } + + /// Executes a raw command string and returns a JSON response. + /// This is the entry point for the Unix socket (loop-cli). + /// + /// Format: ` [args...] [--window-id ] [--bundle-id ] [--screen-id ]` + /// + /// Examples: + /// - `windowlist` + /// - `direction right` + /// - `direction right --bundle-id com.apple.Safari --screen-id 123` + /// - `action maximize --window-id 1234` + /// + /// - Parameter input: The raw command string + /// - Returns: A JSON string containing the response + func executeRaw(_ input: String) -> String { + log.info("Processing command: \(input)") + + var components: [String] = [] + var params = TargetParams() + + let tokens = input.split(separator: " ", omittingEmptySubsequences: true).map(String.init) + var i = 0 + + while i < tokens.count { + switch tokens[i] { + case "--window-id": + i += 1 + if i < tokens.count { params.windowID = UInt32(tokens[i]) } + case "--bundle-id": + i += 1 + if i < tokens.count { params.bundleID = tokens[i] } + case "--screen-id": + i += 1 + if i < tokens.count { params.screenID = UInt32(tokens[i]) } + default: + components.append(tokens[i]) + } + i += 1 + } + + return execute(components, params: params) + } + + // MARK: - Command Execution + + /// Core command execution. Both `handle()` (URL scheme) and `executeRaw()` (socket) converge here. + /// - Parameters: + /// - components: Path components (e.g., `["direction", "right"]`) + /// - params: Targeting parameters (windowID/bundleID/screenID) + /// - Returns: A JSON string containing the response + private func execute(_ components: [String], params: TargetParams) -> String { // windowID and bundleID are mutually exclusive if params.windowID != nil, params.bundleID != nil { return jsonString([ @@ -234,8 +290,6 @@ final class URLCommandHandler { ]) } - let components = (url.host.map { [$0] } ?? []) + url.pathComponents.filter { $0 != "/" && !$0.isEmpty } - guard let commandString = components.first, let command = Command(rawValue: commandString.lowercased()) else { return jsonString([ @@ -245,18 +299,7 @@ final class URLCommandHandler { ]) } - return processCommand(command, Array(components.dropFirst()), params: params) - } - - // MARK: - Command Processing - - /// Processes a command with its parameters and returns a JSON response string - /// - Parameters: - /// - command: The command to process - /// - parameters: Array of command parameters - /// - params: Targeting parameters (windowID/bundleID/screenID) - /// - Returns: A JSON string containing the response - private func processCommand(_ command: Command, _ parameters: [String], params: TargetParams = .init()) -> String { + let parameters = Array(components.dropFirst()) log.info("\(command.rawValue) \(parameters)") let response: [String: Any] @@ -684,7 +727,7 @@ final class URLCommandHandler { var launchedApp: NSRunningApplication? NSWorkspace.shared.openApplication(at: appURL, configuration: config) { app, error in if let error { - Self.log.error("Failed to launch \(bundleID): \(error.localizedDescription)") + self.log.error("Failed to launch \(bundleID): \(error.localizedDescription)") } launchedApp = app semaphore.signal() diff --git a/LoopCLI/main.swift b/LoopCLI/main.swift new file mode 100644 index 00000000..1722405b --- /dev/null +++ b/LoopCLI/main.swift @@ -0,0 +1,176 @@ +// +// main.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-18. +// + +import Foundation + +// MARK: - Socket Communication + +/// Connects to the Loop Unix socket, sends a command URL, and returns the JSON response. +func sendCommand(_ urlString: String) -> (response: String, success: Bool) { + let socketPath = "/tmp/loop-\(getuid()).socket" + + // Create socket + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + return (makeError("Failed to create socket"), false) + } + defer { close(fd) } + + // Connect + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + + let pathBytes = socketPath.utf8CString + withUnsafeMutablePointer(to: &addr.sun_path) { ptr in + ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in + pathBytes.withUnsafeBufferPointer { src in + _ = memcpy(dest, src.baseAddress!, src.count) + } + } + } + + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + connect(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) + } + } + + guard connectResult == 0 else { + return (makeError("Loop is not running (could not connect to \(socketPath))"), false) + } + + // Set timeout + var timeout = timeval(tv_sec: 5, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + + // Send request + let request = urlString + "\n" + let sent = request.utf8.withContiguousStorageIfAvailable { buffer in + Darwin.write(fd, buffer.baseAddress!, buffer.count) + } ?? -1 + + guard sent > 0 else { + return (makeError("Failed to send command"), false) + } + + // Read response (until EOF) + var responseData = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + + while true { + let bytesRead = read(fd, &buffer, buffer.count) + if bytesRead <= 0 { break } + responseData.append(contentsOf: buffer[.. String { + #"{"success":false,"error":"\#(message)"}"# +} + +// MARK: - Command Building + +/// Builds a raw command string from CLI arguments. +/// The command string is sent directly over the socket — no URL wrapping. +/// +/// Usage: loop-cli [subcommand...] [--window-id ] [--bundle-id ] [--screen-id ] +/// +/// Examples: +/// loop-cli windowlist → "windowlist" +/// loop-cli direction right → "direction right" +/// loop-cli direction right --bundle-id com.apple.Safari → "direction right --bundle-id com.apple.Safari" +func buildCommand(from args: [String]) -> String? { + guard !args.isEmpty else { return nil } + + // Validate that flag args have values + var i = 0 + while i < args.count { + if args[i] == "--window-id" || args[i] == "--bundle-id" || args[i] == "--screen-id" { + guard i + 1 < args.count else { + fputs("Error: \(args[i]) requires a value\n", stderr) + return nil + } + i += 2 + } else { + i += 1 + } + } + + return args.joined(separator: " ") +} + +// MARK: - Help + +let helpText = """ +loop-cli — Command-line interface for Loop window manager + +USAGE: + loop-cli [arguments] [options] + +COMMANDS: + windowlist List all visible windows + screenlist List all connected screens + direction Move/resize window (left, right, top, bottom, maximize, center, ...) + action Execute a window action + keybind Execute a custom keybind + screen Move window to another screen + list List available commands + +OPTIONS: + --window-id Target a specific window by ID (from windowlist) + --bundle-id Target an app by bundle identifier (launches if needed) + --screen-id Target a specific screen by ID (from screenlist) + --help, -h Show this help message + +EXAMPLES: + loop-cli windowlist + loop-cli direction right + loop-cli direction right --bundle-id com.apple.Safari + loop-cli action maximize --window-id 1234 --screen-id 5678 + loop-cli list all + +All commands return JSON. Exit code is 0 on success, 1 on failure. +""" + +// MARK: - Main + +let args = Array(CommandLine.arguments.dropFirst()) + +if args.isEmpty || args.contains("--help") || args.contains("-h") { + print(helpText) + exit(args.isEmpty ? 1 : 0) +} + +guard let command = buildCommand(from: args) else { + fputs("Error: No command specified. Run 'loop-cli --help' for usage.\n", stderr) + exit(1) +} + +let (response, success) = sendCommand(command) +print(response) +exit(success ? 0 : 1) From 7a2fc8652f9005ea21a816ecac90068a099a6f24 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sun, 29 Mar 2026 23:44:27 -0600 Subject: [PATCH 4/8] =?UTF-8?q?=E2=9C=A8=20Installable=20CLI,=20streamline?= =?UTF-8?q?=20URL/CLI=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop.xcodeproj/project.pbxproj | 58 +- Loop/App/AppDelegate.swift | 89 +- Loop/Core/URLCommandHandler.swift | 812 -------- Loop/Localizable.xcstrings | 12 + Loop/Scripting/CommandLineToolInstaller.swift | 138 ++ .../CommandOutputWindowController.swift | 153 ++ .../CommandOutputWindowManager.swift | 56 + Loop/Scripting/LoopCommandHandler.swift | 1794 +++++++++++++++++ .../LoopSocketManager.swift} | 14 +- .../Loop/AdvancedConfiguration.swift | 125 ++ ...wift => PrivilegedHelperCoordinator.swift} | 116 +- Loop/Updater/UpdateInstaller.swift | 20 +- LoopCLI/main.swift | 113 +- .../Info.plist | 4 +- .../PrivilegedHelper.swift | 202 +- .../PrivilegedHelperError.swift | 7 +- .../PrivilegedHelperService.swift | 20 +- .../main.swift | 2 +- README.md | 32 +- Shared/PrivilegedHelperProtocol.swift | 58 + Shared/PrivilegedInstallerProtocol.swift | 34 - 21 files changed, 2802 insertions(+), 1057 deletions(-) delete mode 100644 Loop/Core/URLCommandHandler.swift create mode 100644 Loop/Scripting/CommandLineToolInstaller.swift create mode 100644 Loop/Scripting/CommandOutputWindowController.swift create mode 100644 Loop/Scripting/CommandOutputWindowManager.swift create mode 100644 Loop/Scripting/LoopCommandHandler.swift rename Loop/{Core/LoopServer.swift => Scripting/LoopSocketManager.swift} (94%) rename Loop/Updater/{UpdaterAuthorizationCoordinator.swift => PrivilegedHelperCoordinator.swift} (67%) rename {LoopUpdaterHelper => LoopPrivilegedHelper}/Info.plist (80%) rename LoopUpdaterHelper/PrivilegedInstaller.swift => LoopPrivilegedHelper/PrivilegedHelper.swift (71%) rename LoopUpdaterHelper/PrivilegedInstallerError.swift => LoopPrivilegedHelper/PrivilegedHelperError.swift (78%) rename LoopUpdaterHelper/PrivilegedInstallerService.swift => LoopPrivilegedHelper/PrivilegedHelperService.swift (87%) rename {LoopUpdaterHelper => LoopPrivilegedHelper}/main.swift (52%) create mode 100644 Shared/PrivilegedHelperProtocol.swift delete mode 100644 Shared/PrivilegedInstallerProtocol.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 185b36d2..996dcf13 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -13,7 +13,7 @@ 2AF9238E2F540B1300F467FD /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AF9238D2F540B1300F467FD /* Scribe */; }; 2AF923902F540B2200F467FD /* Scribe in Frameworks */ = {isa = PBXBuildFile; productRef = 2AF9238F2F540B2200F467FD /* Scribe */; }; 3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */; }; - B1AA00412F30000100AABBCC /* LoopUpdaterHelper in CopyFiles */ = {isa = PBXBuildFile; fileRef = B1AA00012F30000100AABBCC /* LoopUpdaterHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + B1AA00412F30000100AABBCC /* LoopPrivilegedHelper in CopyFiles */ = {isa = PBXBuildFile; fileRef = B1AA00012F30000100AABBCC /* LoopPrivilegedHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; C1BB00412F30000200AABBCC /* loop-cli in CopyFiles */ = {isa = PBXBuildFile; fileRef = C1BB00012F30000200AABBCC /* loop-cli */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; F06D768A2DFF7A77007EEDA9 /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F06D76892DFF7A77007EEDA9 /* SkyLight.framework */; }; /* End PBXBuildFile section */ @@ -24,7 +24,7 @@ containerPortal = A8E59C2D297F5E9A0064D4BA /* Project object */; proxyType = 1; remoteGlobalIDString = B1AA00112F30000100AABBCC; - remoteInfo = LoopUpdaterHelper; + remoteInfo = LoopPrivilegedHelper; }; C1BB00512F30000200AABBCC /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; @@ -42,7 +42,7 @@ dstPath = Contents/Library/LaunchServices; dstSubfolderSpec = 1; files = ( - B1AA00412F30000100AABBCC /* LoopUpdaterHelper in CopyFiles */, + B1AA00412F30000100AABBCC /* LoopPrivilegedHelper in CopyFiles */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -65,9 +65,9 @@ A894B0C52C4B31AA00B4CE6F /* CONTRIBUTING.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CONTRIBUTING.md; sourceTree = ""; }; A8E59C35297F5E9A0064D4BA /* Loop.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Loop.app; sourceTree = BUILT_PRODUCTS_DIR; }; A8E6D1FC2A4155DC005751D4 /* .gitignore */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = .gitignore; sourceTree = ""; }; - B1AA00012F30000100AABBCC /* LoopUpdaterHelper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = LoopUpdaterHelper; sourceTree = BUILT_PRODUCTS_DIR; }; - C1BB00012F30000200AABBCC /* loop-cli */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "loop-cli"; sourceTree = BUILT_PRODUCTS_DIR; }; + B1AA00012F30000100AABBCC /* LoopPrivilegedHelper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = LoopPrivilegedHelper; sourceTree = BUILT_PRODUCTS_DIR; }; B1AA00802F30000100AABBCC /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; + C1BB00012F30000200AABBCC /* loop-cli */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "loop-cli"; sourceTree = BUILT_PRODUCTS_DIR; }; F06D76892DFF7A77007EEDA9 /* SkyLight.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SkyLight.framework; path = ../../../../System/Library/PrivateFrameworks/SkyLight.framework; sourceTree = ""; }; /* End PBXFileReference section */ @@ -77,15 +77,15 @@ membershipExceptions = ( Info.plist, ); - target = B1AA00112F30000100AABBCC /* LoopUpdaterHelper */; + target = B1AA00112F30000100AABBCC /* LoopPrivilegedHelper */; }; 2A6A87F22F4D20F4004E995D /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( LoopSupportPaths.swift, - PrivilegedInstallerProtocol.swift, + PrivilegedHelperProtocol.swift, ); - target = B1AA00112F30000100AABBCC /* LoopUpdaterHelper */; + target = B1AA00112F30000100AABBCC /* LoopPrivilegedHelper */; }; A8C751FF2D7BA98600B58784 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; @@ -110,7 +110,7 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 2A6A87F02F4D20D2004E995D /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2A6A87F22F4D20F4004E995D /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Shared; sourceTree = ""; }; A8C751AC2D7BA98600B58784 /* Loop */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (A8C751FF2D7BA98600B58784 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Loop; sourceTree = ""; }; - B1AA00022F30000100AABBCC /* LoopUpdaterHelper */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2A5DD5CC2F5D270C0077AB3C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LoopUpdaterHelper; sourceTree = ""; }; + B1AA00022F30000100AABBCC /* LoopPrivilegedHelper */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (2A5DD5CC2F5D270C0077AB3C /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LoopPrivilegedHelper; sourceTree = ""; }; C1BB00022F30000200AABBCC /* LoopCLI */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = LoopCLI; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ @@ -164,7 +164,7 @@ A86AFD7529888B29008F4892 /* README.md */, A894B0C52C4B31AA00B4CE6F /* CONTRIBUTING.md */, A8C751AC2D7BA98600B58784 /* Loop */, - B1AA00022F30000100AABBCC /* LoopUpdaterHelper */, + B1AA00022F30000100AABBCC /* LoopPrivilegedHelper */, C1BB00022F30000200AABBCC /* LoopCLI */, A8E59C36297F5E9A0064D4BA /* Products */, A883642D298B7288005D6C19 /* Frameworks */, @@ -176,7 +176,7 @@ isa = PBXGroup; children = ( A8E59C35297F5E9A0064D4BA /* Loop.app */, - B1AA00012F30000100AABBCC /* LoopUpdaterHelper */, + B1AA00012F30000100AABBCC /* LoopPrivilegedHelper */, C1BB00012F30000200AABBCC /* loop-cli */, ); name = Products; @@ -217,9 +217,9 @@ productReference = A8E59C35297F5E9A0064D4BA /* Loop.app */; productType = "com.apple.product-type.application"; }; - B1AA00112F30000100AABBCC /* LoopUpdaterHelper */ = { + B1AA00112F30000100AABBCC /* LoopPrivilegedHelper */ = { isa = PBXNativeTarget; - buildConfigurationList = B1AA00212F30000100AABBCC /* Build configuration list for PBXNativeTarget "LoopUpdaterHelper" */; + buildConfigurationList = B1AA00212F30000100AABBCC /* Build configuration list for PBXNativeTarget "LoopPrivilegedHelper" */; buildPhases = ( B1AA00122F30000100AABBCC /* Sources */, B1AA00102F30000100AABBCC /* Frameworks */, @@ -230,14 +230,14 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( - B1AA00022F30000100AABBCC /* LoopUpdaterHelper */, + B1AA00022F30000100AABBCC /* LoopPrivilegedHelper */, ); - name = LoopUpdaterHelper; + name = LoopPrivilegedHelper; packageProductDependencies = ( 2AF9238F2F540B2200F467FD /* Scribe */, ); - productName = LoopUpdaterHelper; - productReference = B1AA00012F30000100AABBCC /* LoopUpdaterHelper */; + productName = LoopPrivilegedHelper; + productReference = B1AA00012F30000100AABBCC /* LoopPrivilegedHelper */; productType = "com.apple.product-type.tool"; }; C1BB00112F30000200AABBCC /* LoopCLI */ = { @@ -312,7 +312,7 @@ projectRoot = ""; targets = ( A8E59C34297F5E9A0064D4BA /* Loop */, - B1AA00112F30000100AABBCC /* LoopUpdaterHelper */, + B1AA00112F30000100AABBCC /* LoopPrivilegedHelper */, C1BB00112F30000200AABBCC /* LoopCLI */, ); }; @@ -362,7 +362,7 @@ /* Begin PBXTargetDependency section */ B1AA00712F30000100AABBCC /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = B1AA00112F30000100AABBCC /* LoopUpdaterHelper */; + target = B1AA00112F30000100AABBCC /* LoopPrivilegedHelper */; targetProxy = B1AA00512F30000100AABBCC /* PBXContainerItemProxy */; }; C1BB00712F30000200AABBCC /* PBXTargetDependency */ = { @@ -702,10 +702,10 @@ ENABLE_RESOURCE_ACCESS_LOCATION = NO; ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = LoopUpdaterHelper/Info.plist; + INFOPLIST_FILE = LoopPrivilegedHelper/Info.plist; MACOSX_DEPLOYMENT_TARGET = 13.0; - PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.UpdaterHelper; - PRODUCT_NAME = LoopUpdaterHelper; + PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.PrivilegedHelper; + PRODUCT_NAME = LoopPrivilegedHelper; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; RUNTIME_EXCEPTION_ALLOW_JIT = NO; RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; @@ -733,10 +733,10 @@ ENABLE_RESOURCE_ACCESS_LOCATION = NO; ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = LoopUpdaterHelper/Info.plist; + INFOPLIST_FILE = LoopPrivilegedHelper/Info.plist; MACOSX_DEPLOYMENT_TARGET = 13.0; - PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.UpdaterHelper; - PRODUCT_NAME = LoopUpdaterHelper; + PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.PrivilegedHelper; + PRODUCT_NAME = LoopPrivilegedHelper; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; RUNTIME_EXCEPTION_ALLOW_JIT = NO; RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; @@ -765,10 +765,10 @@ ENABLE_RESOURCE_ACCESS_LOCATION = NO; ENABLE_RESOURCE_ACCESS_PHOTO_LIBRARY = NO; GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = LoopUpdaterHelper/Info.plist; + INFOPLIST_FILE = LoopPrivilegedHelper/Info.plist; MACOSX_DEPLOYMENT_TARGET = 13.0; - PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.UpdaterHelper; - PRODUCT_NAME = LoopUpdaterHelper; + PRODUCT_BUNDLE_IDENTIFIER = com.MrKai77.Loop.PrivilegedHelper; + PRODUCT_NAME = LoopPrivilegedHelper; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = NO; RUNTIME_EXCEPTION_ALLOW_JIT = NO; RUNTIME_EXCEPTION_ALLOW_UNSIGNED_EXECUTABLE_MEMORY = NO; @@ -855,7 +855,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - B1AA00212F30000100AABBCC /* Build configuration list for PBXNativeTarget "LoopUpdaterHelper" */ = { + B1AA00212F30000100AABBCC /* Build configuration list for PBXNativeTarget "LoopPrivilegedHelper" */ = { isa = XCConfigurationList; buildConfigurations = ( B1AA00312F30000100AABBCC /* Debug */, diff --git a/Loop/App/AppDelegate.swift b/Loop/App/AppDelegate.swift index c7999ee5..e08cd125 100644 --- a/Loop/App/AppDelegate.swift +++ b/Loop/App/AppDelegate.swift @@ -12,8 +12,9 @@ import UserNotifications @Loggable final class AppDelegate: NSObject, NSApplicationDelegate { - private let urlCommandHandler = URLCommandHandler() - private lazy var loopServer = LoopServer(handler: urlCommandHandler) + private let loopCommandHandler = LoopCommandHandler() + private lazy var loopSocketManager = LoopSocketManager(handler: loopCommandHandler) + private var pendingSettingsWindowOpen: Task? private var launchedAsLoginItem: Bool { guard let event = NSAppleEventManager.shared().currentAppleEvent else { return false } @@ -32,11 +33,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate { await Defaults.iCloud.waitForSyncCompletion() } - // Show settings window only if not launched as login item AND startHidden is disabled + // Normal user-facing launches should open Settings, but URL-driven launches need a chance + // to cancel that presentation when their URL event arrives immediately after startup. if !launchedAsLoginItem, !Defaults[.startHidden] { - SettingsWindowManager.shared.show() + scheduleSettingsWindowOpen() } else { - // Closing also hides the dock icon if needed. SettingsWindowManager.shared.close() } @@ -71,8 +72,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { andEventID: AEEventID(kAEGetURL) ) - // Start the Unix socket server for loop-cli - loopServer.start() + // Start the Unix socket listener for loop-cli + loopSocketManager.start() } /// Terminates any other running instances of Loop to prevent accessibility permission conflicts. @@ -123,15 +124,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return } - log.info("Received URL: \(url)") - let response = urlCommandHandler.handle(url) - log.info("Response: \(response)") + processIncomingURL(url, replyEvent: replyEvent) + } - // Set reply for callers that support Apple Event replies - replyEvent.setDescriptor( - NSAppleEventDescriptor(string: response), - forKeyword: keyDirectObject - ) + func applicationShouldOpenUntitledFile(_: NSApplication) -> Bool { + !launchedAsLoginItem && !Defaults[.startHidden] + } + + func applicationOpenUntitledFile(_: NSApplication) -> Bool { + cancelPendingSettingsWindowOpen() + SettingsWindowManager.shared.show() + return true } func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { @@ -139,19 +142,63 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return false } - func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows _: Bool) -> Bool { - SettingsWindowManager.shared.show() + func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows: Bool) -> Bool { + guard !hasVisibleWindows else { + return false + } + + scheduleSettingsWindowOpen() return true } - + func applicationWillTerminate(_: Notification) { - loopServer.stop() + loopSocketManager.stop() StashManager.shared.onApplicationWillTerminate() } - + func application(_: NSApplication, open urls: [URL]) { for url in urls { - urlCommandHandler.handle(url) + processIncomingURL(url) } } + + private func processIncomingURL(_ url: URL, replyEvent: NSAppleEventDescriptor? = nil) { + cancelPendingSettingsWindowOpen() + log.info("Received URL: \(url)") + + let result = loopCommandHandler.handle(url) + log.info("Response: \(result.jsonResponse)") + + replyEvent?.setDescriptor( + NSAppleEventDescriptor(string: result.jsonResponse), + forKeyword: keyDirectObject + ) + + Task { @MainActor in + result.presentIfNeeded() + } + } + + private func scheduleSettingsWindowOpen() { + guard !launchedAsLoginItem, !Defaults[.startHidden] else { + return + } + + cancelPendingSettingsWindowOpen() + + pendingSettingsWindowOpen = Task { @MainActor [weak self] in + await Task.yield() + guard !Task.isCancelled else { + return + } + + self?.pendingSettingsWindowOpen = nil + SettingsWindowManager.shared.show() + } + } + + private func cancelPendingSettingsWindowOpen() { + pendingSettingsWindowOpen?.cancel() + pendingSettingsWindowOpen = nil + } } diff --git a/Loop/Core/URLCommandHandler.swift b/Loop/Core/URLCommandHandler.swift deleted file mode 100644 index 91c6ce12..00000000 --- a/Loop/Core/URLCommandHandler.swift +++ /dev/null @@ -1,812 +0,0 @@ -// -// URLCommandHandler.swift -// Loop -// -// Created by Kami on 06/03/2025. -// - -/* - Loop URL Scheme Documentation - =========================== - - The Loop app supports URL scheme commands for window management and automation. - Base URL format: loop:/// - - Available Commands: - ----------------- - - 1. Window Direction Commands: - Format: loop://direction/ - Examples: - - loop://direction/left (Move window to left half) - - loop://direction/right (Move window to right half) - - loop://direction/top (Move window to top half) - - loop://direction/bottom (Move window to bottom half) - - loop://direction/maximize (Maximize window) - - loop://direction/center (Center window) - - 2. Screen Management: - Format: loop://screen/ - Examples: - - loop://screen/next (Move window to next screen) - - loop://screen/previous (Move window to previous screen) - - 3. Action Commands: - Format: loop://action/ - Examples: - - loop://action/maximize (Maximize window) - - loop://action/leftHalf (Move to left half) - Note: See 'loop://list/actions' for all available actions - - 4. Keybind Commands: - Format: loop://keybind/ - Examples: - - loop://keybind/myCustomLayout - Note: See 'loop://list/keybinds' for available keybinds - - 5. List Commands: - Format: loop://list/ - Types: - - actions (List all window actions) - - keybinds (List all custom keybinds) - - all (List everything) - - 6. Window List Command: - Format: loop://windowlist - Returns JSON listing all visible windows with: - - windowID, bundleID, appName, windowTitle, frame - - 7. Screen List Command: - Format: loop://screenlist - Returns JSON listing all connected screens with: - - screenID, name, frame, isMain - - Query Parameters: - ---------------- - All action commands support optional query parameters for targeting: - - ?windowID= Target a specific window by CGWindowID - ?bundleID= Target an app by bundle ID (launches if needed) - ?screenID= Target a specific screen by display ID - - Notes: - - windowID and bundleID are mutually exclusive (error if both specified) - - screenID can be combined with either windowID or bundleID - - Use loop://windowlist and loop://screenlist to discover IDs - - Examples: - - loop://direction/right?windowID=1234 - - loop://action/maximize?bundleID=com.apple.Safari - - loop://direction/left?screenID=12345 - - loop://action/maximize?bundleID=com.apple.Safari&screenID=12345 - - Usage Tips: - ---------- - 1. All commands are case-insensitive - 2. All commands return JSON responses - 3. Window commands operate on the frontmost window by default - 4. Use loop://windowlist and loop://screenlist to discover IDs - 5. Use loop://list/all to discover all available commands - - Examples: - -------- - # Move current window to right half - open "loop://direction/right" - - # List all windows and screens - open "loop://windowlist" - open "loop://screenlist" - - # Move a specific window to the right half - open "loop://direction/right?windowID=1234" - - # Launch/focus Safari and maximize it on a specific screen - open "loop://action/maximize?bundleID=com.apple.Safari&screenID=12345" - - # List all available actions - open "loop://list/actions" - - Error Examples: - ------------- - # Invalid command - open "loop://invalid" -> {"success": false, "error": "Unknown command: invalid"} - - # Both windowID and bundleID - open "loop://direction/right?windowID=1&bundleID=com.x" -> error: mutually exclusive - - # Invalid window/screen ID - open "loop://direction/right?windowID=9999" -> error: No window found with ID 9999 - */ - -import Defaults -import Foundation -import Scribe -import SwiftUI - -/// Handles URL scheme commands for the Loop application -@Loggable -final class URLCommandHandler { - // MARK: - Types - - /// Available URL scheme commands with their descriptions - enum Command: String, CaseIterable { - /// Window positioning commands (left, right, top, bottom, etc.) - case direction - /// Multi-screen management commands (next, previous) - case screen - /// Predefined window actions - case action - /// Custom keybind actions - case keybind - /// List available commands and options - case list - /// List all visible windows as JSON - case windowlist - /// List all screens as JSON - case screenlist - - /// Human-readable description of each command type - var description: String { - switch self { - case .direction: "Window direction command" - case .screen: "Screen management" - case .action: "Execute predefined window action" - case .keybind: "Execute custom keybind action" - case .list: "List available commands" - case .windowlist: "List all visible windows as JSON" - case .screenlist: "List all screens as JSON" - } - } - } - - /// Parameters parsed from URL query string for targeting specific windows and screens - private struct TargetParams { - var windowID: CGWindowID? - var bundleID: String? - var screenID: CGDirectDisplayID? - } - - // MARK: - Properties - - // MARK: - JSON Helpers - - /// Serializes a response dictionary to a pretty-printed JSON string - private func jsonString(_ dict: [String: Any]) -> String { - guard let data = try? JSONSerialization.data( - withJSONObject: dict, - options: [.prettyPrinted, .sortedKeys] - ) else { - return #"{"success":false,"error":"Failed to serialize response"}"# - } - return String(data: data, encoding: .utf8) - ?? #"{"success":false,"error":"Failed to encode response"}"# - } - - /// Builds a JSON-serializable dictionary for a window - private func windowJSON(_ window: Window) -> [String: Any] { - let app = window.nsRunningApplication - let frame = window.frame - return [ - "windowID": window.cgWindowID, - "bundleID": app?.bundleIdentifier ?? "", - "appName": app?.localizedName ?? "", - "windowTitle": window.title ?? "", - "frame": [ - "x": Int(frame.origin.x), - "y": Int(frame.origin.y), - "width": Int(frame.width), - "height": Int(frame.height) - ] - ] - } - - // MARK: - Public Methods - - /// Handles incoming URL scheme requests and returns a JSON response string. - /// This is the entry point for `loop://` URL scheme events. - /// - Parameter url: The URL to process (e.g., `loop://direction/right?bundleID=com.apple.Safari`) - /// - Returns: A JSON string containing the response - @discardableResult - func handle(_ url: URL) -> String { - log.info("Processing URL: \(url)") - - guard url.scheme?.lowercased() == "loop" else { - return jsonString([ - "success": false, - "error": "Invalid scheme: \(url.scheme ?? "nil"). Required: loop://" - ]) - } - - // Parse query parameters for targeting specific windows and screens - let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) - let queryItems = urlComponents?.queryItems - - let params = TargetParams( - windowID: queryItems?.first(where: { $0.name == "windowID" })?.value.flatMap { UInt32($0) }, - bundleID: queryItems?.first(where: { $0.name == "bundleID" })?.value, - screenID: queryItems?.first(where: { $0.name == "screenID" })?.value.flatMap { UInt32($0) } - ) - - let components = (url.host.map { [$0] } ?? []) + url.pathComponents.filter { $0 != "/" && !$0.isEmpty } - - return execute(components, params: params) - } - - /// Executes a raw command string and returns a JSON response. - /// This is the entry point for the Unix socket (loop-cli). - /// - /// Format: ` [args...] [--window-id ] [--bundle-id ] [--screen-id ]` - /// - /// Examples: - /// - `windowlist` - /// - `direction right` - /// - `direction right --bundle-id com.apple.Safari --screen-id 123` - /// - `action maximize --window-id 1234` - /// - /// - Parameter input: The raw command string - /// - Returns: A JSON string containing the response - func executeRaw(_ input: String) -> String { - log.info("Processing command: \(input)") - - var components: [String] = [] - var params = TargetParams() - - let tokens = input.split(separator: " ", omittingEmptySubsequences: true).map(String.init) - var i = 0 - - while i < tokens.count { - switch tokens[i] { - case "--window-id": - i += 1 - if i < tokens.count { params.windowID = UInt32(tokens[i]) } - case "--bundle-id": - i += 1 - if i < tokens.count { params.bundleID = tokens[i] } - case "--screen-id": - i += 1 - if i < tokens.count { params.screenID = UInt32(tokens[i]) } - default: - components.append(tokens[i]) - } - i += 1 - } - - return execute(components, params: params) - } - - // MARK: - Command Execution - - /// Core command execution. Both `handle()` (URL scheme) and `executeRaw()` (socket) converge here. - /// - Parameters: - /// - components: Path components (e.g., `["direction", "right"]`) - /// - params: Targeting parameters (windowID/bundleID/screenID) - /// - Returns: A JSON string containing the response - private func execute(_ components: [String], params: TargetParams) -> String { - // windowID and bundleID are mutually exclusive - if params.windowID != nil, params.bundleID != nil { - return jsonString([ - "success": false, - "error": "windowID and bundleID are mutually exclusive" - ]) - } - - guard let commandString = components.first, - let command = Command(rawValue: commandString.lowercased()) else { - return jsonString([ - "success": false, - "error": "Unknown command: \(components.first ?? "nil")", - "availableCommands": Command.allCases.map(\.rawValue) - ]) - } - - let parameters = Array(components.dropFirst()) - log.info("\(command.rawValue) \(parameters)") - - let response: [String: Any] - switch command { - case .direction: response = handleDirectionCommand(parameters, params: params) - case .screen: response = handleScreenCommand(parameters, params: params) - case .action: response = handleActionCommand(parameters, params: params) - case .keybind: response = handleKeybindCommand(parameters, params: params) - case .list: response = handleListCommand(parameters) - case .windowlist: response = handleWindowListCommand() - case .screenlist: response = handleScreenListCommand() - } - - return jsonString(response) - } - - /// Handles window direction commands - /// - Parameters: - /// - parameters: Direction parameters - /// - windowID: Optional CGWindowID to target a specific window - /// - Returns: JSON response dictionary - private func handleDirectionCommand(_ parameters: [String], params: TargetParams = .init()) -> [String: Any] { - guard let directionStr = parameters.first?.lowercased() else { - return [ - "success": false, - "command": "direction", - "error": "No direction specified" - ] - } - - // If this is a list command, redirect to the list handler - if directionStr == "list" { - return handleListCommand(["actions"]) - } - - // First check if this is a custom action being called via direction - if directionStr.hasPrefix("custom") || directionStr.hasPrefix("stash") { - return handleActionCommand(parameters, params: params) - } - - let direction: WindowDirection? = WindowDirection.allCases.first { $0.rawValue.lowercased() == directionStr } ?? { - switch directionStr { - case "left": return WindowDirection.leftHalf - case "right": return WindowDirection.rightHalf - case "top": return WindowDirection.topHalf - case "bottom": return WindowDirection.bottomHalf - default: - let withoutHalf = directionStr.replacingOccurrences(of: "half", with: "") - return WindowDirection.allCases.first { $0.rawValue.lowercased() == withoutHalf } - } - }() - - if let direction { - return executeWindowAction(direction, params: params) - } else { - return [ - "success": false, - "command": "direction", - "parameter": directionStr, - "error": "Invalid direction: \(directionStr)" - ] - } - } - - /// Executes a window action for a given direction - /// - Parameters: - /// - direction: The direction to move/resize the window - /// - windowID: Optional CGWindowID to target a specific window - /// - Returns: JSON response dictionary - private func executeWindowAction(_ direction: WindowDirection, params: TargetParams = .init()) -> [String: Any] { - log.info("Executing direction: \(direction.rawValue)") - - guard let window = resolveWindow(params: params) else { - return [ - "success": false, - "command": "direction", - "parameter": direction.rawValue.lowercased(), - "error": windowResolveError(params) - ] - } - - guard let screen = resolveScreen(screenID: params.screenID) else { - return [ - "success": false, - "command": "direction", - "error": "No screen found with ID \(params.screenID!)" - ] - } - - let action = WindowAction(direction) - activateAndResizeWindow(window, action, screen) - return [ - "success": true, - "command": "direction", - "action": direction.rawValue, - "window": windowJSON(window) - ] - } - - /// Handles screen management commands - /// - Parameters: - /// - parameters: Screen command parameters - /// - windowID: Optional CGWindowID to target a specific window - /// - Returns: JSON response dictionary - private func handleScreenCommand(_ parameters: [String], params: TargetParams = .init()) -> [String: Any] { - guard let command = parameters.first?.lowercased() else { - return ["success": false, "command": "screen", "error": "No screen command specified"] - } - - guard let window = resolveWindow(params: params) else { - return [ - "success": false, - "command": "screen", - "parameter": command, - "error": windowResolveError(params) - ] - } - - let direction: WindowDirection = command == "next" ? .nextScreen : .previousScreen - moveWindowToScreen(window, direction) - return [ - "success": true, - "command": "screen", - "action": command, - "window": windowJSON(window) - ] - } - - /// Handles predefined window actions - /// - Parameters: - /// - parameters: Action parameters - /// - windowID: Optional CGWindowID to target a specific window - /// - Returns: JSON response dictionary - private func handleActionCommand(_ parameters: [String], params: TargetParams = .init()) -> [String: Any] { - guard let actionStr = parameters.first?.lowercased() else { - return buildActionsResponse() - } - - // First check for custom actions by name - let customKeybinds = Defaults[.keybinds].filter { $0.direction.isCustomizable && $0.name != nil } - if let customAction = customKeybinds.first(where: { ($0.name?.lowercased() ?? "") == actionStr }) { - guard let window = resolveWindow(params: params) else { - return ["success": false, "command": "action", "parameter": actionStr, "error": windowResolveError(params)] - } - guard let screen = resolveScreen(screenID: params.screenID) else { - return ["success": false, "command": "action", "error": "No screen found with ID \(params.screenID!)"] - } - activateAndResizeWindow(window, customAction, screen) - return [ - "success": true, - "command": "action", - "action": customAction.name ?? actionStr, - "window": windowJSON(window) - ] - } - - if actionStr == "list" { - return buildActionsResponse() - } - - if let direction = WindowDirection.allCases.first(where: { $0.rawValue.lowercased() == actionStr }) { - guard let window = resolveWindow(params: params) else { - return ["success": false, "command": "action", "parameter": actionStr, "error": windowResolveError(params)] - } - guard let screen = resolveScreen(screenID: params.screenID) else { - return ["success": false, "command": "action", "error": "No screen found with ID \(params.screenID!)"] - } - activateAndResizeWindow(window, .init(direction), screen) - return [ - "success": true, - "command": "action", - "action": direction.rawValue, - "window": windowJSON(window) - ] - } - - return [ - "success": false, - "command": "action", - "parameter": actionStr, - "error": "Invalid action: \(actionStr)" - ] - } - - /// Builds a structured response of all available actions grouped by category - /// - Returns: JSON response dictionary with categorized actions - private func buildActionsResponse() -> [String: Any] { - var categories: [[String: Any]] = [] - - let customKeybinds = Defaults[.keybinds].filter { $0.direction == .custom && $0.name?.isEmpty == false } - if !customKeybinds.isEmpty { - categories.append([ - "category": "Custom Actions", - "actions": customKeybinds.compactMap { $0.name?.lowercased() } - ]) - } - - let stashKeybinds = Defaults[.keybinds].filter { $0.direction == .stash && $0.name?.isEmpty == false } - if !stashKeybinds.isEmpty { - categories.append([ - "category": "Stash Actions", - "actions": stashKeybinds.compactMap { $0.name?.lowercased() } - ]) - } - - let builtinCategories: [(String, [WindowDirection])] = [ - ("General Actions", Array(WindowDirection.general.dropFirst(3))), - ("Halves", WindowDirection.halves), - ("Quarters", WindowDirection.quarters), - ("Horizontal Thirds", WindowDirection.horizontalThirds), - ("Vertical Thirds", WindowDirection.verticalThirds), - ("Screen Switching", WindowDirection.screenSwitching), - ("Size Adjustment", WindowDirection.sizeAdjustment), - ("Shrink", WindowDirection.shrink), - ("Grow", WindowDirection.grow), - ("Move", WindowDirection.move), - ("Other", WindowDirection.more) - ] - - for (title, actions) in builtinCategories where !actions.isEmpty { - categories.append([ - "category": title, - "actions": actions.map { $0.rawValue.lowercased() } - ]) - } - - return [ - "success": true, - "command": "list", - "type": "actions", - "categories": categories - ] - } - - /// Handles custom keybind execution - /// - Parameters: - /// - parameters: Keybind parameters - /// - windowID: Optional CGWindowID to target a specific window - /// - Returns: JSON response dictionary - private func handleKeybindCommand(_ parameters: [String], params: TargetParams = .init()) -> [String: Any] { - let keybinds = Defaults[.keybinds] - - guard let keybindName = parameters.first else { - return ["success": false, "command": "keybind", "error": "No keybind specified"] - } - - if keybindName.lowercased() == "list" { - return [ - "success": true, - "command": "list", - "type": "keybinds", - "keybinds": keybinds.compactMap(\.name) - ] - } - - guard let keybind = keybinds.first(where: { $0.name?.lowercased() == keybindName.lowercased() }) else { - return [ - "success": false, - "command": "keybind", - "parameter": keybindName, - "error": "Keybind not found: \(keybindName)", - "availableKeybinds": keybinds.compactMap(\.name) - ] - } - - guard let window = resolveWindow(params: params) else { - return [ - "success": false, - "command": "keybind", - "parameter": keybindName, - "error": windowResolveError(params) - ] - } - - guard let screen = resolveScreen(screenID: params.screenID) else { - return [ - "success": false, - "command": "keybind", - "error": "No screen found with ID \(params.screenID!)" - ] - } - - Task { - _ = try await WindowActionEngine.shared.apply( - keybind, window: window, screen: screen - ) - } - return [ - "success": true, - "command": "keybind", - "keybind": keybind.name ?? keybindName, - "window": windowJSON(window) - ] - } - - /// Handles list commands for viewing available options - /// - Parameter parameters: List parameters - /// - Returns: JSON response dictionary - private func handleListCommand(_ parameters: [String]) -> [String: Any] { - let type = parameters.first?.lowercased() ?? "all" - - switch type { - case "actions": - return buildActionsResponse() - - case "keybinds": - return [ - "success": true, - "command": "list", - "type": "keybinds", - "keybinds": Defaults[.keybinds].compactMap(\.name) - ] - - default: - let actionsResponse = buildActionsResponse() - - return [ - "success": true, - "command": "list", - "type": "all", - "directions": WindowDirection.allCases.map { $0.rawValue.lowercased() }, - "screenCommands": ["next", "previous"], - "actionCategories": actionsResponse["categories"] ?? [], - "keybinds": Defaults[.keybinds].compactMap(\.name), - "commands": Command.allCases.map { [ - "command": $0.rawValue, - "description": $0.description - ] as [String: String] } - ] - } - } - - // MARK: - Window List - - /// Lists all visible windows with their details - /// - Returns: JSON response dictionary - private func handleWindowListCommand() -> [String: Any] { - let visibleWindows = WindowUtility.windowList().filter { win in - guard let app = win.nsRunningApplication else { return false } - return app.bundleIdentifier != Bundle.main.bundleIdentifier - && app.activationPolicy == .regular - && !win.isApplicationHidden - && !win.minimized - } - - return [ - "success": true, - "command": "windowlist", - "windowCount": visibleWindows.count, - "windows": visibleWindows.map { windowJSON($0) } - ] - } - - // MARK: - Screen List - - /// Lists all connected screens with their details - /// - Returns: JSON response dictionary - private func handleScreenListCommand() -> [String: Any] { - let screens = NSScreen.screens - return [ - "success": true, - "command": "screenlist", - "screenCount": screens.count, - "screens": screens.map { screen in - [ - "screenID": screen.displayID ?? 0, - "name": screen.localizedName, - "frame": [ - "x": Int(screen.frame.origin.x), - "y": Int(screen.frame.origin.y), - "width": Int(screen.frame.width), - "height": Int(screen.frame.height) - ], - "isMain": screen == NSScreen.main - ] as [String: Any] - } - ] - } - - // MARK: - Helper Methods - - /// Finds a window by its CGWindowID from the current window list - /// - Parameter windowID: The CGWindowID to search for - /// - Returns: The matching window, or nil if not found - private func findWindowByID(_ windowID: CGWindowID) -> Window? { - WindowUtility.windowList().first { $0.cgWindowID == windowID } - } - - /// Resolves the target window from targeting parameters - /// Priority: windowID > bundleID > frontmost window - /// - Parameter params: Targeting parameters - /// - Returns: The resolved window, or nil if not found - private func resolveWindow(params: TargetParams = .init()) -> Window? { - if let windowID = params.windowID { - return findWindowByID(windowID) - } - if let bundleID = params.bundleID { - return resolveWindowByBundleID(bundleID) - } - return try? WindowUtility.frontmostWindow() - } - - /// Resolves a window by bundle ID, launching the app if needed - /// - Parameter bundleID: The bundle identifier of the app - /// - Returns: The app's frontmost window, or nil if not found - private func resolveWindowByBundleID(_ bundleID: String) -> Window? { - // Check if already running - if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bundleID }) { - app.activate(options: .activateIgnoringOtherApps) - // Brief pause to let activation settle - Thread.sleep(forTimeInterval: 0.1) - return try? Window(pid: app.processIdentifier) - } - - // Not running — try to launch it - guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else { - log.error("No app found for bundle ID: \(bundleID)") - return nil - } - - let config = NSWorkspace.OpenConfiguration() - config.activates = true - - let semaphore = DispatchSemaphore(value: 0) - var launchedApp: NSRunningApplication? - NSWorkspace.shared.openApplication(at: appURL, configuration: config) { app, error in - if let error { - self.log.error("Failed to launch \(bundleID): \(error.localizedDescription)") - } - launchedApp = app - semaphore.signal() - } - _ = semaphore.wait(timeout: .now() + 5) - - guard let app = launchedApp else { return nil } - - // Wait for the app to create a window (up to 3 seconds) - for _ in 0..<30 { - Thread.sleep(forTimeInterval: 0.1) - if let window = try? Window(pid: app.processIdentifier) { - return window - } - } - - log.error("App launched but no window appeared: \(bundleID)") - return nil - } - - /// Resolves a screen by display ID, falling back to the main screen - /// - Parameter screenID: Optional display ID to target a specific screen - /// - Returns: The resolved screen, or nil if the specified ID was not found - private func resolveScreen(screenID: CGDirectDisplayID? = nil) -> NSScreen? { - if let screenID { - return NSScreen.screens.first { $0.displayID == screenID } - } - return NSScreen.main - } - - /// Builds a human-readable error message for window resolution failure - /// - Parameter params: The targeting parameters that were used - /// - Returns: Error message string - private func windowResolveError(_ params: TargetParams) -> String { - if let windowID = params.windowID { - return "No window found with ID \(windowID)" - } - if let bundleID = params.bundleID { - return "Could not find or launch app: \(bundleID)" - } - return "No frontmost window found" - } - - /// Activates and resizes a window - private func activateAndResizeWindow(_ window: Window, _ action: WindowAction, _ screen: NSScreen) { - if let app = window.nsRunningApplication { - log.info("Activating application: \(app.localizedName ?? "unknown")") - app.activate(options: .activateIgnoringOtherApps) - } - - Task { - try? await Task.sleep(for: .seconds(0.1)) - - log.info("Executing resize: \(action) on \(window.title ?? "unknown")") - _ = try await WindowActionEngine.shared.apply( - action, - window: window, - screen: screen - ) - log.info("New window frame: \(window.frame)") - } - } - - /// Moves a window to another screen - private func moveWindowToScreen(_ window: Window, _ direction: WindowDirection) { - if let currentScreen = ScreenUtility.screenContaining(window), - let targetScreen = direction == .nextScreen ? - ScreenUtility.nextScreen(from: currentScreen) : - ScreenUtility.previousScreen(from: currentScreen) { - log.info("Moving window to screen: \(targetScreen.localizedName)") - Task { - _ = try await WindowActionEngine.shared.apply( - .init(direction), - window: window, - screen: targetScreen - ) - } - } else { - log.error("Failed to find target screen") - } - } -} diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 0f72a46a..65683dda 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -4075,6 +4075,12 @@ } } } + }, + "Command Line Interface" : { + "comment" : "Section header shown in settings" + }, + "Command-Line Tool Error" : { + }, "Configure…" : { "comment" : "Button label that opens a view to configure padding settings.", @@ -12220,6 +12226,9 @@ } } } + }, + "Install CLI" : { + }, "Install failed" : { "comment" : "The text that appears when the update fails to install.", @@ -24952,6 +24961,9 @@ } } } + }, + "Re-install CLI" : { + }, "Relaunch to complete" : { "comment" : "A button title that appears when an update is being installed, instructing the user to relaunch the app to complete the installation.", diff --git a/Loop/Scripting/CommandLineToolInstaller.swift b/Loop/Scripting/CommandLineToolInstaller.swift new file mode 100644 index 00000000..da9ff923 --- /dev/null +++ b/Loop/Scripting/CommandLineToolInstaller.swift @@ -0,0 +1,138 @@ +// +// CommandLineToolInstaller.swift +// Loop +// +// Created by Kai Azim on 2026-03-29. +// + +import Darwin +import SwiftUI + +final class CommandLineToolInstaller { + enum Status: Equatable { + case notInstalled + case installedCurrent + case installedStale + case blocked + + var description: LocalizedStringKey { + switch self { + case .notInstalled: + "Installs `/usr/local/bin/loop` to run Loop from the shell." + case .installedCurrent: + "`/usr/local/bin/loop` is installed and points to this version of Loop." + case .installedStale: + "`/usr/local/bin/loop` points to a different or moved version of Loop. Repair it to update the symlink." + case .blocked: + "`/usr/local/bin/loop` is already in use by another file or symlink. Remove it manually before installing Loop CLI." + } + } + } + + private enum ExistingFilesystemEntry { + case missing + case symbolicLink + case other + } + + private let privilegedHelperCoordinator: PrivilegedHelperCoordinator + private let fileManager: FileManager + + init( + privilegedHelperCoordinator: PrivilegedHelperCoordinator = PrivilegedHelperCoordinator(), + fileManager: FileManager = .default + ) { + self.privilegedHelperCoordinator = privilegedHelperCoordinator + self.fileManager = fileManager + } + + func status() throws -> Status { + let symlinkURL = PrivilegedHelperConstants.commandLineToolSymlinkURL + + switch try filesystemEntry(at: symlinkURL) { + case .missing: + return .notInstalled + case .other: + return .blocked + case .symbolicLink: + let rawTarget = try fileManager.destinationOfSymbolicLink(atPath: symlinkURL.path) + guard isLoopManagedCommandLineToolTarget(rawTarget) else { + return .blocked + } + + let currentCLIURL = currentBundledCommandLineToolURL() + let installedCLIURL = resolvedSymbolicLinkDestinationURL( + rawTarget, + relativeTo: symlinkURL.deletingLastPathComponent() + ) + + return installedCLIURL.path == currentCLIURL.path ? .installedCurrent : .installedStale + } + } + + func install() async throws { + try await privilegedHelperCoordinator.withPrivilegedSession( + prompt: "\(Bundle.main.appName) needs administrator permission to install its command-line tool." + ) { session in + try await session.installCommandLineTool() + } + } + + func reinstall() async throws { + try await privilegedHelperCoordinator.withPrivilegedSession( + prompt: "\(Bundle.main.appName) needs administrator permission to install its command-line tool." + ) { session in + try await session.reinstallCommandLineTool() + } + } + + private func currentBundledCommandLineToolURL() -> URL { + LoopSupportPaths.canonical( + Bundle.main.bundleURL + .appendingPathComponent("Contents/MacOS", isDirectory: true) + .appendingPathComponent(PrivilegedHelperConstants.commandLineToolExecutableName, isDirectory: false) + ) + } + + private func resolvedSymbolicLinkDestinationURL(_ targetPath: String, relativeTo baseURL: URL) -> URL { + if targetPath.hasPrefix("/") { + return URL(fileURLWithPath: targetPath).resolvingSymlinksInPath().standardizedFileURL + } + + return URL(fileURLWithPath: targetPath, relativeTo: baseURL) + .resolvingSymlinksInPath() + .standardizedFileURL + } + + private func isLoopManagedCommandLineToolTarget(_ targetPath: String) -> Bool { + resolvedSymbolicLinkDestinationURL( + targetPath, + relativeTo: PrivilegedHelperConstants.commandLineToolInstallDirectoryURL + ) + .path + .hasSuffix(PrivilegedHelperConstants.loopManagedCommandLineToolSuffix) + } + + private func filesystemEntry(at url: URL) throws -> ExistingFilesystemEntry { + var statBuffer = stat() + let result = url.withUnsafeFileSystemRepresentation { path in + guard let path else { return -1 } + return Int(lstat(path, &statBuffer)) + } + + if result == 0 { + let fileType = statBuffer.st_mode & S_IFMT + return fileType == S_IFLNK ? .symbolicLink : .other + } + + if errno == ENOENT { + return .missing + } + + throw NSError( + domain: NSPOSIXErrorDomain, + code: Int(errno), + userInfo: [NSLocalizedDescriptionKey: String(cString: strerror(errno))] + ) + } +} diff --git a/Loop/Scripting/CommandOutputWindowController.swift b/Loop/Scripting/CommandOutputWindowController.swift new file mode 100644 index 00000000..4434f21c --- /dev/null +++ b/Loop/Scripting/CommandOutputWindowController.swift @@ -0,0 +1,153 @@ +// +// CommandOutputWindowController.swift +// Loop +// +// Created by Codex on 2026-03-28. +// + +import AppKit + +@MainActor +final class CommandOutputWindowController: NSWindowController, NSWindowDelegate, NSToolbarDelegate { + private enum ToolbarIdentifier { + static let toolbar = NSToolbar.Identifier("LoopCommandOutputToolbar") + static let copy = NSToolbarItem.Identifier("LoopCommandOutputCopy") + } + + private let output: String + private let onClose: () -> Void + + init(title: String, content: String, onClose: @escaping () -> Void) { + output = content + self.onClose = onClose + + let scrollView = NSScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.drawsBackground = false + + let textView = NSTextView(frame: .zero) + textView.isEditable = false + textView.isSelectable = true + textView.isRichText = false + textView.allowsUndo = false + textView.usesFindBar = true + textView.drawsBackground = false + textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + textView.string = content + textView.textContainerInset = NSSize(width: 12, height: 12) + textView.minSize = .zero + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.textContainer?.containerSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.heightTracksTextView = false + + scrollView.documentView = textView + + let contentView = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: 720, height: 560)) + contentView.material = .popover + contentView.blendingMode = .behindWindow + contentView.state = .active + contentView.addSubview(scrollView) + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: contentView.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 720, height: 560), + styleMask: [.titled, .closable, .resizable, .miniaturizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.title = title + window.minSize = NSSize(width: 480, height: 320) + window.isReleasedWhenClosed = false + window.contentView = contentView + + super.init(window: window) + + self.window?.delegate = self + self.window?.toolbar = makeToolbar() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + showWindow(self) + window?.makeKeyAndOrderFront(nil) + window?.orderFrontRegardless() + + if #available(macOS 14.0, *) { + NSApp.activate() + } else { + NSApp.activate(ignoringOtherApps: true) + } + } + + func windowWillClose(_: Notification) { + onClose() + } + + @objc private func copyOutput(_: Any?) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(output, forType: .string) + } + + private func makeToolbar() -> NSToolbar { + let toolbar = NSToolbar(identifier: ToolbarIdentifier.toolbar) + toolbar.delegate = self + toolbar.allowsUserCustomization = false + toolbar.autosavesConfiguration = false + toolbar.displayMode = .iconOnly + return toolbar + } + + func toolbarAllowedItemIdentifiers(_: NSToolbar) -> [NSToolbarItem.Identifier] { + [ToolbarIdentifier.copy] + } + + func toolbarDefaultItemIdentifiers(_: NSToolbar) -> [NSToolbarItem.Identifier] { + [ToolbarIdentifier.copy] + } + + func toolbar( + _: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar _: Bool + ) -> NSToolbarItem? { + guard itemIdentifier == ToolbarIdentifier.copy else { + return nil + } + + let item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.label = "Copy" + item.paletteLabel = "Copy" + item.toolTip = "Copy output to the clipboard" + item.target = self + item.action = #selector(copyOutput(_:)) + + if let image = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: "Copy") { + item.image = image + } + + return item + } +} diff --git a/Loop/Scripting/CommandOutputWindowManager.swift b/Loop/Scripting/CommandOutputWindowManager.swift new file mode 100644 index 00000000..e41a4c4d --- /dev/null +++ b/Loop/Scripting/CommandOutputWindowManager.swift @@ -0,0 +1,56 @@ +// +// CommandOutputWindowManager.swift +// Loop +// +// Created by Codex on 2026-03-28. +// + +import AppKit + +@MainActor +final class CommandOutputWindowManager { + static let shared = CommandOutputWindowManager() + + private var controllers: [UUID: CommandOutputWindowController] = [:] + private var cascadePoint: NSPoint? + + private init() {} + + func show(title: String, content: String) { + let identifier = UUID() + let controller = CommandOutputWindowController( + title: title, + content: content + ) { [weak self] in + self?.removeController(for: identifier) + } + + controllers[identifier] = controller + + if let window = controller.window { + let origin = cascadePoint ?? initialCascadePoint() + cascadePoint = window.cascadeTopLeft(from: origin) + } + + controller.show() + } + + private func removeController(for identifier: UUID) { + controllers.removeValue(forKey: identifier) + + if controllers.isEmpty { + cascadePoint = nil + } + } + + private func initialCascadePoint() -> NSPoint { + if let screen = NSScreen.main ?? NSScreen.screens.first { + return NSPoint( + x: screen.visibleFrame.minX + 80, + y: screen.visibleFrame.maxY - 80 + ) + } + + return NSPoint(x: 120, y: 800) + } +} diff --git a/Loop/Scripting/LoopCommandHandler.swift b/Loop/Scripting/LoopCommandHandler.swift new file mode 100644 index 00000000..0c23a77f --- /dev/null +++ b/Loop/Scripting/LoopCommandHandler.swift @@ -0,0 +1,1794 @@ +// +// LoopCommandHandler.swift +// Loop +// +// Created by Kami on 06/03/2025. +// + +/* + Loop Automation API + =================== + + Public URL commands: + - loop://list/windows + - loop://list/screens + - loop://list/actions + - loop://list/actions/directions + - loop://list/actions/keybinds + - loop://direction/ + - loop://keybind/ + + Public CLI commands: + - loop-cli list windows + - loop-cli list screens + - loop-cli list actions [--directions-only | --keybinds-only] + - loop-cli exec --direction + - loop-cli exec --keybind + - loop-cli exec --id + + Query parameters / target flags: + - ?windowID= / --window-id + - ?bundleID= / --bundle-id + - ?screenID= / --screen-id + */ + +import AppKit +import CryptoKit +import Defaults +import Foundation +import Scribe + +/// Handles Loop automation commands for both the URL scheme and `loop-cli`. +@Loggable +final class LoopCommandHandler { + // MARK: - Types + + enum InvocationSource: Sendable { + case urlScheme + case cli + } + + enum CommandKind: Sendable { + case read + case write + } + + struct CommandExecutionResult: Sendable { + let source: InvocationSource + let kind: CommandKind + let title: String + let jsonResponse: String + let isSuccess: Bool + let errorMessage: String? + + @MainActor + func presentIfNeeded() { + guard source == .urlScheme else { + return + } + + switch kind { + case .read: + CommandOutputWindowManager.shared.show( + title: title, + content: jsonResponse + ) + case .write: + guard !isSuccess else { + return + } + + let alert = NSAlert() + alert.messageText = "Loop Command Failed" + alert.informativeText = errorMessage ?? jsonResponse + alert.alertStyle = .warning + + let button = alert.addButton(withTitle: "OK") + if #available(macOS 26.0, *) { + button.tintProminence = .primary + } + + if #available(macOS 14.0, *) { + NSApp.activate() + } else { + NSApp.activate(ignoringOtherApps: true) + } + + if let window = NSApp.keyWindow ?? NSApp.mainWindow { + alert.beginSheetModal(for: window) + } else { + alert.runModal() + } + } + } + } + + private enum ListActionFilter { + case all + case directionsOnly + case keybindsOnly + } + + private enum ResponseResult { + case success(Value) + case failure([String: Any]) + } + + private enum MessageResult { + case success(Value) + case failure(String) + } + + /// Parameters parsed from URL query string / CLI flags for targeting specific windows and screens. + private struct TargetParams { + var windowID: CGWindowID? + var bundleID: String? + var screenID: CGDirectDisplayID? + } + + private struct DirectionActionDescriptor { + let category: String + let direction: WindowDirection + let id: UUID + let slug: String + let name: String + + var idString: String { + id.uuidString.lowercased() + } + + var urlPath: String { + "direction/\(slug)" + } + } + + private struct KeybindActionDescriptor { + let action: WindowAction + let id: UUID + let slug: String + let name: String + + var idString: String { + id.uuidString.lowercased() + } + + var urlPath: String { + "keybind/\(slug)" + } + } + + private enum ExecutableActionDescriptor { + case direction(DirectionActionDescriptor) + case keybind(KeybindActionDescriptor) + + var idString: String { + switch self { + case let .direction(descriptor): + descriptor.idString + case let .keybind(descriptor): + descriptor.idString + } + } + + var slug: String { + switch self { + case let .direction(descriptor): + descriptor.slug + case let .keybind(descriptor): + descriptor.slug + } + } + + var name: String { + switch self { + case let .direction(descriptor): + descriptor.name + case let .keybind(descriptor): + descriptor.name + } + } + + var kind: String { + switch self { + case .direction: + "direction" + case .keybind: + "keybind" + } + } + + var urlPath: String { + switch self { + case let .direction(descriptor): + descriptor.urlPath + case let .keybind(descriptor): + descriptor.urlPath + } + } + + var windowAction: WindowAction { + switch self { + case let .direction(descriptor): + WindowAction(descriptor.direction) + case let .keybind(descriptor): + descriptor.action + } + } + } + + // MARK: - Constants + + private static let directionCategories: [(String, [WindowDirection])] = [ + ("General Actions", WindowDirection.general), + ("Halves", WindowDirection.halves), + ("Quarters", WindowDirection.quarters), + ("Horizontal Thirds", WindowDirection.horizontalThirds), + ("Horizontal Fourths", WindowDirection.horizontalFourths), + ("Vertical Thirds", WindowDirection.verticalThirds), + ("Screen Switching", WindowDirection.screenSwitching), + ("Size Adjustment", WindowDirection.sizeAdjustment), + ("Shrink", WindowDirection.shrink), + ("Grow", WindowDirection.grow), + ("Move", WindowDirection.move), + ("Focus", WindowDirection.focus), + ("Other", [.initialFrame, .undo]) + ] + + private static let directionIDNamespace = UUID(uuidString: "6c6e0e9d-2da7-4b3d-bf5b-4e868e4b6d7b")! + + // MARK: - Public Methods + + /// Handles incoming `loop://` requests and returns command metadata. + @discardableResult + func handle(_ url: URL) -> CommandExecutionResult { + log.info("Processing URL: \(url)") + + guard url.scheme?.lowercased() == "loop" else { + return makeExecutionResult( + source: .urlScheme, + kind: .write, + components: [], + response: [ + "success": false, + "error": "Invalid scheme: \(url.scheme ?? "nil"). Required: loop://" + ] + ) + } + + let urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) + let queryItems = urlComponents?.queryItems + + let params = TargetParams( + windowID: queryItems?.first(where: { $0.name == "windowID" })?.value.flatMap(UInt32.init), + bundleID: queryItems?.first(where: { $0.name == "bundleID" })?.value, + screenID: queryItems?.first(where: { $0.name == "screenID" })?.value.flatMap(UInt32.init) + ) + + let components = (url.host.map { [$0] } ?? []) + url.pathComponents.filter { $0 != "/" && !$0.isEmpty } + return execute(components, params: params, source: .urlScheme) + } + + /// Executes a raw command string and returns command metadata. + func executeRaw(_ input: String) -> CommandExecutionResult { + log.info("Processing command: \(input)") + + switch tokenizeCommandLine(input) { + case let .failure(error): + return makeExecutionResult( + source: .cli, + kind: .write, + components: [], + response: [ + "success": false, + "command": "exec", + "error": error + ] + ) + + case let .success(tokens): + var components: [String] = [] + var params = TargetParams() + var index = 0 + + while index < tokens.count { + let token = tokens[index] + + switch token { + case "--window-id": + guard index + 1 < tokens.count else { + return makeExecutionResult( + source: .cli, + kind: .write, + components: components, + response: [ + "success": false, + "command": components.first?.lowercased() ?? "exec", + "error": "--window-id requires a value" + ] + ) + } + + params.windowID = UInt32(tokens[index + 1]) + index += 2 + + case "--bundle-id": + guard index + 1 < tokens.count else { + return makeExecutionResult( + source: .cli, + kind: .write, + components: components, + response: [ + "success": false, + "command": components.first?.lowercased() ?? "exec", + "error": "--bundle-id requires a value" + ] + ) + } + + params.bundleID = tokens[index + 1] + index += 2 + + case "--screen-id": + guard index + 1 < tokens.count else { + return makeExecutionResult( + source: .cli, + kind: .write, + components: components, + response: [ + "success": false, + "command": components.first?.lowercased() ?? "exec", + "error": "--screen-id requires a value" + ] + ) + } + + params.screenID = UInt32(tokens[index + 1]) + index += 2 + + default: + components.append(token) + index += 1 + } + } + + return execute(components, params: params, source: .cli) + } + } + + // MARK: - Command Execution + + private func execute( + _ components: [String], + params: TargetParams, + source: InvocationSource + ) -> CommandExecutionResult { + if params.windowID != nil, params.bundleID != nil { + return makeExecutionResult( + source: source, + kind: .write, + components: components, + response: [ + "success": false, + "error": "windowID and bundleID are mutually exclusive" + ] + ) + } + + guard let commandString = components.first?.lowercased() else { + return makeExecutionResult( + source: source, + kind: .write, + components: components, + response: unknownCommandResponse(nil, source: source) + ) + } + + let parameters = Array(components.dropFirst()) + + if let legacy = removedLegacyCommandResponse( + command: commandString, + parameters: parameters, + source: source + ) { + return makeExecutionResult( + source: source, + kind: legacy.kind, + components: components, + response: legacy.response + ) + } + + switch commandString { + case "list": + return makeExecutionResult( + source: source, + kind: .read, + components: components, + response: handleListCommand(parameters, source: source) + ) + + case "direction" where source == .urlScheme: + return makeExecutionResult( + source: source, + kind: .write, + components: components, + response: handleDirectionCommand(parameters, params: params) + ) + + case "keybind" where source == .urlScheme: + return makeExecutionResult( + source: source, + kind: .write, + components: components, + response: handleKeybindCommand(parameters, params: params) + ) + + case "exec" where source == .cli: + return makeExecutionResult( + source: source, + kind: .write, + components: components, + response: handleExecCommand(parameters, params: params) + ) + + default: + return makeExecutionResult( + source: source, + kind: .write, + components: components, + response: unknownCommandResponse(commandString, source: source) + ) + } + } + + // MARK: - List Commands + + private func handleListCommand(_ parameters: [String], source: InvocationSource) -> [String: Any] { + guard let type = parameters.first?.lowercased() else { + return invalidListRootResponse(source: source) + } + + switch type { + case "windows": + guard parameters.count == 1 else { + return invalidListRouteResponse(parameters, source: source) + } + return buildWindowListResponse() + + case "screens": + guard parameters.count == 1 else { + return invalidListRouteResponse(parameters, source: source) + } + return buildScreenListResponse() + + case "actions": + switch parseListActionFilter(parameters, source: source) { + case let .success(filter): + return buildActionsResponse(filter: filter) + case let .failure(error): + return error + } + + case "all": + return removedListAllResponse(source: source) + + case "keybinds": + return removedListKeybindsResponse(source: source) + + default: + return invalidListRouteResponse(parameters, source: source) + } + } + + private func parseListActionFilter( + _ parameters: [String], + source: InvocationSource + ) -> ResponseResult { + let tail = Array(parameters.dropFirst()) + + switch source { + case .urlScheme: + guard tail.count <= 1 else { + return .failure(invalidListRouteResponse(parameters, source: source)) + } + + guard let subtype = tail.first?.lowercased() else { + return .success(.all) + } + + switch subtype { + case "directions": + return .success(.directionsOnly) + case "keybinds": + return .success(.keybindsOnly) + default: + return .failure(invalidListRouteResponse(parameters, source: source)) + } + + case .cli: + let flags = tail.filter { $0.hasPrefix("--") } + let positional = tail.filter { !$0.hasPrefix("--") } + + guard positional.isEmpty else { + return .failure([ + "success": false, + "command": "list", + "type": "actions", + "error": "CLI action filters must use --directions-only or --keybinds-only" + ]) + } + + let allowedFlags: Set = ["--directions-only", "--keybinds-only"] + let unknownFlags = flags.filter { !allowedFlags.contains($0.lowercased()) } + + guard unknownFlags.isEmpty else { + return .failure([ + "success": false, + "command": "list", + "type": "actions", + "error": "Unknown list actions flag: \(unknownFlags[0])" + ]) + } + + let directionsOnly = flags.contains { $0.lowercased() == "--directions-only" } + let keybindsOnly = flags.contains { $0.lowercased() == "--keybinds-only" } + + if directionsOnly, keybindsOnly { + return .failure([ + "success": false, + "command": "list", + "type": "actions", + "error": "--directions-only and --keybinds-only are mutually exclusive" + ]) + } + + if directionsOnly { + return .success(.directionsOnly) + } + + if keybindsOnly { + return .success(.keybindsOnly) + } + + return .success(.all) + } + } + + private func buildActionsResponse(filter: ListActionFilter) -> [String: Any] { + let directionActions = buildDirectionActionCategoriesJSON() + let keybindActions = keybindActionDescriptors().map(keybindActionJSON) + + var response: [String: Any] = [ + "success": true, + "command": "list", + "type": "actions" + ] + + switch filter { + case .all: + response["directionActions"] = directionActions + response["keybindActions"] = keybindActions + case .directionsOnly: + response["subtype"] = "directions" + response["directionActions"] = directionActions + case .keybindsOnly: + response["subtype"] = "keybinds" + response["keybindActions"] = keybindActions + } + + return response + } + + private func buildWindowListResponse() -> [String: Any] { + let visibleWindows = WindowUtility.windowList().filter { window in + guard let app = window.nsRunningApplication else { + return false + } + + return app.bundleIdentifier != Bundle.main.bundleIdentifier + && app.activationPolicy == .regular + && !window.isApplicationHidden + && !window.minimized + } + + return [ + "success": true, + "command": "list", + "type": "windows", + "windowCount": visibleWindows.count, + "windows": visibleWindows.map(windowJSON) + ] + } + + private func buildScreenListResponse() -> [String: Any] { + let screens = NSScreen.screens + return [ + "success": true, + "command": "list", + "type": "screens", + "screenCount": screens.count, + "screens": screens.map { screen in + [ + "screenID": screen.displayID ?? 0, + "name": screen.localizedName, + "frame": [ + "x": Int(screen.frame.origin.x), + "y": Int(screen.frame.origin.y), + "width": Int(screen.frame.width), + "height": Int(screen.frame.height) + ], + "isMain": screen == NSScreen.main + ] as [String: Any] + } + ] + } + + // MARK: - Write Commands + + private func handleDirectionCommand(_ parameters: [String], params: TargetParams) -> [String: Any] { + guard parameters.count == 1 else { + return [ + "success": false, + "command": "direction", + "error": "Direction execution requires exactly one slug", + "replacement": urlCommandString(["list", "actions", "directions"]) + ] + } + + let token = parameters[0] + if let descriptor = directionActionDescriptor(slug: token) { + return executeAction(.direction(descriptor), params: params, command: "direction") + } + + if let descriptor = legacyDirectionDescriptor(for: token) { + return migrationErrorResponse( + command: "direction", + error: "Use the canonical direction slug", + replacement: urlCommandString(["direction", descriptor.slug]) + ) + } + + return [ + "success": false, + "command": "direction", + "error": "Unknown direction slug: \(token)", + "replacement": urlCommandString(["list", "actions", "directions"]) + ] + } + + private func handleKeybindCommand(_ parameters: [String], params: TargetParams) -> [String: Any] { + guard parameters.count == 1 else { + return [ + "success": false, + "command": "keybind", + "error": "Keybind execution requires exactly one slug", + "replacement": urlCommandString(["list", "actions", "keybinds"]) + ] + } + + let token = parameters[0] + if let descriptor = keybindActionDescriptor(slug: token) { + return executeAction(.keybind(descriptor), params: params, command: "keybind") + } + + let legacyMatches = legacyKeybindDescriptors(for: token) + if legacyMatches.count == 1, let descriptor = legacyMatches.first { + return migrationErrorResponse( + command: "keybind", + error: "Use the canonical keybind slug", + replacement: urlCommandString(["keybind", descriptor.slug]) + ) + } + + if legacyMatches.count > 1 { + return [ + "success": false, + "command": "keybind", + "error": "Multiple keybind actions match \(token). Use list/actions/keybinds to find the canonical slug." + ] + } + + return [ + "success": false, + "command": "keybind", + "error": "Unknown keybind slug: \(token)", + "replacement": urlCommandString(["list", "actions", "keybinds"]) + ] + } + + private func handleExecCommand(_ parameters: [String], params: TargetParams) -> [String: Any] { + if parameters.isEmpty { + return execUsageError("No exec target specified") + } + + var directionSlug: String? + var keybindName: String? + var identifierValue: String? + var index = 0 + + while index < parameters.count { + let token = parameters[index] + switch token.lowercased() { + case "--direction": + guard index + 1 < parameters.count else { + return execUsageError("--direction requires a value") + } + directionSlug = parameters[index + 1] + index += 2 + + case "--keybind": + guard index + 1 < parameters.count else { + return execUsageError("--keybind requires a value") + } + keybindName = parameters[index + 1] + index += 2 + + case "--id": + guard index + 1 < parameters.count else { + return execUsageError("--id requires a value") + } + identifierValue = parameters[index + 1] + index += 2 + + default: + if token.hasPrefix("--") { + return execUsageError("Unknown exec flag: \(token)") + } + + return migrationErrorResponse( + command: "exec", + error: "Positional exec targets have been removed", + availableRoutes: execUsageExamples() + ) + } + } + + let selectorCount = [directionSlug, keybindName, identifierValue].compactMap(\.self).count + guard selectorCount == 1 else { + return execUsageError("exec requires exactly one of --direction, --keybind, or --id") + } + + if let directionSlug { + if let descriptor = directionActionDescriptor(slug: directionSlug) { + return executeAction(.direction(descriptor), params: params, command: "exec") + } + + if let descriptor = legacyDirectionDescriptor(for: directionSlug) { + return migrationErrorResponse( + command: "exec", + error: "Use the canonical direction slug", + replacement: cliExecDirectionCommand(descriptor.slug) + ) + } + + return [ + "success": false, + "command": "exec", + "error": "Unknown direction slug: \(directionSlug)", + "replacement": cliCommandString(["list", "actions", "--directions-only"]) + ] + } + + if let keybindName { + let matches = keybindActionDescriptors(matchingName: keybindName) + + if let descriptor = matches.only { + return executeAction(.keybind(descriptor), params: params, command: "exec") + } + + if matches.count > 1 { + return [ + "success": false, + "command": "exec", + "error": "Multiple keybind actions share the name \"\(keybindName)\". Use --id instead.", + "matchingIDs": matches.map(\.idString) + ] + } + + if let descriptor = keybindActionDescriptor(slug: keybindName) { + return migrationErrorResponse( + command: "exec", + error: "--keybind expects the display name, not the slug", + replacement: cliExecKeybindCommand(descriptor.name) + ) + } + + return [ + "success": false, + "command": "exec", + "error": "Unknown keybind name: \(keybindName)", + "replacement": cliCommandString(["list", "actions", "--keybinds-only"]) + ] + } + + guard let identifierValue else { + return execUsageError("No exec target specified") + } + + guard let identifier = UUID(uuidString: identifierValue) else { + return [ + "success": false, + "command": "exec", + "error": "Invalid UUID: \(identifierValue)" + ] + } + + guard let descriptor = executableActionDescriptor(id: identifier) else { + return [ + "success": false, + "command": "exec", + "error": "Unknown action ID: \(identifierValue)", + "replacement": cliCommandString(["list", "actions"]) + ] + } + + return executeAction(descriptor, params: params, command: "exec") + } + + private func executeAction( + _ descriptor: ExecutableActionDescriptor, + params: TargetParams, + command: String + ) -> [String: Any] { + let action = descriptor.windowAction + let resolvedWindow = resolveWindow(params: params) + let resolvedAction = resolveActionForCommandExecution(action, window: resolvedWindow) + + if resolvedAction.direction.isNoOp || resolvedAction.direction == .cycle { + return [ + "success": false, + "command": command, + "id": descriptor.idString, + "name": descriptor.name, + "kind": descriptor.kind, + "error": "Action is not executable: \(descriptor.name)" + ] + } + + if !resolvedAction.direction.willFocusWindow, resolvedWindow == nil { + return [ + "success": false, + "command": command, + "id": descriptor.idString, + "name": descriptor.name, + "kind": descriptor.kind, + "error": windowResolveError(params) + ] + } + + let targetScreen: NSScreen + switch resolveTargetScreen(for: resolvedAction, window: resolvedWindow, params: params) { + case let .success(screen): + targetScreen = screen + case let .failure(error): + return [ + "success": false, + "command": command, + "id": descriptor.idString, + "name": descriptor.name, + "kind": descriptor.kind, + "error": error + ] + } + + dispatchAction(resolvedAction, on: resolvedWindow, screen: targetScreen) + + var response: [String: Any] = [ + "success": true, + "command": command, + "id": descriptor.idString, + "kind": descriptor.kind, + "name": descriptor.name, + "slug": descriptor.slug + ] + + if command != "exec" { + response["urlPath"] = descriptor.urlPath + } + + if let window = resolvedWindow { + response["window"] = windowJSON(window) + } + + return response + } + + // MARK: - Action Catalog + + private func buildDirectionActionCategoriesJSON() -> [[String: Any]] { + Self.directionCategories.map { category, directions in + [ + "category": category, + "actions": directions.map { direction in + directionActionJSON(directionActionDescriptor(for: direction)) + } + ] + } + } + + private func directionActionJSON(_ descriptor: DirectionActionDescriptor) -> [String: Any] { + [ + "id": descriptor.idString, + "kind": "direction", + "name": descriptor.name, + "slug": descriptor.slug, + "urlPath": descriptor.urlPath + ] + } + + private func keybindActionJSON(_ descriptor: KeybindActionDescriptor) -> [String: Any] { + [ + "id": descriptor.idString, + "kind": "keybind", + "name": descriptor.name, + "slug": descriptor.slug, + "urlPath": descriptor.urlPath + ] + } + + private func allDirectionActionDescriptors() -> [DirectionActionDescriptor] { + Self.directionCategories.flatMap { category, directions in + directions.map { direction in + DirectionActionDescriptor( + category: category, + direction: direction, + id: deterministicDirectionID(for: direction), + slug: canonicalDirectionSlug(for: direction), + name: direction.name + ) + } + } + } + + private func directionActionDescriptor(for direction: WindowDirection) -> DirectionActionDescriptor { + let category = Self.directionCategories.first { $0.1.contains(direction) }?.0 ?? "Actions" + return DirectionActionDescriptor( + category: category, + direction: direction, + id: deterministicDirectionID(for: direction), + slug: canonicalDirectionSlug(for: direction), + name: direction.name + ) + } + + private func directionActionDescriptor(slug: String) -> DirectionActionDescriptor? { + allDirectionActionDescriptors().first { $0.slug == slug.lowercased() } + } + + private func directionActionDescriptor(id: UUID) -> DirectionActionDescriptor? { + allDirectionActionDescriptors().first { $0.id == id } + } + + private func keybindActionDescriptors() -> [KeybindActionDescriptor] { + let candidates: [(WindowAction, String, String)] = Defaults[.keybinds].compactMap { action in + guard + !action.keybind.isEmpty, + isExecutableKeybindAction(action) + else { + return nil + } + + let displayName = action.getName().trimmingCharacters(in: .whitespacesAndNewlines) + guard !displayName.isEmpty else { + return nil + } + + return (action, displayName, slugifyDisplayString(displayName)) + } + + let groupedByBaseSlug = Dictionary(grouping: candidates, by: \.2) + + return candidates.map { action, name, baseSlug in + let finalSlug: String + if groupedByBaseSlug[baseSlug, default: []].count > 1 { + finalSlug = "\(baseSlug)_\(shortIdentifier(for: action.id))" + } else { + finalSlug = baseSlug + } + + return KeybindActionDescriptor( + action: action, + id: action.id, + slug: finalSlug, + name: name + ) + } + } + + private func keybindActionDescriptor(slug: String) -> KeybindActionDescriptor? { + keybindActionDescriptors().first { $0.slug == slug.lowercased() } + } + + private func keybindActionDescriptors(matchingName name: String) -> [KeybindActionDescriptor] { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + return keybindActionDescriptors().filter { + $0.name.caseInsensitiveCompare(trimmedName) == .orderedSame + } + } + + private func keybindActionDescriptor(id: UUID) -> KeybindActionDescriptor? { + keybindActionDescriptors().first { $0.id == id } + } + + private func executableActionDescriptor(id: UUID) -> ExecutableActionDescriptor? { + if let descriptor = directionActionDescriptor(id: id) { + return .direction(descriptor) + } + + if let descriptor = keybindActionDescriptor(id: id) { + return .keybind(descriptor) + } + + return nil + } + + private func legacyDirectionDescriptor(for token: String) -> DirectionActionDescriptor? { + let lowered = token.lowercased() + + switch lowered { + case "next": + return directionActionDescriptor(for: .nextScreen) + case "previous": + return directionActionDescriptor(for: .previousScreen) + default: + return allDirectionActionDescriptors().first { descriptor in + descriptor.slug == lowered + || slugifyDisplayString(descriptor.direction.rawValue, treatCamelCaseAsWords: true) == lowered + || descriptor.direction.rawValue.lowercased() == lowered + } + } + } + + private func legacyKeybindDescriptors(for token: String) -> [KeybindActionDescriptor] { + let lowered = token.lowercased() + return keybindActionDescriptors().filter { + $0.slug == lowered || $0.name.caseInsensitiveCompare(token) == .orderedSame + } + } + + // MARK: - Removed Public Commands + + private func removedLegacyCommandResponse( + command: String, + parameters: [String], + source: InvocationSource + ) -> (kind: CommandKind, response: [String: Any])? { + switch command { + case "windowlist": + return ( + .read, + migrationErrorResponse( + command: "windowlist", + error: "windowlist has been removed", + replacement: listRouteReplacement(["windows"], source: source) + ) + ) + + case "screenlist": + return ( + .read, + migrationErrorResponse( + command: "screenlist", + error: "screenlist has been removed", + replacement: listRouteReplacement(["screens"], source: source) + ) + ) + + case "execute": + return ( + .write, + removedExecuteResponse(parameters: parameters, source: source) + ) + + case "direction": + guard source == .cli else { + return nil + } + + return ( + parameters.first?.lowercased() == "list" ? .read : .write, + removedDirectionCLIResponse(parameters: parameters) + ) + + case "keybind": + guard source == .cli else { + return nil + } + + return ( + parameters.first?.lowercased() == "list" ? .read : .write, + removedKeybindCLIResponse(parameters: parameters) + ) + + case "screen": + return ( + .write, + removedScreenResponse(parameters: parameters, source: source) + ) + + case "action": + return ( + parameters.first?.lowercased() == "list" ? .read : .write, + removedActionResponse(parameters: parameters, source: source) + ) + + default: + return nil + } + } + + private func removedExecuteResponse(parameters: [String], source: InvocationSource) -> [String: Any] { + if parameters.count == 1, let identifier = UUID(uuidString: parameters[0]), let descriptor = executableActionDescriptor(id: identifier) { + return migrationErrorResponse( + command: "execute", + error: "execute has been removed", + replacement: replacementCommand(for: descriptor, source: source) + ) + } + + if parameters.count == 2, parameters[0].lowercased() == "direction", let descriptor = legacyDirectionDescriptor(for: parameters[1]) { + return migrationErrorResponse( + command: "execute", + error: "execute has been removed", + replacement: replacementCommand(for: .direction(descriptor), source: source) + ) + } + + if parameters.count == 2, parameters[0].lowercased() == "keybind", let descriptor = keybindActionDescriptor(slug: parameters[1]) ?? legacyKeybindDescriptors(for: parameters[1]).only { + return migrationErrorResponse( + command: "execute", + error: "execute has been removed", + replacement: replacementCommand(for: .keybind(descriptor), source: source) + ) + } + + return migrationErrorResponse( + command: "execute", + error: "execute has been removed", + availableRoutes: source == .urlScheme ? publicURLWriteRoutes() : execUsageExamples() + ) + } + + private func removedDirectionCLIResponse(parameters: [String]) -> [String: Any] { + if parameters.isEmpty || parameters.first?.lowercased() == "list" { + return migrationErrorResponse( + command: "direction", + error: "direction has been removed from loop-cli", + replacement: cliCommandString(["list", "actions", "--directions-only"]) + ) + } + + if let descriptor = legacyDirectionDescriptor(for: parameters[0]) { + return migrationErrorResponse( + command: "direction", + error: "direction has been removed from loop-cli", + replacement: cliExecDirectionCommand(descriptor.slug) + ) + } + + return migrationErrorResponse( + command: "direction", + error: "direction has been removed from loop-cli", + replacement: cliCommandString(["list", "actions", "--directions-only"]) + ) + } + + private func removedKeybindCLIResponse(parameters: [String]) -> [String: Any] { + if parameters.isEmpty || parameters.first?.lowercased() == "list" { + return migrationErrorResponse( + command: "keybind", + error: "keybind has been removed from loop-cli", + replacement: cliCommandString(["list", "actions", "--keybinds-only"]) + ) + } + + let matches = legacyKeybindDescriptors(for: parameters[0]) + if let descriptor = matches.only { + return migrationErrorResponse( + command: "keybind", + error: "keybind has been removed from loop-cli", + replacement: cliExecKeybindCommand(descriptor.name) + ) + } + + return migrationErrorResponse( + command: "keybind", + error: "keybind has been removed from loop-cli", + replacement: cliCommandString(["list", "actions", "--keybinds-only"]) + ) + } + + private func removedScreenResponse(parameters: [String], source: InvocationSource) -> [String: Any] { + if let parameter = parameters.first, let descriptor = legacyDirectionDescriptor(for: parameter) { + return migrationErrorResponse( + command: "screen", + error: "screen has been removed", + replacement: replacementCommand(for: .direction(descriptor), source: source) + ) + } + + return migrationErrorResponse( + command: "screen", + error: "screen has been removed", + replacement: source == .urlScheme + ? urlCommandString(["list", "actions", "directions"]) + : cliCommandString(["list", "actions", "--directions-only"]) + ) + } + + private func removedActionResponse(parameters: [String], source: InvocationSource) -> [String: Any] { + if parameters.isEmpty || parameters.first?.lowercased() == "list" { + return migrationErrorResponse( + command: "action", + error: "action has been removed", + replacement: listRouteReplacement(["actions"], source: source) + ) + } + + if let descriptor = legacyDirectionDescriptor(for: parameters[0]) { + return migrationErrorResponse( + command: "action", + error: "action has been removed", + replacement: replacementCommand(for: .direction(descriptor), source: source) + ) + } + + let keybindMatches = legacyKeybindDescriptors(for: parameters[0]) + if let descriptor = keybindMatches.only { + return migrationErrorResponse( + command: "action", + error: "action has been removed", + replacement: replacementCommand(for: .keybind(descriptor), source: source) + ) + } + + return migrationErrorResponse( + command: "action", + error: "action has been removed", + replacement: listRouteReplacement(["actions"], source: source) + ) + } + + // MARK: - Response Helpers + + private func publicCommandNames(for source: InvocationSource) -> [String] { + switch source { + case .urlScheme: + ["list", "direction", "keybind"] + case .cli: + ["list", "exec"] + } + } + + private func publicListRoutes(for source: InvocationSource) -> [String] { + [ + listRouteReplacement(["windows"], source: source), + listRouteReplacement(["screens"], source: source), + listRouteReplacement(["actions"], source: source), + source == .urlScheme + ? listRouteReplacement(["actions", "directions"], source: source) + : cliCommandString(["list", "actions", "--directions-only"]), + source == .urlScheme + ? listRouteReplacement(["actions", "keybinds"], source: source) + : cliCommandString(["list", "actions", "--keybinds-only"]) + ] + } + + private func publicURLWriteRoutes() -> [String] { + [ + urlCommandString(["direction", "right"]), + urlCommandString(["direction", "maximize"]), + urlCommandString(["direction", "next_screen"]), + urlCommandString(["keybind", "my_layout"]) + ] + } + + private func execUsageExamples() -> [String] { + [ + cliCommandString(["exec", "--direction", "right"]), + cliCommandString(["exec", "--keybind", "\"My Layout\""]), + cliCommandString(["exec", "--id", ""]) + ] + } + + private func execUsageError(_ error: String) -> [String: Any] { + migrationErrorResponse( + command: "exec", + error: error, + availableRoutes: execUsageExamples() + ) + } + + private func urlCommandString(_ components: [String]) -> String { + "loop://\(components.joined(separator: "/"))" + } + + private func cliCommandString(_ arguments: [String]) -> String { + "loop-cli " + arguments.joined(separator: " ") + } + + private func cliExecDirectionCommand(_ slug: String) -> String { + cliCommandString(["exec", "--direction", slug]) + } + + private func cliExecKeybindCommand(_ name: String) -> String { + cliCommandString(["exec", "--keybind", shellQuoted(name)]) + } + + private func cliExecIDCommand(_ id: String) -> String { + cliCommandString(["exec", "--id", id]) + } + + private func listRouteReplacement(_ components: [String], source: InvocationSource) -> String { + switch source { + case .urlScheme: + return urlCommandString(["list"] + components) + case .cli: + let args: [String] + if components == ["actions", "directions"] { + args = ["list", "actions", "--directions-only"] + } else if components == ["actions", "keybinds"] { + args = ["list", "actions", "--keybinds-only"] + } else { + args = ["list"] + components + } + + return cliCommandString(args) + } + } + + private func replacementCommand(for descriptor: ExecutableActionDescriptor, source: InvocationSource) -> String { + switch source { + case .urlScheme: + urlCommandString(descriptor.urlPath.split(separator: "/").map(String.init)) + case .cli: + switch descriptor { + case let .direction(direction): + cliExecDirectionCommand(direction.slug) + case let .keybind(keybind): + cliExecKeybindCommand(keybind.name) + } + } + } + + private func migrationErrorResponse( + command: String, + error: String, + replacement: String? = nil, + availableRoutes: [String] = [] + ) -> [String: Any] { + var response: [String: Any] = [ + "success": false, + "command": command, + "error": error + ] + + if let replacement { + response["replacement"] = replacement + } + + if !availableRoutes.isEmpty { + response["availableRoutes"] = availableRoutes + } + + return response + } + + private func invalidListRootResponse(source: InvocationSource) -> [String: Any] { + migrationErrorResponse( + command: "list", + error: "No list type specified", + availableRoutes: publicListRoutes(for: source) + ) + } + + private func removedListAllResponse(source: InvocationSource) -> [String: Any] { + migrationErrorResponse( + command: "list", + error: "list/all has been removed", + availableRoutes: publicListRoutes(for: source) + ) + } + + private func removedListKeybindsResponse(source: InvocationSource) -> [String: Any] { + migrationErrorResponse( + command: "list", + error: "list/keybinds has been removed", + replacement: listRouteReplacement(["actions", "keybinds"], source: source) + ) + } + + private func invalidListRouteResponse(_ parameters: [String], source: InvocationSource) -> [String: Any] { + migrationErrorResponse( + command: "list", + error: "Unknown list route: list/\(parameters.joined(separator: "/"))", + availableRoutes: publicListRoutes(for: source) + ) + } + + private func unknownCommandResponse(_ command: String?, source: InvocationSource) -> [String: Any] { + [ + "success": false, + "error": "Unknown command: \(command ?? "nil")", + "availableCommands": publicCommandNames(for: source) + ] + } + + // MARK: - JSON Helpers + + /// Serializes a response dictionary to a pretty-printed JSON string. + private func jsonString(_ dict: [String: Any]) -> String { + guard let data = try? JSONSerialization.data( + withJSONObject: dict, + options: [.prettyPrinted, .sortedKeys] + ) else { + return #"{"success":false,"error":"Failed to serialize response"}"# + } + return String(data: data, encoding: .utf8) + ?? #"{"success":false,"error":"Failed to encode response"}"# + } + + private func makeExecutionResult( + source: InvocationSource, + kind: CommandKind, + components: [String], + response: [String: Any] + ) -> CommandExecutionResult { + CommandExecutionResult( + source: source, + kind: kind, + title: outputTitle(for: components), + jsonResponse: jsonString(response), + isSuccess: response["success"] as? Bool ?? false, + errorMessage: response["error"] as? String + ) + } + + private func outputTitle(for components: [String]) -> String { + let commandPath = components.joined(separator: " ") + return commandPath.isEmpty ? "Loop Output" : "Loop Output: \(commandPath)" + } + + /// Builds a JSON-serializable dictionary for a window. + private func windowJSON(_ window: Window) -> [String: Any] { + let app = window.nsRunningApplication + let frame = window.frame + return [ + "windowID": window.cgWindowID, + "bundleID": app?.bundleIdentifier ?? "", + "appName": app?.localizedName ?? "", + "windowTitle": window.title ?? "", + "frame": [ + "x": Int(frame.origin.x), + "y": Int(frame.origin.y), + "width": Int(frame.width), + "height": Int(frame.height) + ] + ] + } + + // MARK: - Tokenization Helpers + + private func tokenizeCommandLine(_ input: String) -> MessageResult<[String]> { + var tokens: [String] = [] + var current = "" + var activeQuote: Character? + var isEscaping = false + var closedQuotedArgument = false + + for character in input { + if isEscaping { + current.append(character) + isEscaping = false + continue + } + + if character == "\\" { + isEscaping = true + continue + } + + if let quote = activeQuote { + if character == quote { + activeQuote = nil + closedQuotedArgument = true + } else { + current.append(character) + } + continue + } + + if character == "\"" || character == "'" { + activeQuote = character + continue + } + + if character.isWhitespace { + if !current.isEmpty || closedQuotedArgument { + tokens.append(current) + current.removeAll() + closedQuotedArgument = false + } + continue + } + + current.append(character) + } + + if isEscaping { + current.append("\\") + } + + guard activeQuote == nil else { + return .failure("Unterminated quoted argument") + } + + if !current.isEmpty || closedQuotedArgument { + tokens.append(current) + } + + return .success(tokens) + } + + private func shellQuoted(_ string: String) -> String { + if string.isEmpty { + return "\"\"" + } + + let escaped = string + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + + return escaped.contains(where: \.isWhitespace) || escaped.contains("\"") + ? "\"\(escaped)\"" + : escaped + } + + // MARK: - Slug and ID Helpers + + private func slugifyDisplayString(_ string: String, treatCamelCaseAsWords: Bool = false) -> String { + let source = if treatCamelCaseAsWords { + string + .replacingOccurrences( + of: "([A-Z]+)([A-Z][a-z])", + with: "$1_$2", + options: .regularExpression + ) + .replacingOccurrences( + of: "([a-z0-9])([A-Z])", + with: "$1_$2", + options: .regularExpression + ) + } else { + string + } + + let slug = source + .replacingOccurrences(of: "[^A-Za-z0-9]+", with: "_", options: .regularExpression) + .replacingOccurrences(of: "_{2,}", with: "_", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "_")) + .lowercased() + + return slug.isEmpty ? "unnamed" : slug + } + + private func canonicalDirectionSlug(for direction: WindowDirection) -> String { + slugifyDisplayString(direction.rawValue, treatCamelCaseAsWords: true) + } + + private func deterministicDirectionID(for direction: WindowDirection) -> UUID { + uuidV5(namespace: Self.directionIDNamespace, name: direction.rawValue) + } + + private func uuidV5(namespace: UUID, name: String) -> UUID { + var namespaceUUID = namespace.uuid + let namespaceData = withUnsafeBytes(of: &namespaceUUID) { Data($0) } + let nameData = Data(name.utf8) + let digest = Insecure.SHA1.hash(data: namespaceData + nameData) + + var bytes = Array(digest.prefix(16)) + bytes[6] = (bytes[6] & 0x0F) | 0x50 + bytes[8] = (bytes[8] & 0x3F) | 0x80 + + return UUID(uuid: ( + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15] + )) + } + + private func shortIdentifier(for uuid: UUID) -> String { + String(uuid.uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(8)) + } + + // MARK: - Execution Helpers + + private func isExecutableKeybindAction(_ action: WindowAction) -> Bool { + switch action.direction { + case .noAction, .noSelection: + return false + case .cycle: + return !(action.cycle?.isEmpty ?? true) + case .stash: + return action.stashEdge != nil + default: + return true + } + } + + private func resolveActionForCommandExecution(_ action: WindowAction, window: Window?) -> WindowAction { + var currentAction = action + var depth = 0 + + while currentAction.direction == .cycle { + guard depth < 8, let cycle = currentAction.cycle, !cycle.isEmpty else { + return currentAction + } + + if let window, + let latestRecord = WindowRecords.getCurrentAction(for: window), + let currentIndex = cycle.firstIndex(of: latestRecord) { + currentAction = cycle[(currentIndex + 1) % cycle.count] + } else { + currentAction = cycle[0] + } + + depth += 1 + } + + return currentAction + } + + private func dispatchAction(_ action: WindowAction, on window: Window?, screen: NSScreen) { + if let app = window?.nsRunningApplication { + log.info("Activating application: \(app.localizedName ?? "unknown")") + app.activate(options: .activateIgnoringOtherApps) + } + + Task { + try? await Task.sleep(for: .seconds(0.1)) + + log.info("Executing action: \(action) on \(window?.title ?? "unknown")") + _ = try await WindowActionEngine.shared.apply( + action, + window: window, + screen: screen + ) + if let window { + log.info("New window frame: \(window.frame)") + } + } + } + + private func resolveTargetScreen( + for action: WindowAction, + window: Window?, + params: TargetParams + ) -> MessageResult { + if action.direction.willChangeScreen { + guard let window else { + return .failure(windowResolveError(params)) + } + + guard let currentScreen = ScreenUtility.screenContaining(window) ?? NSScreen.main else { + return .failure("No current screen found") + } + + let targetScreen: NSScreen? = switch action.direction { + case .nextScreen: + ScreenUtility.nextScreen(from: currentScreen) + case .previousScreen: + ScreenUtility.previousScreen(from: currentScreen) + case .leftScreen: + ScreenUtility.directionalScreen(from: currentScreen, direction: .left) + case .rightScreen: + ScreenUtility.directionalScreen(from: currentScreen, direction: .right) + case .topScreen: + ScreenUtility.directionalScreen(from: currentScreen, direction: .top) + case .bottomScreen: + ScreenUtility.directionalScreen(from: currentScreen, direction: .bottom) + default: + currentScreen + } + + guard let targetScreen else { + return .failure("No target screen found for \(action.direction.name)") + } + + return .success(targetScreen) + } + + guard let screen = resolveScreen(screenID: params.screenID) else { + return .failure("No screen found with ID \(params.screenID!)") + } + + return .success(screen) + } + + // MARK: - Window/Screen Helpers + + /// Finds a window by its CGWindowID from the current window list. + private func findWindowByID(_ windowID: CGWindowID) -> Window? { + WindowUtility.windowList().first { $0.cgWindowID == windowID } + } + + /// Resolves the target window from targeting parameters. + /// Priority: windowID > bundleID > frontmost window. + private func resolveWindow(params: TargetParams = .init()) -> Window? { + if let windowID = params.windowID { + return findWindowByID(windowID) + } + if let bundleID = params.bundleID { + return resolveWindowByBundleID(bundleID) + } + return try? WindowUtility.frontmostWindow() + } + + /// Resolves a window by bundle ID, launching the app if needed. + private func resolveWindowByBundleID(_ bundleID: String) -> Window? { + if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == bundleID }) { + app.activate(options: .activateIgnoringOtherApps) + Thread.sleep(forTimeInterval: 0.1) + return try? Window(pid: app.processIdentifier) + } + + guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else { + log.error("No app found for bundle ID: \(bundleID)") + return nil + } + + let config = NSWorkspace.OpenConfiguration() + config.activates = true + + let semaphore = DispatchSemaphore(value: 0) + var launchedApp: NSRunningApplication? + NSWorkspace.shared.openApplication(at: appURL, configuration: config) { app, error in + if let error { + self.log.error("Failed to launch \(bundleID): \(error.localizedDescription)") + } + launchedApp = app + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 5) + + guard let app = launchedApp else { + return nil + } + + for _ in 0..<30 { + Thread.sleep(forTimeInterval: 0.1) + if let window = try? Window(pid: app.processIdentifier) { + return window + } + } + + log.error("App launched but no window appeared: \(bundleID)") + return nil + } + + /// Resolves a screen by display ID, falling back to the main screen. + private func resolveScreen(screenID: CGDirectDisplayID? = nil) -> NSScreen? { + if let screenID { + return NSScreen.screens.first { $0.displayID == screenID } + } + return NSScreen.main + } + + /// Builds a human-readable error message for window resolution failure. + private func windowResolveError(_ params: TargetParams) -> String { + if let windowID = params.windowID { + return "No window found with ID \(windowID)" + } + if let bundleID = params.bundleID { + return "Could not find or launch app: \(bundleID)" + } + return "No frontmost window found" + } +} + +private extension Array { + var only: Element? { + count == 1 ? first : nil + } +} diff --git a/Loop/Core/LoopServer.swift b/Loop/Scripting/LoopSocketManager.swift similarity index 94% rename from Loop/Core/LoopServer.swift rename to Loop/Scripting/LoopSocketManager.swift index 8c1d6268..0ca1aa75 100644 --- a/Loop/Core/LoopServer.swift +++ b/Loop/Scripting/LoopSocketManager.swift @@ -1,5 +1,5 @@ // -// LoopServer.swift +// LoopSocketManager.swift // Loop // // Created by Kai Azim on 2026-03-18. @@ -11,17 +11,17 @@ import Scribe /// Listens on a Unix domain socket for commands from loop-cli. /// /// The server accepts connections, reads a raw command string (newline-terminated), -/// dispatches it to `URLCommandHandler.executeRaw()` on the main thread, and writes +/// dispatches it to `LoopCommandHandler.executeRaw()` on the main thread, and writes /// the JSON response back before closing the connection. /// /// Command format: ` [args...] [--window-id ] [--bundle-id ] [--screen-id ]` -/// Example: `direction right --bundle-id com.apple.Safari` +/// Example: `exec --direction right --bundle-id com.apple.Safari` @Loggable -final class LoopServer { +final class LoopSocketManager { // MARK: - Properties private let socketPath: String - private let handler: URLCommandHandler + private let handler: LoopCommandHandler private var serverFD: Int32 = -1 private var isRunning = false @@ -35,7 +35,7 @@ final class LoopServer { // MARK: - Initialization - init(handler: URLCommandHandler) { + init(handler: LoopCommandHandler) { self.handler = handler self.socketPath = "/tmp/loop-\(getuid()).socket" } @@ -187,7 +187,7 @@ final class LoopServer { semaphore.signal() return } - response = self.handler.executeRaw(requestString) + response = self.handler.executeRaw(requestString).jsonResponse semaphore.signal() } diff --git a/Loop/Settings Window/Loop/AdvancedConfiguration.swift b/Loop/Settings Window/Loop/AdvancedConfiguration.swift index 9887138c..1a19c40b 100644 --- a/Loop/Settings Window/Loop/AdvancedConfiguration.swift +++ b/Loop/Settings Window/Loop/AdvancedConfiguration.swift @@ -21,13 +21,18 @@ final class AdvancedConfigurationModel: ObservableObject { @Published private(set) var isLowPowerModeEnabled: Bool = ProcessInfo.processInfo.isLowPowerModeEnabled @Published private(set) var isAccessibilityAccessGranted = AccessibilityManager.shared.isGranted + @Published private(set) var commandLineToolInstallStatus: CommandLineToolInstaller.Status = .notInstalled + @Published private(set) var isCommandLineToolOperationInProgress = false + @Published var commandLineToolErrorMessage: String? private var lowPowerModeCheckerTask: Task<(), Never>? private var accessibilityCheckerTask: Task<(), Never>? + private let commandLineToolInstaller = CommandLineToolInstaller() func startTracking() { trackLowPowerMode() trackAccessibilityStatus() + refreshCommandLineToolInstallStatus() } func stopTracking() { @@ -102,6 +107,46 @@ final class AdvancedConfigurationModel: ObservableObject { showSuccessIndicator(\.showResetRadialMenuActionsSuccessIndicator) } + var canPerformCommandLineToolAction: Bool { + !isCommandLineToolOperationInProgress && ( + commandLineToolInstallStatus == .notInstalled + || commandLineToolInstallStatus == .installedStale + ) + } + + var isCommandLineToolInstalled: Bool { + commandLineToolInstallStatus == .installedCurrent + } + + var commandLineToolActionTitle: LocalizedStringKey { + switch commandLineToolInstallStatus { + case .installedStale: + "Repair…" + case .notInstalled, .installedCurrent, .blocked: + "Install…" + } + } + + func clearCommandLineToolError() { + commandLineToolErrorMessage = nil + } + + func installOrRepairCommandLineTool() { + guard canPerformCommandLineToolAction else { return } + let status = commandLineToolInstallStatus + + performCommandLineToolOperation { installer in + switch status { + case .notInstalled: + try await installer.install() + case .installedStale: + try await installer.reinstall() + case .installedCurrent, .blocked: + break + } + } + } + private func showSuccessIndicator(_ keyPath: ReferenceWritableKeyPath) { Task { withAnimation(.smooth(duration: 0.5)) { @@ -115,11 +160,42 @@ final class AdvancedConfigurationModel: ObservableObject { } } } + + private func performCommandLineToolOperation(_ operation: @escaping (CommandLineToolInstaller) async throws -> ()) { + Task { @MainActor in + guard !isCommandLineToolOperationInProgress else { return } + + isCommandLineToolOperationInProgress = true + commandLineToolErrorMessage = nil + + defer { + isCommandLineToolOperationInProgress = false + refreshCommandLineToolInstallStatus() + } + + do { + try await operation(commandLineToolInstaller) + } catch { + log.error("Error installing Loop CLI: \(error.localizedDescription)") + commandLineToolErrorMessage = error.localizedDescription + } + } + } + + private func refreshCommandLineToolInstallStatus() { + do { + commandLineToolInstallStatus = try commandLineToolInstaller.status() + } catch { + log.error("Error checking CLI installation status: \(error.localizedDescription)") + commandLineToolInstallStatus = .blocked + } + } } struct AdvancedConfigurationView: View { @EnvironmentObject private var windowModel: SettingsWindowManager @Environment(\.luminareAnimation) var luminareAnimation + @Environment(\.luminareSectionHorizontalPadding) private var sectionHorizontalPadding @Environment(\.openURL) private var openURL @StateObject private var model = AdvancedConfigurationModel() @@ -146,11 +222,17 @@ struct AdvancedConfigurationView: View { generalSection radialMenuSection keybindsSection + commandLineToolSection permissionsSection .onAppear(perform: model.startTracking) .onDisappear(perform: model.stopTracking) } .animation(luminareAnimation, value: enableRadialMenuCustomization) + .alert("Command-Line Tool Error", isPresented: commandLineToolErrorIsPresented) { + Button("OK", role: .cancel, action: model.clearCommandLineToolError) + } message: { + Text(model.commandLineToolErrorMessage ?? "") + } } private var generalSection: some View { @@ -303,6 +385,38 @@ struct AdvancedConfigurationView: View { .animation(luminareAnimation, value: model.isAccessibilityAccessGranted) } + private var commandLineToolSection: some View { + LuminareSection(String(localized: "Command Line Interface", comment: "Section header shown in settings")) { + LuminareCompose { + Button { + model.installOrRepairCommandLineTool() + } label: { + Text(model.commandLineToolActionTitle) + .padding(.horizontal, sectionHorizontalPadding) + } + .luminareRoundingBehavior(top: true, bottom: true) + .luminareContentSize(contentMode: .fit, hasFixedHeight: true) + .luminareComposeIgnoreSafeArea(edges: .traili + ng) + .disabled(!model.canPerformCommandLineToolAction) + } label: { + HStack { + if model.isCommandLineToolInstalled { + Image(systemName: "checkmark.seal.fill") + .foregroundStyle(.green) + } + + Text("Loop CLI") + .padding(.trailing, 4) + .luminareToolTip(attachedTo: .topTrailing) { + Text(model.commandLineToolInstallStatus.description) + .padding(6) + } + } + } + } + } + private func accessibilityComponent() -> some View { LuminareButton { HStack { @@ -320,4 +434,15 @@ struct AdvancedConfigurationView: View { } .disabled(model.isAccessibilityAccessGranted) } + + private var commandLineToolErrorIsPresented: Binding { + Binding( + get: { model.commandLineToolErrorMessage != nil }, + set: { isPresented in + if !isPresented { + model.clearCommandLineToolError() + } + } + ) + } } diff --git a/Loop/Updater/UpdaterAuthorizationCoordinator.swift b/Loop/Updater/PrivilegedHelperCoordinator.swift similarity index 67% rename from Loop/Updater/UpdaterAuthorizationCoordinator.swift rename to Loop/Updater/PrivilegedHelperCoordinator.swift index a900c0ae..fcd0ce5c 100644 --- a/Loop/Updater/UpdaterAuthorizationCoordinator.swift +++ b/Loop/Updater/PrivilegedHelperCoordinator.swift @@ -1,5 +1,5 @@ // -// UpdaterAuthorizationCoordinator.swift +// PrivilegedHelperCoordinator.swift // Loop // // Created by Kai Azim on 2026-02-23. @@ -10,39 +10,54 @@ import Scribe import Security import ServiceManagement +private struct PrivilegedHelperCoordinatorError: LocalizedError { + let message: String + + var errorDescription: String? { + message + } +} + @Loggable -final class UpdaterAuthorizationCoordinator { +final class PrivilegedHelperCoordinator { enum PrivilegedHelperReadiness: Sendable { case available case unavailable(reason: String) } final class PrivilegedSession { - private unowned let coordinator: UpdaterAuthorizationCoordinator + private unowned let coordinator: PrivilegedHelperCoordinator private let connection: NSXPCConnection - fileprivate init(coordinator: UpdaterAuthorizationCoordinator, connection: NSXPCConnection) { + fileprivate init(coordinator: PrivilegedHelperCoordinator, connection: NSXPCConnection) { self.coordinator = coordinator self.connection = connection } - /// Invokes the helper atomic swap using a rollback token instead of caller-provided paths. func atomicSwap(rollbackID: String) async throws { let operation = PrivilegedOperation.atomicSwap(rollbackID: rollbackID) try await coordinator.performXPCOperation(connection: connection, operation: operation) } - /// Invokes helper restore for the rollback token selected by the caller. func restoreFromBackup(rollbackID: String) async throws { let operation = PrivilegedOperation.restore(rollbackID: rollbackID) try await coordinator.performXPCOperation(connection: connection, operation: operation) } - /// Removes the authenticated client's current app bundle. func removeCurrentBundle() async throws { let operation = PrivilegedOperation.removeCurrentBundle try await coordinator.performXPCOperation(connection: connection, operation: operation) } + + func installCommandLineTool() async throws { + let operation = PrivilegedOperation.installCommandLineTool + try await coordinator.performXPCOperation(connection: connection, operation: operation) + } + + func reinstallCommandLineTool() async throws { + let operation = PrivilegedOperation.reinstallCommandLineTool + try await coordinator.performXPCOperation(connection: connection, operation: operation) + } } private final class ContinuationCompletion: @unchecked Sendable { @@ -59,7 +74,6 @@ final class UpdaterAuthorizationCoordinator { } private let fileManager: FileManager - private let operationTimeout: Duration = .seconds(90) init(fileManager: FileManager = .default) { @@ -76,6 +90,7 @@ final class UpdaterAuthorizationCoordinator { } func withPrivilegedSession( + prompt: String = "\(Bundle.main.appName) needs administrator permission to perform this action.", _ body: (PrivilegedSession) async throws -> T ) async throws -> T { let helperURL = try helperExecutableURL() @@ -84,8 +99,8 @@ final class UpdaterAuthorizationCoordinator { let createStatus = AuthorizationCreate(nil, nil, [], &authRef) guard createStatus == errAuthorizationSuccess, let authRef else { - throw UpdateError.installationFailed( - "Could not request installation authorization: \(authorizationErrorMessage(for: createStatus))" + throw operationFailed( + "Could not request administrator authorization: \(authorizationErrorMessage(for: createStatus))" ) } @@ -93,9 +108,9 @@ final class UpdaterAuthorizationCoordinator { AuthorizationFree(authRef, [.destroyRights]) } - try requestInstallerAuthorizationRight(authRef) + try requestPrivilegedHelperAuthorizationRight(authRef, prompt: prompt) - let serviceName = PrivilegedInstallerConstants.serviceName + let serviceName = PrivilegedHelperConstants.serviceName let jobDictionary = makeJobDictionary(serviceName: serviceName, helperPath: helperURL.path) try submit(jobDictionary, authRef: authRef) @@ -103,11 +118,10 @@ final class UpdaterAuthorizationCoordinator { removeSubmittedJob(serviceName: serviceName, authRef: authRef) } - // Give launchd a brief moment to bootstrap the helper listener. try await Task.sleep(for: .milliseconds(250)) let connection = NSXPCConnection(machServiceName: serviceName, options: .privileged) - connection.remoteObjectInterface = NSXPCInterface(with: PrivilegedInstallerProtocol.self) + connection.remoteObjectInterface = NSXPCInterface(with: PrivilegedHelperProtocol.self) connection.resume() defer { @@ -125,7 +139,6 @@ final class UpdaterAuthorizationCoordinator { let completion = ContinuationCompletion() var timeoutTask: Task<(), Never>? - // Keep completion synchronous so competing callbacks cannot resume more than once. let finish: (Result<(), Error>) -> () = { result in guard completion.tryComplete() else { return } timeoutTask?.cancel() @@ -140,27 +153,26 @@ final class UpdaterAuthorizationCoordinator { } connection.interruptionHandler = { - self.log.warn("Privileged installer \(operation.name) interrupted during shared session") - finish(.failure(UpdateError.installationFailed("Privileged installer \(operation.name) interrupted"))) + self.log.warn("Privileged helper \(operation.name) interrupted during shared session") + finish(.failure(self.operationFailed("Privileged helper \(operation.name) interrupted"))) } connection.invalidationHandler = { - self.log.warn("Privileged installer \(operation.name) invalidated during shared session") - finish(.failure(UpdateError.installationFailed("Privileged installer \(operation.name) invalidated"))) + self.log.warn("Privileged helper \(operation.name) invalidated during shared session") + finish(.failure(self.operationFailed("Privileged helper \(operation.name) invalidated"))) } guard let proxy = connection.remoteObjectProxyWithErrorHandler({ error in - self.log.warn("Privileged installer \(operation.name) transport failed during shared session: \(error.localizedDescription)") - finish(.failure(UpdateError.installationFailed("Privileged installer \(operation.name) transport failed: \(error.localizedDescription)"))) - }) as? PrivilegedInstallerProtocol else { - self.log.warn("Failed to connect to privileged installer helper for \(operation.name)") - finish(.failure(UpdateError.installationFailed("Failed to connect to privileged installer helper"))) + self.log.warn("Privileged helper \(operation.name) transport failed during shared session: \(error.localizedDescription)") + finish(.failure(self.operationFailed("Privileged helper \(operation.name) transport failed: \(error.localizedDescription)"))) + }) as? PrivilegedHelperProtocol else { + self.log.warn("Failed to connect to privileged helper for \(operation.name)") + finish(.failure(self.operationFailed("Failed to connect to privileged helper"))) return } - // NSXPC reports remote failures via callbacks; direct throwing proxy calls can raise uncaught Objective-C exceptions. operation.invoke(on: proxy) { error in if let error { - finish(.failure(UpdateError.installationFailed(error.localizedDescription))) + finish(.failure(self.operationFailed(error.localizedDescription))) } else { finish(.success(())) } @@ -173,8 +185,8 @@ final class UpdaterAuthorizationCoordinator { return } - self.log.warn("Privileged installer \(operation.name) timed out during shared session") - finish(.failure(UpdateError.installationFailed("Privileged installer \(operation.name) timed out"))) + self.log.warn("Privileged helper \(operation.name) timed out during shared session") + finish(.failure(self.operationFailed("Privileged helper \(operation.name) timed out"))) } } } @@ -182,31 +194,28 @@ final class UpdaterAuthorizationCoordinator { private func helperExecutableURL() throws -> URL { let helperURL = Bundle.main.bundleURL .appendingPathComponent("Contents/Library/LaunchServices", isDirectory: true) - .appendingPathComponent(PrivilegedInstallerConstants.helperExecutableName, isDirectory: false) + .appendingPathComponent(PrivilegedHelperConstants.helperExecutableName, isDirectory: false) let canonicalHelperURL = helperURL.resolvingSymlinksInPath().standardizedFileURL let helperPath = canonicalHelperURL.path guard fileManager.fileExists(atPath: helperPath) else { - throw UpdateError.installationFailed("Privileged installer executable was not found at \(helperPath)") + throw operationFailed("Privileged helper executable was not found at \(helperPath)") } guard fileManager.isExecutableFile(atPath: helperPath) else { - throw UpdateError.installationFailed("Privileged installer executable is not executable at \(helperPath)") + throw operationFailed("Privileged helper executable is not executable at \(helperPath)") } return canonicalHelperURL } - private func requestInstallerAuthorizationRight(_ authRef: AuthorizationRef) throws { - let rightName = installerAuthorizationRightName() - let prompt = "\(Bundle.main.appName) needs administrator permission to install this update." + private func requestPrivilegedHelperAuthorizationRight(_ authRef: AuthorizationRef, prompt: String) throws { + let rightName = privilegedHelperAuthorizationRightName() let getStatus = rightName.withCString { AuthorizationRightGet($0, nil) } if getStatus == errAuthorizationDenied { let setStatus = rightName.withCString { rightNameCString in - // Mirrors Sparkle's code. If kSMRightModifySystemDaemons is added, - // the permission prompt changes, seems to change the wording. AuthorizationRightSet( authRef, rightNameCString, @@ -218,10 +227,10 @@ final class UpdaterAuthorizationCoordinator { } if setStatus != errAuthorizationSuccess { - log.warn("Failed to set installer authorization right \(rightName): \(authorizationErrorMessage(for: setStatus))") + log.warn("Failed to set privileged helper authorization right \(rightName): \(authorizationErrorMessage(for: setStatus))") } } else if getStatus != errAuthorizationSuccess { - log.warn("Failed to retrieve installer authorization right \(rightName): \(authorizationErrorMessage(for: getStatus))") + log.warn("Failed to retrieve privileged helper authorization right \(rightName): \(authorizationErrorMessage(for: getStatus))") } let rightsStatus: OSStatus = rightName.withCString { rightNameCString in @@ -245,17 +254,17 @@ final class UpdaterAuthorizationCoordinator { } guard rightsStatus == errAuthorizationSuccess else { - throw UpdateError.installationFailed( + throw operationFailed( "Authorization rights request failed: \(authorizationErrorMessage(for: rightsStatus))" ) } - log.info("Authorization rights granted for one-shot privileged installer") + log.info("Authorization rights granted for one-shot privileged helper") } - private func installerAuthorizationRightName() -> String { - let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.MrKai77.Loop" - return "\(bundleIdentifier).updater-auth" + private func privilegedHelperAuthorizationRightName() -> String { + let bundleIdentifier = Bundle.main.bundleIdentifier ?? PrivilegedHelperConstants.appBundleIdentifier + return "\(bundleIdentifier).privileged-helper-auth" } private func makeJobDictionary(serviceName: String, helperPath: String) -> [String: Any] { @@ -283,7 +292,7 @@ final class UpdaterAuthorizationCoordinator { guard success else { let details = error?.takeRetainedValue().localizedDescription ?? "Unknown privileged submission failure" - throw UpdateError.installationFailed("Privileged installer submission failed: \(details)") + throw operationFailed("Privileged helper submission failed: \(details)") } } @@ -302,7 +311,7 @@ final class UpdaterAuthorizationCoordinator { } let details = error?.takeRetainedValue().localizedDescription ?? "Unknown cleanup failure" - log.warn("Failed to remove privileged updater job \(serviceName): \(details)") + log.warn("Failed to remove privileged helper job \(serviceName): \(details)") } private func authorizationErrorMessage(for status: OSStatus) -> String { @@ -316,12 +325,18 @@ final class UpdaterAuthorizationCoordinator { return "OSStatus \(status)" } + + private func operationFailed(_ message: String) -> Error { + PrivilegedHelperCoordinatorError(message: message) + } } private enum PrivilegedOperation { case atomicSwap(rollbackID: String) case restore(rollbackID: String) case removeCurrentBundle + case installCommandLineTool + case reinstallCommandLineTool var name: String { switch self { @@ -331,11 +346,14 @@ private enum PrivilegedOperation { "restore" case .removeCurrentBundle: "remove current bundle" + case .installCommandLineTool: + "install command-line tool" + case .reinstallCommandLineTool: + "reinstall command-line tool" } } - /// Dispatches the selected privileged operation on the typed helper proxy. - func invoke(on proxy: PrivilegedInstallerProtocol, reply: @escaping (NSError?) -> ()) { + func invoke(on proxy: PrivilegedHelperProtocol, reply: @escaping (NSError?) -> ()) { switch self { case let .atomicSwap(rollbackID): proxy.atomicSwap(rollbackID: rollbackID, withReply: reply) @@ -343,6 +361,10 @@ private enum PrivilegedOperation { proxy.restoreFromBackup(rollbackID: rollbackID, withReply: reply) case .removeCurrentBundle: proxy.removeCurrentBundle(withReply: reply) + case .installCommandLineTool: + proxy.installCommandLineTool(withReply: reply) + case .reinstallCommandLineTool: + proxy.reinstallCommandLineTool(withReply: reply) } } } diff --git a/Loop/Updater/UpdateInstaller.swift b/Loop/Updater/UpdateInstaller.swift index 4448096c..aa7a10fc 100644 --- a/Loop/Updater/UpdateInstaller.swift +++ b/Loop/Updater/UpdateInstaller.swift @@ -22,7 +22,7 @@ actor UpdateInstaller { private let backupManager: BackupManager private let fileManager: FileManager - private let authorizationCoordinator: UpdaterAuthorizationCoordinator + private let privilegedHelperCoordinator: PrivilegedHelperCoordinator private var isCancelled = false private var relocateToApplications = false @@ -44,7 +44,7 @@ actor UpdateInstaller { init(fileManager: FileManager = .default) { self.fileManager = fileManager self.backupManager = BackupManager(fileManager: fileManager) - self.authorizationCoordinator = UpdaterAuthorizationCoordinator() + self.privilegedHelperCoordinator = PrivilegedHelperCoordinator() } func installUpdate( @@ -233,7 +233,7 @@ actor UpdateInstaller { } private func permissionStateForRestrictedInstallLocation(baseReason: String) -> InstallationPermissionState { - switch authorizationCoordinator.privilegedHelperReadiness() { + switch privilegedHelperCoordinator.privilegedHelperReadiness() { case .available: .needsElevation(reason: baseReason) case let .unavailable(helperReason): @@ -483,7 +483,9 @@ actor UpdateInstaller { log.info("Requesting administrator authorization for privileged installation") var enteredPrivilegedSession = false do { - try await authorizationCoordinator.withPrivilegedSession { session in + try await privilegedHelperCoordinator.withPrivilegedSession( + prompt: "\(Bundle.main.appName) needs administrator permission to install this update." + ) { session in enteredPrivilegedSession = true log.success("Administrator authorization granted; privileged session established") @@ -544,7 +546,7 @@ actor UpdateInstaller { private func performRelocationInstall( from appBundle: URL, manifest: UpdateManifest, - cleanupSession: UpdaterAuthorizationCoordinator.PrivilegedSession? = nil + cleanupSession: PrivilegedHelperCoordinator.PrivilegedSession? = nil ) async throws { log.info("Installing to Applications folder") @@ -586,7 +588,7 @@ actor UpdateInstaller { private func cleanupOldRelocatedCopyIfNeeded( source sourceAppURL: URL, destination destinationURL: URL, - cleanupSession: UpdaterAuthorizationCoordinator.PrivilegedSession? + cleanupSession: PrivilegedHelperCoordinator.PrivilegedSession? ) async { let canonicalSource = LoopSupportPaths.canonical(sourceAppURL) let canonicalDestination = LoopSupportPaths.canonical(destinationURL) @@ -700,7 +702,7 @@ actor UpdateInstaller { from sourceURL: URL, to destinationURL: URL, manifest: UpdateManifest, - session: UpdaterAuthorizationCoordinator.PrivilegedSession + session: PrivilegedHelperCoordinator.PrivilegedSession ) async throws { let stagingURL = stagingRootDirectory .appendingPathComponent("\(destinationURL.lastPathComponent).staging", isDirectory: true) @@ -801,7 +803,7 @@ actor UpdateInstaller { private func atomicSwapPrivileged( staged stagingURL: URL, current currentURL: URL, - session: UpdaterAuthorizationCoordinator.PrivilegedSession + session: PrivilegedHelperCoordinator.PrivilegedSession ) async throws { try checkCancellation() @@ -840,7 +842,7 @@ actor UpdateInstaller { staged stagingURL: URL, rollbackID: String, originalError: Error, - session: UpdaterAuthorizationCoordinator.PrivilegedSession + session: PrivilegedHelperCoordinator.PrivilegedSession ) async throws { let rollbackContainerURL = rollbackRootDirectory.appendingPathComponent(rollbackID, isDirectory: true) let backupBundleURL = rollbackContainerURL.appendingPathComponent( diff --git a/LoopCLI/main.swift b/LoopCLI/main.swift index 1722405b..d6247440 100644 --- a/LoopCLI/main.swift +++ b/LoopCLI/main.swift @@ -9,18 +9,16 @@ import Foundation // MARK: - Socket Communication -/// Connects to the Loop Unix socket, sends a command URL, and returns the JSON response. -func sendCommand(_ urlString: String) -> (response: String, success: Bool) { +/// Connects to the Loop Unix socket, sends a raw command string, and returns the JSON response. +func sendCommand(_ commandString: String) -> (response: String, success: Bool) { let socketPath = "/tmp/loop-\(getuid()).socket" - // Create socket let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { return (makeError("Failed to create socket"), false) } defer { close(fd) } - // Connect var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) @@ -43,13 +41,11 @@ func sendCommand(_ urlString: String) -> (response: String, success: Bool) { return (makeError("Loop is not running (could not connect to \(socketPath))"), false) } - // Set timeout var timeout = timeval(tv_sec: 5, tv_usec: 0) setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) - // Send request - let request = urlString + "\n" + let request = commandString + "\n" let sent = request.utf8.withContiguousStorageIfAvailable { buffer in Darwin.write(fd, buffer.baseAddress!, buffer.count) } ?? -1 @@ -58,7 +54,6 @@ func sendCommand(_ urlString: String) -> (response: String, success: Bool) { return (makeError("Failed to send command"), false) } - // Read response (until EOF) var responseData = Data() var buffer = [UInt8](repeating: 0, count: 4096) @@ -75,7 +70,6 @@ func sendCommand(_ urlString: String) -> (response: String, success: Bool) { return (makeError("Empty response from Loop"), false) } - // Check success field let isSuccess: Bool if let data = response.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -96,63 +90,88 @@ func makeError(_ message: String) -> String { // MARK: - Command Building /// Builds a raw command string from CLI arguments. -/// The command string is sent directly over the socket — no URL wrapping. -/// -/// Usage: loop-cli [subcommand...] [--window-id ] [--bundle-id ] [--screen-id ] -/// -/// Examples: -/// loop-cli windowlist → "windowlist" -/// loop-cli direction right → "direction right" -/// loop-cli direction right --bundle-id com.apple.Safari → "direction right --bundle-id com.apple.Safari" +/// Arguments are shell-escaped so quoted values such as `--keybind "My Layout"` +/// survive the socket transport and can be re-tokenized in the app. func buildCommand(from args: [String]) -> String? { guard !args.isEmpty else { return nil } - // Validate that flag args have values - var i = 0 - while i < args.count { - if args[i] == "--window-id" || args[i] == "--bundle-id" || args[i] == "--screen-id" { - guard i + 1 < args.count else { - fputs("Error: \(args[i]) requires a value\n", stderr) + let flagsRequiringValues: Set = [ + "--window-id", + "--bundle-id", + "--screen-id", + "--direction", + "--keybind", + "--id" + ] + + var index = 0 + while index < args.count { + if flagsRequiringValues.contains(args[index]) { + guard index + 1 < args.count else { + fputs("Error: \(args[index]) requires a value\n", stderr) return nil } - i += 2 + index += 2 } else { - i += 1 + index += 1 } } - return args.joined(separator: " ") + return args.map(escapeArgument).joined(separator: " ") +} + +func escapeArgument(_ argument: String) -> String { + if argument.isEmpty { + return "\"\"" + } + + let escaped = argument + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + + return escaped.contains(where: \.isWhitespace) || escaped.contains("\"") + ? "\"\(escaped)\"" + : escaped } // MARK: - Help +let executableName = URL(fileURLWithPath: CommandLine.arguments.first ?? "loop-cli").lastPathComponent + let helpText = """ -loop-cli — Command-line interface for Loop window manager +\(executableName) — Command-line interface for Loop window manager USAGE: - loop-cli [arguments] [options] - -COMMANDS: - windowlist List all visible windows - screenlist List all connected screens - direction Move/resize window (left, right, top, bottom, maximize, center, ...) - action Execute a window action - keybind Execute a custom keybind - screen Move window to another screen - list List available commands - -OPTIONS: - --window-id Target a specific window by ID (from windowlist) + \(executableName) list [--directions-only | --keybinds-only] + \(executableName) exec --direction | --keybind | --id [options] + +READ COMMANDS: + list windows List all visible windows + list screens List all connected screens + list actions List all executable actions + list actions --directions-only List only built-in direction actions + list actions --keybinds-only List only keybind-backed actions + +WRITE COMMANDS: + exec --direction Execute a built-in direction action + exec --keybind Execute a keybind-backed action by display name + exec --id Execute any action by UUID + +TARGET OPTIONS: + --window-id Target a specific window by ID (from `list windows`) --bundle-id Target an app by bundle identifier (launches if needed) - --screen-id Target a specific screen by ID (from screenlist) + --screen-id Target a specific screen by ID (from `list screens`) + +OTHER OPTIONS: --help, -h Show this help message EXAMPLES: - loop-cli windowlist - loop-cli direction right - loop-cli direction right --bundle-id com.apple.Safari - loop-cli action maximize --window-id 1234 --screen-id 5678 - loop-cli list all + \(executableName) list windows + \(executableName) list actions --directions-only + \(executableName) exec --direction right + \(executableName) exec --direction next_screen --bundle-id com.apple.Safari + \(executableName) exec --keybind "My Layout" + \(executableName) exec --id 123e4567-e89b-12d3-a456-426614174000 All commands return JSON. Exit code is 0 on success, 1 on failure. """ @@ -167,7 +186,7 @@ if args.isEmpty || args.contains("--help") || args.contains("-h") { } guard let command = buildCommand(from: args) else { - fputs("Error: No command specified. Run 'loop-cli --help' for usage.\n", stderr) + fputs("Error: Invalid command. Run '\(executableName) --help' for usage.\n", stderr) exit(1) } diff --git a/LoopUpdaterHelper/Info.plist b/LoopPrivilegedHelper/Info.plist similarity index 80% rename from LoopUpdaterHelper/Info.plist rename to LoopPrivilegedHelper/Info.plist index b30b7a1a..982a4d4b 100644 --- a/LoopUpdaterHelper/Info.plist +++ b/LoopPrivilegedHelper/Info.plist @@ -3,11 +3,11 @@ CFBundleIdentifier - com.MrKai77.Loop.UpdaterHelper + com.MrKai77.Loop.PrivilegedHelper CFBundleExecutable $(EXECUTABLE_NAME) CFBundleName - LoopUpdaterHelper + LoopPrivilegedHelper CFBundlePackageType BNDL diff --git a/LoopUpdaterHelper/PrivilegedInstaller.swift b/LoopPrivilegedHelper/PrivilegedHelper.swift similarity index 71% rename from LoopUpdaterHelper/PrivilegedInstaller.swift rename to LoopPrivilegedHelper/PrivilegedHelper.swift index 5a6c1c50..03aca629 100644 --- a/LoopUpdaterHelper/PrivilegedInstaller.swift +++ b/LoopPrivilegedHelper/PrivilegedHelper.swift @@ -1,16 +1,17 @@ // -// PrivilegedInstaller.swift +// PrivilegedHelper.swift // Loop // // Created by Kai Azim on 2026-03-01. // +import Darwin import Foundation import Scribe import Security @Loggable -final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { +final class PrivilegedHelper: NSObject, PrivilegedHelperProtocol { private struct AtomicSwapPaths { let currentURL: URL let stagedURL: URL @@ -24,15 +25,27 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { let backupBundleURL: URL } + private enum ExistingFilesystemEntry { + case missing + case symbolicLink + case other + } + + private enum CommandLineToolDestinationState { + case missing + case loopManaged + case occupied(reason: String) + } + private static let maxRollbackIDLength = 128 private static let allowedRollbackIDScalars = CharacterSet.alphanumerics .union(CharacterSet(charactersIn: "._-")) - private let context: PrivilegedInstallerService.TrustedClientContext + private let context: PrivilegedHelperService.TrustedClientContext private let fileManager: FileManager init( - context: PrivilegedInstallerService.TrustedClientContext, + context: PrivilegedHelperService.TrustedClientContext, fileManager: FileManager = .default ) { self.context = context @@ -66,7 +79,24 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { } } - /// Executes a privileged atomic swap using rollback-token-derived paths in user Application Support. + func installCommandLineTool(withReply reply: @escaping (NSError?) -> ()) { + do { + try executeInstallCommandLineTool(reinstall: false) + reply(nil) + } catch { + reply(error as NSError) + } + } + + func reinstallCommandLineTool(withReply reply: @escaping (NSError?) -> ()) { + do { + try executeInstallCommandLineTool(reinstall: true) + reply(nil) + } catch { + reply(error as NSError) + } + } + private func executeAtomicSwap(rollbackID: String) throws { let operation = "atomic swap" @@ -93,7 +123,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { } } - /// Restores the current app directly from rollback-token-derived backup path in user Application Support. private func executeRestoreFromBackup(rollbackID: String) throws { let operation = "restore" @@ -124,7 +153,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { } } - /// Removes the authenticated client's current app bundle path. private func executeRemoveCurrentBundle() throws { let currentBundleURL = LoopSupportPaths.canonical(context.clientBundleURL) @@ -137,7 +165,146 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { log.success("Removed current app bundle at \(currentBundleURL.path)") } - /// Derives and validates atomic swap paths from trusted connection context and rollback token. + private func executeInstallCommandLineTool(reinstall: Bool) throws { + let sourceURL = try validatedCommandLineToolSourceURL() + let destinationURL = PrivilegedHelperConstants.commandLineToolSymlinkURL + + try ensureCommandLineToolInstallDirectoryExists() + + switch try commandLineToolDestinationState(at: destinationURL) { + case .missing: + guard !reinstall else { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "No existing Loop CLI symlink was found at \(destinationURL.path)." + ) + } + case .loopManaged: + guard reinstall else { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "Loop CLI is already installed at \(destinationURL.path)." + ) + } + + try fileManager.removeItem(at: destinationURL) + case let .occupied(reason): + throw PrivilegedHelperError.commandLineToolInstallFailed(reason: reason) + } + + do { + try fileManager.createSymbolicLink(at: destinationURL, withDestinationURL: sourceURL) + log.success("Installed Loop CLI symlink at \(destinationURL.path) -> \(sourceURL.path)") + } catch { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "Failed to create symlink at \(destinationURL.path): \(error.localizedDescription)" + ) + } + } + + private func validatedCommandLineToolSourceURL() throws -> URL { + let sourceURL = LoopSupportPaths.canonical( + context.clientBundleURL + .appendingPathComponent("Contents/MacOS", isDirectory: true) + .appendingPathComponent(PrivilegedHelperConstants.commandLineToolExecutableName, isDirectory: false) + ) + + guard fileManager.fileExists(atPath: sourceURL.path) else { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "Bundled CLI was not found at \(sourceURL.path)." + ) + } + + guard fileManager.isExecutableFile(atPath: sourceURL.path) else { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "Bundled CLI is not executable at \(sourceURL.path)." + ) + } + + return sourceURL + } + + private func ensureCommandLineToolInstallDirectoryExists() throws { + let installDirectoryURL = PrivilegedHelperConstants.commandLineToolInstallDirectoryURL + var isDirectory = ObjCBool(false) + + if fileManager.fileExists(atPath: installDirectoryURL.path, isDirectory: &isDirectory) { + guard isDirectory.boolValue else { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "\(installDirectoryURL.path) exists but is not a directory." + ) + } + return + } + + do { + try fileManager.createDirectory(at: installDirectoryURL, withIntermediateDirectories: true) + } catch { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "Could not create \(installDirectoryURL.path): \(error.localizedDescription)" + ) + } + } + + private func commandLineToolDestinationState(at destinationURL: URL) throws -> CommandLineToolDestinationState { + switch try filesystemEntry(at: destinationURL) { + case .missing: + return .missing + case .other: + return .occupied(reason: "\(destinationURL.path) is already in use.") + case .symbolicLink: + let rawDestination = try symbolicLinkDestination(at: destinationURL) + guard isLoopManagedCommandLineToolTarget(rawDestination) else { + return .occupied(reason: "\(destinationURL.path) is already in use by another symlink.") + } + return .loopManaged + } + } + + private func filesystemEntry(at url: URL) throws -> ExistingFilesystemEntry { + var statBuffer = stat() + let result = url.withUnsafeFileSystemRepresentation { path in + guard let path else { return -1 } + return Int(lstat(path, &statBuffer)) + } + + if result == 0 { + let fileType = statBuffer.st_mode & S_IFMT + return fileType == S_IFLNK ? .symbolicLink : .other + } + + if errno == ENOENT { + return .missing + } + + let errorCode = errno + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "Could not inspect \(url.path): \(String(cString: strerror(errorCode))) (\(errorCode))" + ) + } + + private func symbolicLinkDestination(at url: URL) throws -> String { + do { + return try fileManager.destinationOfSymbolicLink(atPath: url.path) + } catch { + throw PrivilegedHelperError.commandLineToolInstallFailed( + reason: "Could not inspect symbolic link at \(url.path): \(error.localizedDescription)" + ) + } + } + + private func isLoopManagedCommandLineToolTarget(_ targetPath: String) -> Bool { + let standardizedTargetPath: String + if targetPath.hasPrefix("/") { + standardizedTargetPath = URL(fileURLWithPath: targetPath).standardizedFileURL.path + } else { + let baseURL = PrivilegedHelperConstants.commandLineToolInstallDirectoryURL + standardizedTargetPath = URL(fileURLWithPath: targetPath, relativeTo: baseURL) + .standardizedFileURL + .path + } + + return standardizedTargetPath.hasSuffix(PrivilegedHelperConstants.loopManagedCommandLineToolSuffix) + } + private func deriveAndValidateAtomicSwapPaths( for rollbackID: String, operation: String @@ -194,7 +361,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { ) } - /// Derives and validates restore paths from trusted connection context and rollback token. private func deriveAndValidateRestorePaths( for rollbackID: String, operation: String @@ -236,7 +402,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { ) } - /// Validates bundle code signature using the same static validation path as non-privileged install flow. private func validateBundleForInstall( at bundleURL: URL, operation: String, @@ -276,7 +441,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { } } - /// Validates rollback token format to prevent traversal and unexpected path materialization. private func validateRollbackID(_ rollbackID: String, operation: String) throws { guard !rollbackID.isEmpty else { throw pathValidationFailure( @@ -315,7 +479,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { } } - /// Ensures a candidate path remains within the expected root after canonicalization. private func ensurePathInside( _ candidate: URL, root: URL, @@ -334,7 +497,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { } } - /// Converts Security framework status codes to readable log/error strings. private func securityErrorMessage(for status: OSStatus) -> String { if let message = SecCopyErrorMessageString(status, nil) as String? { return "\(message) (OSStatus \(status))" @@ -342,13 +504,12 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { return "OSStatus \(status)" } - /// Logs and constructs a privileged path validation failure with operation context. private func pathValidationFailure( operation: String, rollbackID: String, path: String, reason: String - ) -> PrivilegedInstallerError { + ) -> PrivilegedHelperError { log.warn( """ Rejected privileged \(operation) path for pid \(context.clientPID), uid \(context.clientUID), rollbackID \(rollbackID). \ @@ -360,13 +521,12 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { return .pathValidationFailed(operation: operation, path: path, reason: reason) } - /// Logs and constructs a privileged bundle validation failure with operation context. private func bundleValidationFailure( operation: String, rollbackID: String, path: String, reason: String - ) -> PrivilegedInstallerError { + ) -> PrivilegedHelperError { log.warn( """ Rejected privileged \(operation) bundle for pid \(context.clientPID), uid \(context.clientUID), rollbackID \(rollbackID). \ @@ -378,14 +538,12 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { return .bundleValidationFailed(path: path, reason: reason) } - /// Returns true when a canonicalized URL is equal to or contained within a canonicalized root. private func isPath(_ url: URL, inside root: URL) -> Bool { let canonicalURLPath = LoopSupportPaths.canonical(url).path let canonicalRootPath = LoopSupportPaths.canonical(root).path return canonicalURLPath == canonicalRootPath || canonicalURLPath.hasPrefix("\(canonicalRootPath)/") } - /// Moves current app to backup and installs the staged app atomically with rollback on failure. private func performAtomicSwap( currentURL: URL, stagedURL: URL, @@ -445,7 +603,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { log.success("Privileged atomic swap completed") } - /// Restores the app from backup and reapplies root ownership. private func performRestoreFromBackup(currentURL: URL, backupBundleURL: URL) throws { log.info("Starting privileged restore from backup") log.info("Current app: \(currentURL.path)") @@ -461,14 +618,12 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { log.success("Privileged restore completed") } - /// Applies root ownership recursively to a directory tree. private func applyRootOwnershipRecursively(at url: URL) throws { log.info("Applying root ownership recursively at \(url.path)") try applyOwnershipRecursively(at: url, uid: 0, gid: 0) log.success("Applied root ownership recursively at \(url.path)") } - /// Applies a target uid/gid recursively to the root URL and its descendants. private func applyOwnershipRecursively(at rootURL: URL, uid: uid_t, gid: gid_t) throws { var itemCount = 0 try applyOwnership(to: rootURL, uid: uid, gid: gid) @@ -490,7 +645,6 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { log.success("Applied ownership to \(itemCount) items under \(rootURL.path)") } - /// Applies ownership to a single filesystem entry using `lchown`. private func applyOwnership(to itemURL: URL, uid: uid_t, gid: gid_t) throws { let result: Int32 = itemURL.withUnsafeFileSystemRepresentation { path in guard let path else { @@ -501,7 +655,7 @@ final class PrivilegedInstaller: NSObject, PrivilegedInstallerProtocol { guard result == 0 else { let errorCode = errno - throw PrivilegedInstallerError.ownershipChangeFailed(url: itemURL, code: errorCode) + throw PrivilegedHelperError.ownershipChangeFailed(url: itemURL, code: errorCode) } log.success("Applied ownership to \(itemURL.path) (uid: \(uid), gid: \(gid))") diff --git a/LoopUpdaterHelper/PrivilegedInstallerError.swift b/LoopPrivilegedHelper/PrivilegedHelperError.swift similarity index 78% rename from LoopUpdaterHelper/PrivilegedInstallerError.swift rename to LoopPrivilegedHelper/PrivilegedHelperError.swift index 31bbde8c..5f812843 100644 --- a/LoopUpdaterHelper/PrivilegedInstallerError.swift +++ b/LoopPrivilegedHelper/PrivilegedHelperError.swift @@ -1,5 +1,5 @@ // -// PrivilegedInstallerError.swift +// PrivilegedHelperError.swift // Loop // // Created by Kai Azim on 2026-02-23. @@ -7,11 +7,12 @@ import Foundation -enum PrivilegedInstallerError: LocalizedError { +enum PrivilegedHelperError: LocalizedError { case ownershipLookupFailed(url: URL) case ownershipChangeFailed(url: URL, code: Int32) case pathValidationFailed(operation: String, path: String, reason: String) case bundleValidationFailed(path: String, reason: String) + case commandLineToolInstallFailed(reason: String) var errorDescription: String? { switch self { @@ -24,6 +25,8 @@ enum PrivilegedInstallerError: LocalizedError { return "Rejected privileged \(operation) path \(path): \(reason)" case let .bundleValidationFailed(path, reason): return "Rejected privileged bundle at \(path): \(reason)" + case let .commandLineToolInstallFailed(reason): + return "Could not install Loop command-line tool: \(reason)" } } } diff --git a/LoopUpdaterHelper/PrivilegedInstallerService.swift b/LoopPrivilegedHelper/PrivilegedHelperService.swift similarity index 87% rename from LoopUpdaterHelper/PrivilegedInstallerService.swift rename to LoopPrivilegedHelper/PrivilegedHelperService.swift index 68dba963..071d9715 100644 --- a/LoopUpdaterHelper/PrivilegedInstallerService.swift +++ b/LoopPrivilegedHelper/PrivilegedHelperService.swift @@ -1,5 +1,5 @@ // -// PrivilegedInstallerService.swift +// PrivilegedHelperService.swift // Loop // // Created by Kai Azim on 2026-02-23. @@ -12,7 +12,7 @@ import Scribe import Security @Loggable -final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { +final class PrivilegedHelperService: NSObject, NSXPCListenerDelegate { struct TrustedClientContext { let clientPID: pid_t let clientUID: uid_t @@ -34,9 +34,9 @@ final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { } func run() { - log.info("Starting privileged installer listener") + log.info("Starting privileged helper listener") listener.resume() - log.success("Privileged installer listener is running") + log.success("Privileged helper listener is running") RunLoop.current.run() } @@ -60,15 +60,14 @@ final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { newConnection.interruptionHandler = { [weak self] in self?.releaseActiveConnection(for: pid, reason: "interruption") } - newConnection.exportedInterface = NSXPCInterface(with: PrivilegedInstallerProtocol.self) - newConnection.exportedObject = PrivilegedInstaller(context: context) + newConnection.exportedInterface = NSXPCInterface(with: PrivilegedHelperProtocol.self) + newConnection.exportedObject = PrivilegedHelper(context: context) newConnection.resume() log.success("Accepted XPC connection (pid: \(pid), uid: \(context.clientUID))") return true } - /// Builds per-connection trusted context from authenticated process identity and uid-derived paths. private func trustedClientContext(for connection: NSXPCConnection) -> TrustedClientContext? { let pid = connection.processIdentifier guard pid > 0 else { @@ -77,7 +76,7 @@ final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { } guard let app = NSRunningApplication(processIdentifier: pid), - app.bundleIdentifier == PrivilegedInstallerConstants.appBundleIdentifier else { + app.bundleIdentifier == PrivilegedHelperConstants.appBundleIdentifier else { log.warn("Rejecting client pid \(pid) due to bundle identifier mismatch") return nil } @@ -128,7 +127,7 @@ final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { var requirement: SecRequirement? let requirementStatus = SecRequirementCreateWithString( - PrivilegedInstallerConstants.authorizedClientRequirement as CFString, + PrivilegedHelperConstants.authorizedClientRequirement as CFString, SecCSFlags(), &requirement ) @@ -148,7 +147,6 @@ final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { return true } - /// Resolves a user's home directory and primary group from the system account database. private func userAccountInfo(for uid: uid_t) -> (homeDirectory: URL, primaryGroupID: gid_t)? { guard let passwdEntry = getpwuid(uid) else { return nil @@ -165,7 +163,6 @@ final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { ) } - /// Reserves a single active connection slot to prevent overlapping privileged sessions. private func reserveActiveConnection(for pid: pid_t) -> Bool { connectionStateLock.lock() defer { connectionStateLock.unlock() } @@ -178,7 +175,6 @@ final class PrivilegedInstallerService: NSObject, NSXPCListenerDelegate { return true } - /// Releases the active connection slot when that connection ends. private func releaseActiveConnection(for pid: pid_t, reason: String) { connectionStateLock.lock() defer { connectionStateLock.unlock() } diff --git a/LoopUpdaterHelper/main.swift b/LoopPrivilegedHelper/main.swift similarity index 52% rename from LoopUpdaterHelper/main.swift rename to LoopPrivilegedHelper/main.swift index 195820bf..5fded360 100644 --- a/LoopUpdaterHelper/main.swift +++ b/LoopPrivilegedHelper/main.swift @@ -7,5 +7,5 @@ import Foundation -let service = PrivilegedInstallerService(serviceName: PrivilegedInstallerConstants.serviceName) +let service = PrivilegedHelperService(serviceName: PrivilegedHelperConstants.serviceName) service.run() diff --git a/README.md b/README.md index 75b56e0a..f92547b6 100644 --- a/README.md +++ b/README.md @@ -114,16 +114,15 @@ To set Caps Lock as your trigger key, you have two options: #### c. Shell/AppleScript -Loop can be controlled via shell commands or AppleScript using its URL scheme: +Loop can be controlled from the shell or AppleScript using its URL scheme: ```bash # Shell examples open "loop://direction/right" # Move window to right half -open "loop://action/maximize" # Maximize window -open "loop://screen/next" # Move to next screen +open "loop://direction/maximize" # Maximize window +open "loop://direction/next_screen" # Move to next screen -# AppleScript examples -osascript -e 'tell application "Loop" to activate' +# AppleScript example osascript -e 'open location "loop://direction/left"' ``` @@ -134,15 +133,28 @@ You can also create custom scripts to chain multiple actions: # Example: Move window right and then maximize open "loop://direction/right" sleep 0.5 -open "loop://action/maximize" +open "loop://direction/maximize" ``` -For a complete list of available commands: +Read-style URL commands open a Loop output window with selectable JSON: ```bash -open "loop://list/all" # List all commands -open "loop://list/actions" # List window actions -open "loop://list/keybinds" # List custom keybinds +open "loop://list/windows" # List visible windows +open "loop://list/screens" # List connected screens +open "loop://list/actions" # List all executable actions +open "loop://list/actions/directions" # List built-in direction actions +open "loop://list/actions/keybinds" # List keybind-backed actions +``` + +For machine-readable shell output, install the CLI from Loop's Advanced tab. This creates `/usr/local/bin/loop`, which points at the bundled `loop-cli` binary: + +```bash +loop list windows +loop list screens +loop list actions --directions-only +loop exec --direction right +loop exec --keybind "My Layout" +loop exec --id 123e4567-e89b-12d3-a456-426614174000 ``` ### Keyboard Shortcuts diff --git a/Shared/PrivilegedHelperProtocol.swift b/Shared/PrivilegedHelperProtocol.swift new file mode 100644 index 00000000..1d07c6d0 --- /dev/null +++ b/Shared/PrivilegedHelperProtocol.swift @@ -0,0 +1,58 @@ +// +// PrivilegedHelperProtocol.swift +// Loop +// +// Created by Kai Azim on 2026-02-23. +// + +import Foundation + +@objc protocol PrivilegedHelperProtocol { + /// Performs a privileged swap using a validated rollback token-derived path set. + func atomicSwap( + rollbackID: String, + withReply reply: @escaping (NSError?) -> () + ) + + /// Restores the current app from the rollback snapshot identified by the rollback token. + func restoreFromBackup( + rollbackID: String, + withReply reply: @escaping (NSError?) -> () + ) + + /// Removes the authenticated client's current app bundle. + func removeCurrentBundle( + withReply reply: @escaping (NSError?) -> () + ) + + /// Installs the Loop CLI symlink at the fixed privileged destination. + func installCommandLineTool( + withReply reply: @escaping (NSError?) -> () + ) + + /// Reinstalls the Loop CLI symlink when the existing destination is already Loop-managed. + func reinstallCommandLineTool( + withReply reply: @escaping (NSError?) -> () + ) +} + +enum PrivilegedHelperConstants { + static let helperExecutableName = "LoopPrivilegedHelper" + static let serviceName = "com.MrKai77.Loop.PrivilegedHelperJob" + static let appBundleIdentifier = "com.MrKai77.Loop" + static let authorizedClientRequirement = "identifier \"com.MrKai77.Loop\" and anchor apple generic and certificate leaf[subject.OU] = \"5F967GYF84\"" + + static let commandLineToolExecutableName = "loop-cli" + static let commandLineToolSymlinkName = "loop" + static let commandLineToolInstallDirectory = "/usr/local/bin" + + static var commandLineToolInstallDirectoryURL: URL { + URL(fileURLWithPath: commandLineToolInstallDirectory, isDirectory: true) + } + + static var commandLineToolSymlinkURL: URL { + commandLineToolInstallDirectoryURL.appendingPathComponent(commandLineToolSymlinkName, isDirectory: false) + } + + static let loopManagedCommandLineToolSuffix = "/Loop.app/Contents/MacOS/\(commandLineToolExecutableName)" +} diff --git a/Shared/PrivilegedInstallerProtocol.swift b/Shared/PrivilegedInstallerProtocol.swift deleted file mode 100644 index 928c52ba..00000000 --- a/Shared/PrivilegedInstallerProtocol.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// PrivilegedInstallerProtocol.swift -// Loop -// -// Created by Kai Azim on 2026-02-23. -// - -import Foundation - -@objc protocol PrivilegedInstallerProtocol { - /// Performs a privileged swap using a validated rollback token-derived path set. - func atomicSwap( - rollbackID: String, - withReply reply: @escaping (NSError?) -> () - ) - - /// Restores the current app from the rollback snapshot identified by the rollback token. - func restoreFromBackup( - rollbackID: String, - withReply reply: @escaping (NSError?) -> () - ) - - /// Removes the authenticated client's current app bundle. - func removeCurrentBundle( - withReply reply: @escaping (NSError?) -> () - ) -} - -enum PrivilegedInstallerConstants { - static let helperExecutableName = "LoopUpdaterHelper" - static let serviceName = "com.MrKai77.Loop.UpdaterJob" - static let appBundleIdentifier = "com.MrKai77.Loop" - static let authorizedClientRequirement = "identifier \"com.MrKai77.Loop\" and anchor apple generic and certificate leaf[subject.OU] = \"5F967GYF84\"" -} From 5a3ff7920fc7a8250ada9fa51f8a8fc40f1f9f5f Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Mon, 30 Mar 2026 21:17:32 -0600 Subject: [PATCH 5/8] =?UTF-8?q?=E2=9C=A8=20Better=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop.xcodeproj/project.pbxproj | 19 + Loop/App/AppDelegate.swift | 10 +- .../CommandOutputWindowController.swift | 8 +- Loop/Scripting/LoopCommandHandler.swift | 728 ++++-------------- Loop/Scripting/LoopSocketManager.swift | 10 +- .../Loop/AdvancedConfiguration.swift | 3 +- LoopCLI/CLIErrorFormatter.swift | 96 +++ LoopCLI/CLIOutputFormatter.swift | 504 ++++++++++++ LoopCLI/CLIOutputMode.swift | 11 + LoopCLI/CLIRequest.swift | 53 ++ LoopCLI/CLIResponse.swift | 89 +++ LoopCLI/ExecCommand.swift | 84 ++ LoopCLI/ListCommand.swift | 58 ++ LoopCLI/ListSubject.swift | 14 + LoopCLI/LoopCLIApplication.swift | 82 ++ LoopCLI/LoopCLICommand.swift | 29 + LoopCLI/LoopSocketClient.swift | 90 +++ LoopCLI/OutputOptions.swift | 17 + LoopCLI/TargetOptions.swift | 44 ++ LoopCLI/main.swift | 189 +---- README.md | 10 + 21 files changed, 1369 insertions(+), 779 deletions(-) create mode 100644 LoopCLI/CLIErrorFormatter.swift create mode 100644 LoopCLI/CLIOutputFormatter.swift create mode 100644 LoopCLI/CLIOutputMode.swift create mode 100644 LoopCLI/CLIRequest.swift create mode 100644 LoopCLI/CLIResponse.swift create mode 100644 LoopCLI/ExecCommand.swift create mode 100644 LoopCLI/ListCommand.swift create mode 100644 LoopCLI/ListSubject.swift create mode 100644 LoopCLI/LoopCLIApplication.swift create mode 100644 LoopCLI/LoopCLICommand.swift create mode 100644 LoopCLI/LoopSocketClient.swift create mode 100644 LoopCLI/OutputOptions.swift create mode 100644 LoopCLI/TargetOptions.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 996dcf13..2bef4611 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 3ED0A7B92F21DF6800A58629 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 3ED0A7B82F21DF6800A58629 /* ZIPFoundation */; }; B1AA00412F30000100AABBCC /* LoopPrivilegedHelper in CopyFiles */ = {isa = PBXBuildFile; fileRef = B1AA00012F30000100AABBCC /* LoopPrivilegedHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; C1BB00412F30000200AABBCC /* loop-cli in CopyFiles */ = {isa = PBXBuildFile; fileRef = C1BB00012F30000200AABBCC /* loop-cli */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + C1BB00A52F40000200AABBCC /* ArgumentParser in Frameworks */ = {isa = PBXBuildFile; productRef = C1BB00A42F40000200AABBCC /* ArgumentParser */; }; F06D768A2DFF7A77007EEDA9 /* SkyLight.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F06D76892DFF7A77007EEDA9 /* SkyLight.framework */; }; /* End PBXBuildFile section */ @@ -140,6 +141,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C1BB00A52F40000200AABBCC /* ArgumentParser in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -255,6 +257,9 @@ C1BB00022F30000200AABBCC /* LoopCLI */, ); name = LoopCLI; + packageProductDependencies = ( + C1BB00A42F40000200AABBCC /* ArgumentParser */, + ); productName = "loop-cli"; productReference = C1BB00012F30000200AABBCC /* loop-cli */; productType = "com.apple.product-type.tool"; @@ -306,6 +311,7 @@ 2A28B62A2EE5057C00A1E26B /* XCRemoteSwiftPackageReference "luminare" */, 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, 2AF9238C2F540B1300F467FD /* XCRemoteSwiftPackageReference "Scribe" */, + C1BB00A32F40000200AABBCC /* XCRemoteSwiftPackageReference "swift-argument-parser" */, ); productRefGroup = A8E59C36297F5E9A0064D4BA /* Products */; projectDirPath = ""; @@ -910,6 +916,14 @@ minimumVersion = 0.9.20; }; }; + C1BB00A32F40000200AABBCC /* XCRemoteSwiftPackageReference "swift-argument-parser" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-argument-parser"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -942,6 +956,11 @@ package = 3ED0A7B72F21DF6800A58629 /* XCRemoteSwiftPackageReference "ZIPFoundation" */; productName = ZIPFoundation; }; + C1BB00A42F40000200AABBCC /* ArgumentParser */ = { + isa = XCSwiftPackageProductDependency; + package = C1BB00A32F40000200AABBCC /* XCRemoteSwiftPackageReference "swift-argument-parser" */; + productName = ArgumentParser; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = A8E59C2D297F5E9A0064D4BA /* Project object */; diff --git a/Loop/App/AppDelegate.swift b/Loop/App/AppDelegate.swift index e08cd125..de9b57f4 100644 --- a/Loop/App/AppDelegate.swift +++ b/Loop/App/AppDelegate.swift @@ -14,7 +14,7 @@ import UserNotifications final class AppDelegate: NSObject, NSApplicationDelegate { private let loopCommandHandler = LoopCommandHandler() private lazy var loopSocketManager = LoopSocketManager(handler: loopCommandHandler) - private var pendingSettingsWindowOpen: Task? + private var pendingSettingsWindowOpen: Task<(), Never>? private var launchedAsLoginItem: Bool { guard let event = NSAppleEventManager.shared().currentAppleEvent else { return false } @@ -146,22 +146,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard !hasVisibleWindows else { return false } - + scheduleSettingsWindowOpen() return true } - + func applicationWillTerminate(_: Notification) { loopSocketManager.stop() StashManager.shared.onApplicationWillTerminate() } - + func application(_: NSApplication, open urls: [URL]) { for url in urls { processIncomingURL(url) } } - + private func processIncomingURL(_ url: URL, replyEvent: NSAppleEventDescriptor? = nil) { cancelPendingSettingsWindowOpen() log.info("Received URL: \(url)") diff --git a/Loop/Scripting/CommandOutputWindowController.swift b/Loop/Scripting/CommandOutputWindowController.swift index 4434f21c..499bfded 100644 --- a/Loop/Scripting/CommandOutputWindowController.swift +++ b/Loop/Scripting/CommandOutputWindowController.swift @@ -15,10 +15,10 @@ final class CommandOutputWindowController: NSWindowController, NSWindowDelegate, } private let output: String - private let onClose: () -> Void + private let onClose: () -> () - init(title: String, content: String, onClose: @escaping () -> Void) { - output = content + init(title: String, content: String, onClose: @escaping () -> ()) { + self.output = content self.onClose = onClose let scrollView = NSScrollView() @@ -85,7 +85,7 @@ final class CommandOutputWindowController: NSWindowController, NSWindowDelegate, } @available(*, unavailable) - required init?(coder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/Loop/Scripting/LoopCommandHandler.swift b/Loop/Scripting/LoopCommandHandler.swift index 0c23a77f..531a13c3 100644 --- a/Loop/Scripting/LoopCommandHandler.swift +++ b/Loop/Scripting/LoopCommandHandler.swift @@ -17,19 +17,15 @@ - loop://list/actions/keybinds - loop://direction/ - loop://keybind/ + - loop://id/ - Public CLI commands: - - loop-cli list windows - - loop-cli list screens - - loop-cli list actions [--directions-only | --keybinds-only] - - loop-cli exec --direction - - loop-cli exec --keybind - - loop-cli exec --id - - Query parameters / target flags: - - ?windowID= / --window-id - - ?bundleID= / --bundle-id - - ?screenID= / --screen-id + Socket / CLI transport: + - loop-cli parses CLI arguments locally and sends canonical loop:// URLs over the socket + + Query parameters: + - ?windowID= + - ?bundleID= + - ?screenID= */ import AppKit @@ -43,17 +39,17 @@ import Scribe final class LoopCommandHandler { // MARK: - Types - enum InvocationSource: Sendable { + enum InvocationSource { case urlScheme case cli } - enum CommandKind: Sendable { + enum CommandKind { case read case write } - struct CommandExecutionResult: Sendable { + struct CommandExecutionResult { let source: InvocationSource let kind: CommandKind let title: String @@ -140,6 +136,10 @@ final class LoopCommandHandler { var urlPath: String { "direction/\(slug)" } + + var idPath: String { + "id/\(idString)" + } } private struct KeybindActionDescriptor { @@ -155,6 +155,10 @@ final class LoopCommandHandler { var urlPath: String { "keybind/\(slug)" } + + var idPath: String { + "id/\(idString)" + } } private enum ExecutableActionDescriptor { @@ -206,6 +210,15 @@ final class LoopCommandHandler { } } + var idPath: String { + switch self { + case let .direction(descriptor): + descriptor.idPath + case let .keybind(descriptor): + descriptor.idPath + } + } + var windowAction: WindowAction { switch self { case let .direction(descriptor): @@ -241,11 +254,16 @@ final class LoopCommandHandler { /// Handles incoming `loop://` requests and returns command metadata. @discardableResult func handle(_ url: URL) -> CommandExecutionResult { - log.info("Processing URL: \(url)") + handle(url, source: .urlScheme) + } + + @discardableResult + func handle(_ url: URL, source: InvocationSource) -> CommandExecutionResult { + log.info("Processing request: \(url.absoluteString)") guard url.scheme?.lowercased() == "loop" else { return makeExecutionResult( - source: .urlScheme, + source: source, kind: .write, components: [], response: [ @@ -265,94 +283,26 @@ final class LoopCommandHandler { ) let components = (url.host.map { [$0] } ?? []) + url.pathComponents.filter { $0 != "/" && !$0.isEmpty } - return execute(components, params: params, source: .urlScheme) + return execute(components, params: params, source: source) } - /// Executes a raw command string and returns command metadata. - func executeRaw(_ input: String) -> CommandExecutionResult { - log.info("Processing command: \(input)") + @discardableResult + func handleRequestURLString(_ request: String, source: InvocationSource) -> CommandExecutionResult { + log.info("Processing request string: \(request)") - switch tokenizeCommandLine(input) { - case let .failure(error): + guard let url = URL(string: request) else { return makeExecutionResult( - source: .cli, + source: source, kind: .write, components: [], response: [ "success": false, - "command": "exec", - "error": error + "error": "Invalid request URL: \(request)" ] ) - - case let .success(tokens): - var components: [String] = [] - var params = TargetParams() - var index = 0 - - while index < tokens.count { - let token = tokens[index] - - switch token { - case "--window-id": - guard index + 1 < tokens.count else { - return makeExecutionResult( - source: .cli, - kind: .write, - components: components, - response: [ - "success": false, - "command": components.first?.lowercased() ?? "exec", - "error": "--window-id requires a value" - ] - ) - } - - params.windowID = UInt32(tokens[index + 1]) - index += 2 - - case "--bundle-id": - guard index + 1 < tokens.count else { - return makeExecutionResult( - source: .cli, - kind: .write, - components: components, - response: [ - "success": false, - "command": components.first?.lowercased() ?? "exec", - "error": "--bundle-id requires a value" - ] - ) - } - - params.bundleID = tokens[index + 1] - index += 2 - - case "--screen-id": - guard index + 1 < tokens.count else { - return makeExecutionResult( - source: .cli, - kind: .write, - components: components, - response: [ - "success": false, - "command": components.first?.lowercased() ?? "exec", - "error": "--screen-id requires a value" - ] - ) - } - - params.screenID = UInt32(tokens[index + 1]) - index += 2 - - default: - components.append(token) - index += 1 - } - } - - return execute(components, params: params, source: .cli) } + + return handle(url, source: source) } // MARK: - Command Execution @@ -379,7 +329,7 @@ final class LoopCommandHandler { source: source, kind: .write, components: components, - response: unknownCommandResponse(nil, source: source) + response: unknownCommandResponse(nil) ) } @@ -387,8 +337,7 @@ final class LoopCommandHandler { if let legacy = removedLegacyCommandResponse( command: commandString, - parameters: parameters, - source: source + parameters: parameters ) { return makeExecutionResult( source: source, @@ -404,10 +353,10 @@ final class LoopCommandHandler { source: source, kind: .read, components: components, - response: handleListCommand(parameters, source: source) + response: handleListCommand(parameters) ) - case "direction" where source == .urlScheme: + case "direction": return makeExecutionResult( source: source, kind: .write, @@ -415,7 +364,7 @@ final class LoopCommandHandler { response: handleDirectionCommand(parameters, params: params) ) - case "keybind" where source == .urlScheme: + case "keybind": return makeExecutionResult( source: source, kind: .write, @@ -423,12 +372,12 @@ final class LoopCommandHandler { response: handleKeybindCommand(parameters, params: params) ) - case "exec" where source == .cli: + case "id": return makeExecutionResult( source: source, kind: .write, components: components, - response: handleExecCommand(parameters, params: params) + response: handleIDCommand(parameters, params: params) ) default: @@ -436,33 +385,33 @@ final class LoopCommandHandler { source: source, kind: .write, components: components, - response: unknownCommandResponse(commandString, source: source) + response: unknownCommandResponse(commandString) ) } } // MARK: - List Commands - private func handleListCommand(_ parameters: [String], source: InvocationSource) -> [String: Any] { + private func handleListCommand(_ parameters: [String]) -> [String: Any] { guard let type = parameters.first?.lowercased() else { - return invalidListRootResponse(source: source) + return invalidListRootResponse() } switch type { case "windows": guard parameters.count == 1 else { - return invalidListRouteResponse(parameters, source: source) + return invalidListRouteResponse(parameters) } return buildWindowListResponse() case "screens": guard parameters.count == 1 else { - return invalidListRouteResponse(parameters, source: source) + return invalidListRouteResponse(parameters) } return buildScreenListResponse() case "actions": - switch parseListActionFilter(parameters, source: source) { + switch parseListActionFilter(parameters) { case let .success(filter): return buildActionsResponse(filter: filter) case let .failure(error): @@ -470,88 +419,35 @@ final class LoopCommandHandler { } case "all": - return removedListAllResponse(source: source) + return removedListAllResponse() case "keybinds": - return removedListKeybindsResponse(source: source) + return removedListKeybindsResponse() default: - return invalidListRouteResponse(parameters, source: source) + return invalidListRouteResponse(parameters) } } - private func parseListActionFilter( - _ parameters: [String], - source: InvocationSource - ) -> ResponseResult { + private func parseListActionFilter(_ parameters: [String]) -> ResponseResult { let tail = Array(parameters.dropFirst()) - switch source { - case .urlScheme: - guard tail.count <= 1 else { - return .failure(invalidListRouteResponse(parameters, source: source)) - } - - guard let subtype = tail.first?.lowercased() else { - return .success(.all) - } - - switch subtype { - case "directions": - return .success(.directionsOnly) - case "keybinds": - return .success(.keybindsOnly) - default: - return .failure(invalidListRouteResponse(parameters, source: source)) - } - - case .cli: - let flags = tail.filter { $0.hasPrefix("--") } - let positional = tail.filter { !$0.hasPrefix("--") } - - guard positional.isEmpty else { - return .failure([ - "success": false, - "command": "list", - "type": "actions", - "error": "CLI action filters must use --directions-only or --keybinds-only" - ]) - } - - let allowedFlags: Set = ["--directions-only", "--keybinds-only"] - let unknownFlags = flags.filter { !allowedFlags.contains($0.lowercased()) } - - guard unknownFlags.isEmpty else { - return .failure([ - "success": false, - "command": "list", - "type": "actions", - "error": "Unknown list actions flag: \(unknownFlags[0])" - ]) - } - - let directionsOnly = flags.contains { $0.lowercased() == "--directions-only" } - let keybindsOnly = flags.contains { $0.lowercased() == "--keybinds-only" } - - if directionsOnly, keybindsOnly { - return .failure([ - "success": false, - "command": "list", - "type": "actions", - "error": "--directions-only and --keybinds-only are mutually exclusive" - ]) - } - - if directionsOnly { - return .success(.directionsOnly) - } - - if keybindsOnly { - return .success(.keybindsOnly) - } + guard tail.count <= 1 else { + return .failure(invalidListRouteResponse(parameters)) + } + guard let subtype = tail.first?.lowercased() else { return .success(.all) } + + switch subtype { + case "directions": + return .success(.directionsOnly) + case "keybinds": + return .success(.keybindsOnly) + default: + return .failure(invalidListRouteResponse(parameters)) + } } private func buildActionsResponse(filter: ListActionFilter) -> [String: Any] { @@ -696,133 +592,36 @@ final class LoopCommandHandler { ] } - private func handleExecCommand(_ parameters: [String], params: TargetParams) -> [String: Any] { - if parameters.isEmpty { - return execUsageError("No exec target specified") - } - - var directionSlug: String? - var keybindName: String? - var identifierValue: String? - var index = 0 - - while index < parameters.count { - let token = parameters[index] - switch token.lowercased() { - case "--direction": - guard index + 1 < parameters.count else { - return execUsageError("--direction requires a value") - } - directionSlug = parameters[index + 1] - index += 2 - - case "--keybind": - guard index + 1 < parameters.count else { - return execUsageError("--keybind requires a value") - } - keybindName = parameters[index + 1] - index += 2 - - case "--id": - guard index + 1 < parameters.count else { - return execUsageError("--id requires a value") - } - identifierValue = parameters[index + 1] - index += 2 - - default: - if token.hasPrefix("--") { - return execUsageError("Unknown exec flag: \(token)") - } - - return migrationErrorResponse( - command: "exec", - error: "Positional exec targets have been removed", - availableRoutes: execUsageExamples() - ) - } - } - - let selectorCount = [directionSlug, keybindName, identifierValue].compactMap(\.self).count - guard selectorCount == 1 else { - return execUsageError("exec requires exactly one of --direction, --keybind, or --id") - } - - if let directionSlug { - if let descriptor = directionActionDescriptor(slug: directionSlug) { - return executeAction(.direction(descriptor), params: params, command: "exec") - } - - if let descriptor = legacyDirectionDescriptor(for: directionSlug) { - return migrationErrorResponse( - command: "exec", - error: "Use the canonical direction slug", - replacement: cliExecDirectionCommand(descriptor.slug) - ) - } - - return [ - "success": false, - "command": "exec", - "error": "Unknown direction slug: \(directionSlug)", - "replacement": cliCommandString(["list", "actions", "--directions-only"]) - ] - } - - if let keybindName { - let matches = keybindActionDescriptors(matchingName: keybindName) - - if let descriptor = matches.only { - return executeAction(.keybind(descriptor), params: params, command: "exec") - } - - if matches.count > 1 { - return [ - "success": false, - "command": "exec", - "error": "Multiple keybind actions share the name \"\(keybindName)\". Use --id instead.", - "matchingIDs": matches.map(\.idString) - ] - } - - if let descriptor = keybindActionDescriptor(slug: keybindName) { - return migrationErrorResponse( - command: "exec", - error: "--keybind expects the display name, not the slug", - replacement: cliExecKeybindCommand(descriptor.name) - ) - } - + private func handleIDCommand(_ parameters: [String], params: TargetParams) -> [String: Any] { + guard parameters.count == 1 else { return [ "success": false, - "command": "exec", - "error": "Unknown keybind name: \(keybindName)", - "replacement": cliCommandString(["list", "actions", "--keybinds-only"]) + "command": "id", + "error": "ID execution requires exactly one UUID", + "replacement": urlCommandString(["list", "actions"]) ] } - guard let identifierValue else { - return execUsageError("No exec target specified") - } - - guard let identifier = UUID(uuidString: identifierValue) else { + let token = parameters[0] + guard let identifier = UUID(uuidString: token) else { return [ "success": false, - "command": "exec", - "error": "Invalid UUID: \(identifierValue)" + "command": "id", + "error": "Invalid UUID: \(token)", + "replacement": urlCommandString(["list", "actions"]) ] } guard let descriptor = executableActionDescriptor(id: identifier) else { return [ "success": false, - "command": "exec", - "error": "Unknown action ID: \(identifierValue)", - "replacement": cliCommandString(["list", "actions"]) + "command": "id", + "error": "Unknown action ID: \(token)", + "replacement": urlCommandString(["list", "actions"]) ] } - return executeAction(descriptor, params: params, command: "exec") + return executeAction(descriptor, params: params, command: "id") } private func executeAction( @@ -879,13 +678,11 @@ final class LoopCommandHandler { "id": descriptor.idString, "kind": descriptor.kind, "name": descriptor.name, - "slug": descriptor.slug + "slug": descriptor.slug, + "urlPath": descriptor.urlPath, + "idPath": descriptor.idPath ] - if command != "exec" { - response["urlPath"] = descriptor.urlPath - } - if let window = resolvedWindow { response["window"] = windowJSON(window) } @@ -912,7 +709,8 @@ final class LoopCommandHandler { "kind": "direction", "name": descriptor.name, "slug": descriptor.slug, - "urlPath": descriptor.urlPath + "urlPath": descriptor.urlPath, + "idPath": descriptor.idPath ] } @@ -922,7 +720,8 @@ final class LoopCommandHandler { "kind": "keybind", "name": descriptor.name, "slug": descriptor.slug, - "urlPath": descriptor.urlPath + "urlPath": descriptor.urlPath, + "idPath": descriptor.idPath ] } @@ -979,11 +778,10 @@ final class LoopCommandHandler { let groupedByBaseSlug = Dictionary(grouping: candidates, by: \.2) return candidates.map { action, name, baseSlug in - let finalSlug: String - if groupedByBaseSlug[baseSlug, default: []].count > 1 { - finalSlug = "\(baseSlug)_\(shortIdentifier(for: action.id))" + let finalSlug: String = if groupedByBaseSlug[baseSlug, default: []].count > 1 { + "\(baseSlug)_\(shortIdentifier(for: action.id))" } else { - finalSlug = baseSlug + baseSlug } return KeybindActionDescriptor( @@ -999,13 +797,6 @@ final class LoopCommandHandler { keybindActionDescriptors().first { $0.slug == slug.lowercased() } } - private func keybindActionDescriptors(matchingName name: String) -> [KeybindActionDescriptor] { - let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) - return keybindActionDescriptors().filter { - $0.name.caseInsensitiveCompare(trimmedName) == .orderedSame - } - } - private func keybindActionDescriptor(id: UUID) -> KeybindActionDescriptor? { keybindActionDescriptors().first { $0.id == id } } @@ -1050,79 +841,58 @@ final class LoopCommandHandler { private func removedLegacyCommandResponse( command: String, - parameters: [String], - source: InvocationSource + parameters: [String] ) -> (kind: CommandKind, response: [String: Any])? { switch command { case "windowlist": - return ( + ( .read, migrationErrorResponse( command: "windowlist", error: "windowlist has been removed", - replacement: listRouteReplacement(["windows"], source: source) + replacement: listRouteReplacement(["windows"]) ) ) case "screenlist": - return ( + ( .read, migrationErrorResponse( command: "screenlist", error: "screenlist has been removed", - replacement: listRouteReplacement(["screens"], source: source) + replacement: listRouteReplacement(["screens"]) ) ) case "execute": - return ( + ( .write, - removedExecuteResponse(parameters: parameters, source: source) - ) - - case "direction": - guard source == .cli else { - return nil - } - - return ( - parameters.first?.lowercased() == "list" ? .read : .write, - removedDirectionCLIResponse(parameters: parameters) - ) - - case "keybind": - guard source == .cli else { - return nil - } - - return ( - parameters.first?.lowercased() == "list" ? .read : .write, - removedKeybindCLIResponse(parameters: parameters) + removedExecuteResponse(parameters: parameters) ) case "screen": - return ( + ( .write, - removedScreenResponse(parameters: parameters, source: source) + removedScreenResponse(parameters: parameters) ) case "action": - return ( + ( parameters.first?.lowercased() == "list" ? .read : .write, - removedActionResponse(parameters: parameters, source: source) + removedActionResponse(parameters: parameters) ) default: - return nil + nil } } - private func removedExecuteResponse(parameters: [String], source: InvocationSource) -> [String: Any] { + private func removedExecuteResponse(parameters: [String]) -> [String: Any] { if parameters.count == 1, let identifier = UUID(uuidString: parameters[0]), let descriptor = executableActionDescriptor(id: identifier) { return migrationErrorResponse( command: "execute", error: "execute has been removed", - replacement: replacementCommand(for: descriptor, source: source) + replacement: urlCommandString(["id", descriptor.idString]) ) } @@ -1130,7 +900,7 @@ final class LoopCommandHandler { return migrationErrorResponse( command: "execute", error: "execute has been removed", - replacement: replacementCommand(for: .direction(descriptor), source: source) + replacement: replacementCommand(for: .direction(descriptor)) ) } @@ -1138,90 +908,39 @@ final class LoopCommandHandler { return migrationErrorResponse( command: "execute", error: "execute has been removed", - replacement: replacementCommand(for: .keybind(descriptor), source: source) + replacement: replacementCommand(for: .keybind(descriptor)) ) } return migrationErrorResponse( command: "execute", error: "execute has been removed", - availableRoutes: source == .urlScheme ? publicURLWriteRoutes() : execUsageExamples() + availableRoutes: publicWriteRoutes() ) } - private func removedDirectionCLIResponse(parameters: [String]) -> [String: Any] { - if parameters.isEmpty || parameters.first?.lowercased() == "list" { - return migrationErrorResponse( - command: "direction", - error: "direction has been removed from loop-cli", - replacement: cliCommandString(["list", "actions", "--directions-only"]) - ) - } - - if let descriptor = legacyDirectionDescriptor(for: parameters[0]) { - return migrationErrorResponse( - command: "direction", - error: "direction has been removed from loop-cli", - replacement: cliExecDirectionCommand(descriptor.slug) - ) - } - - return migrationErrorResponse( - command: "direction", - error: "direction has been removed from loop-cli", - replacement: cliCommandString(["list", "actions", "--directions-only"]) - ) - } - - private func removedKeybindCLIResponse(parameters: [String]) -> [String: Any] { - if parameters.isEmpty || parameters.first?.lowercased() == "list" { - return migrationErrorResponse( - command: "keybind", - error: "keybind has been removed from loop-cli", - replacement: cliCommandString(["list", "actions", "--keybinds-only"]) - ) - } - - let matches = legacyKeybindDescriptors(for: parameters[0]) - if let descriptor = matches.only { - return migrationErrorResponse( - command: "keybind", - error: "keybind has been removed from loop-cli", - replacement: cliExecKeybindCommand(descriptor.name) - ) - } - - return migrationErrorResponse( - command: "keybind", - error: "keybind has been removed from loop-cli", - replacement: cliCommandString(["list", "actions", "--keybinds-only"]) - ) - } - - private func removedScreenResponse(parameters: [String], source: InvocationSource) -> [String: Any] { + private func removedScreenResponse(parameters: [String]) -> [String: Any] { if let parameter = parameters.first, let descriptor = legacyDirectionDescriptor(for: parameter) { return migrationErrorResponse( command: "screen", error: "screen has been removed", - replacement: replacementCommand(for: .direction(descriptor), source: source) + replacement: replacementCommand(for: .direction(descriptor)) ) } return migrationErrorResponse( command: "screen", error: "screen has been removed", - replacement: source == .urlScheme - ? urlCommandString(["list", "actions", "directions"]) - : cliCommandString(["list", "actions", "--directions-only"]) + replacement: urlCommandString(["list", "actions", "directions"]) ) } - private func removedActionResponse(parameters: [String], source: InvocationSource) -> [String: Any] { + private func removedActionResponse(parameters: [String]) -> [String: Any] { if parameters.isEmpty || parameters.first?.lowercased() == "list" { return migrationErrorResponse( command: "action", error: "action has been removed", - replacement: listRouteReplacement(["actions"], source: source) + replacement: listRouteReplacement(["actions"]) ) } @@ -1229,7 +948,7 @@ final class LoopCommandHandler { return migrationErrorResponse( command: "action", error: "action has been removed", - replacement: replacementCommand(for: .direction(descriptor), source: source) + replacement: replacementCommand(for: .direction(descriptor)) ) } @@ -1238,117 +957,53 @@ final class LoopCommandHandler { return migrationErrorResponse( command: "action", error: "action has been removed", - replacement: replacementCommand(for: .keybind(descriptor), source: source) + replacement: replacementCommand(for: .keybind(descriptor)) ) } return migrationErrorResponse( command: "action", error: "action has been removed", - replacement: listRouteReplacement(["actions"], source: source) + replacement: listRouteReplacement(["actions"]) ) } // MARK: - Response Helpers - private func publicCommandNames(for source: InvocationSource) -> [String] { - switch source { - case .urlScheme: - ["list", "direction", "keybind"] - case .cli: - ["list", "exec"] - } + private func publicCommandNames() -> [String] { + ["list", "direction", "keybind", "id"] } - private func publicListRoutes(for source: InvocationSource) -> [String] { + private func publicListRoutes() -> [String] { [ - listRouteReplacement(["windows"], source: source), - listRouteReplacement(["screens"], source: source), - listRouteReplacement(["actions"], source: source), - source == .urlScheme - ? listRouteReplacement(["actions", "directions"], source: source) - : cliCommandString(["list", "actions", "--directions-only"]), - source == .urlScheme - ? listRouteReplacement(["actions", "keybinds"], source: source) - : cliCommandString(["list", "actions", "--keybinds-only"]) + listRouteReplacement(["windows"]), + listRouteReplacement(["screens"]), + listRouteReplacement(["actions"]), + listRouteReplacement(["actions", "directions"]), + listRouteReplacement(["actions", "keybinds"]) ] } - private func publicURLWriteRoutes() -> [String] { + private func publicWriteRoutes() -> [String] { [ urlCommandString(["direction", "right"]), urlCommandString(["direction", "maximize"]), urlCommandString(["direction", "next_screen"]), - urlCommandString(["keybind", "my_layout"]) + urlCommandString(["keybind", "my_layout"]), + urlCommandString(["id", ""]) ] } - private func execUsageExamples() -> [String] { - [ - cliCommandString(["exec", "--direction", "right"]), - cliCommandString(["exec", "--keybind", "\"My Layout\""]), - cliCommandString(["exec", "--id", ""]) - ] - } - - private func execUsageError(_ error: String) -> [String: Any] { - migrationErrorResponse( - command: "exec", - error: error, - availableRoutes: execUsageExamples() - ) - } - private func urlCommandString(_ components: [String]) -> String { "loop://\(components.joined(separator: "/"))" } - private func cliCommandString(_ arguments: [String]) -> String { - "loop-cli " + arguments.joined(separator: " ") + private func listRouteReplacement(_ components: [String]) -> String { + urlCommandString(["list"] + components) } - private func cliExecDirectionCommand(_ slug: String) -> String { - cliCommandString(["exec", "--direction", slug]) - } - - private func cliExecKeybindCommand(_ name: String) -> String { - cliCommandString(["exec", "--keybind", shellQuoted(name)]) - } - - private func cliExecIDCommand(_ id: String) -> String { - cliCommandString(["exec", "--id", id]) - } - - private func listRouteReplacement(_ components: [String], source: InvocationSource) -> String { - switch source { - case .urlScheme: - return urlCommandString(["list"] + components) - case .cli: - let args: [String] - if components == ["actions", "directions"] { - args = ["list", "actions", "--directions-only"] - } else if components == ["actions", "keybinds"] { - args = ["list", "actions", "--keybinds-only"] - } else { - args = ["list"] + components - } - - return cliCommandString(args) - } - } - - private func replacementCommand(for descriptor: ExecutableActionDescriptor, source: InvocationSource) -> String { - switch source { - case .urlScheme: - urlCommandString(descriptor.urlPath.split(separator: "/").map(String.init)) - case .cli: - switch descriptor { - case let .direction(direction): - cliExecDirectionCommand(direction.slug) - case let .keybind(keybind): - cliExecKeybindCommand(keybind.name) - } - } + private func replacementCommand(for descriptor: ExecutableActionDescriptor) -> String { + urlCommandString(descriptor.urlPath.split(separator: "/").map(String.init)) } private func migrationErrorResponse( @@ -1374,43 +1029,43 @@ final class LoopCommandHandler { return response } - private func invalidListRootResponse(source: InvocationSource) -> [String: Any] { + private func invalidListRootResponse() -> [String: Any] { migrationErrorResponse( command: "list", error: "No list type specified", - availableRoutes: publicListRoutes(for: source) + availableRoutes: publicListRoutes() ) } - private func removedListAllResponse(source: InvocationSource) -> [String: Any] { + private func removedListAllResponse() -> [String: Any] { migrationErrorResponse( command: "list", error: "list/all has been removed", - availableRoutes: publicListRoutes(for: source) + availableRoutes: publicListRoutes() ) } - private func removedListKeybindsResponse(source: InvocationSource) -> [String: Any] { + private func removedListKeybindsResponse() -> [String: Any] { migrationErrorResponse( command: "list", error: "list/keybinds has been removed", - replacement: listRouteReplacement(["actions", "keybinds"], source: source) + replacement: listRouteReplacement(["actions", "keybinds"]) ) } - private func invalidListRouteResponse(_ parameters: [String], source: InvocationSource) -> [String: Any] { + private func invalidListRouteResponse(_ parameters: [String]) -> [String: Any] { migrationErrorResponse( command: "list", error: "Unknown list route: list/\(parameters.joined(separator: "/"))", - availableRoutes: publicListRoutes(for: source) + availableRoutes: publicListRoutes() ) } - private func unknownCommandResponse(_ command: String?, source: InvocationSource) -> [String: Any] { + private func unknownCommandResponse(_ command: String?) -> [String: Any] { [ "success": false, "error": "Unknown command: \(command ?? "nil")", - "availableCommands": publicCommandNames(for: source) + "availableCommands": publicCommandNames() ] } @@ -1467,83 +1122,6 @@ final class LoopCommandHandler { ] } - // MARK: - Tokenization Helpers - - private func tokenizeCommandLine(_ input: String) -> MessageResult<[String]> { - var tokens: [String] = [] - var current = "" - var activeQuote: Character? - var isEscaping = false - var closedQuotedArgument = false - - for character in input { - if isEscaping { - current.append(character) - isEscaping = false - continue - } - - if character == "\\" { - isEscaping = true - continue - } - - if let quote = activeQuote { - if character == quote { - activeQuote = nil - closedQuotedArgument = true - } else { - current.append(character) - } - continue - } - - if character == "\"" || character == "'" { - activeQuote = character - continue - } - - if character.isWhitespace { - if !current.isEmpty || closedQuotedArgument { - tokens.append(current) - current.removeAll() - closedQuotedArgument = false - } - continue - } - - current.append(character) - } - - if isEscaping { - current.append("\\") - } - - guard activeQuote == nil else { - return .failure("Unterminated quoted argument") - } - - if !current.isEmpty || closedQuotedArgument { - tokens.append(current) - } - - return .success(tokens) - } - - private func shellQuoted(_ string: String) -> String { - if string.isEmpty { - return "\"\"" - } - - let escaped = string - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - - return escaped.contains(where: \.isWhitespace) || escaped.contains("\"") - ? "\"\(escaped)\"" - : escaped - } - // MARK: - Slug and ID Helpers private func slugifyDisplayString(_ string: String, treatCamelCaseAsWords: Bool = false) -> String { @@ -1607,13 +1185,13 @@ final class LoopCommandHandler { private func isExecutableKeybindAction(_ action: WindowAction) -> Bool { switch action.direction { case .noAction, .noSelection: - return false + false case .cycle: - return !(action.cycle?.isEmpty ?? true) + !(action.cycle?.isEmpty ?? true) case .stash: - return action.stashEdge != nil + action.stashEdge != nil default: - return true + true } } @@ -1756,7 +1334,7 @@ final class LoopCommandHandler { return nil } - for _ in 0..<30 { + for _ in 0 ..< 30 { Thread.sleep(forTimeInterval: 0.1) if let window = try? Window(pid: app.processIdentifier) { return window diff --git a/Loop/Scripting/LoopSocketManager.swift b/Loop/Scripting/LoopSocketManager.swift index 0ca1aa75..da64d3ea 100644 --- a/Loop/Scripting/LoopSocketManager.swift +++ b/Loop/Scripting/LoopSocketManager.swift @@ -10,12 +10,12 @@ import Scribe /// Listens on a Unix domain socket for commands from loop-cli. /// -/// The server accepts connections, reads a raw command string (newline-terminated), -/// dispatches it to `LoopCommandHandler.executeRaw()` on the main thread, and writes +/// The server accepts connections, reads a newline-terminated canonical `loop://...` +/// request URL, dispatches it to `LoopCommandHandler` on the main thread, and writes /// the JSON response back before closing the connection. /// -/// Command format: ` [args...] [--window-id ] [--bundle-id ] [--screen-id ]` -/// Example: `exec --direction right --bundle-id com.apple.Safari` +/// Request format: `loop://[?windowID=&bundleID=&screenID=]` +/// Example: `loop://direction/right?bundleID=com.apple.Safari` @Loggable final class LoopSocketManager { // MARK: - Properties @@ -187,7 +187,7 @@ final class LoopSocketManager { semaphore.signal() return } - response = self.handler.executeRaw(requestString).jsonResponse + response = handler.handleRequestURLString(requestString, source: .cli).jsonResponse semaphore.signal() } diff --git a/Loop/Settings Window/Loop/AdvancedConfiguration.swift b/Loop/Settings Window/Loop/AdvancedConfiguration.swift index 1a19c40b..538834b2 100644 --- a/Loop/Settings Window/Loop/AdvancedConfiguration.swift +++ b/Loop/Settings Window/Loop/AdvancedConfiguration.swift @@ -396,8 +396,7 @@ struct AdvancedConfigurationView: View { } .luminareRoundingBehavior(top: true, bottom: true) .luminareContentSize(contentMode: .fit, hasFixedHeight: true) - .luminareComposeIgnoreSafeArea(edges: .traili - ng) + .luminareComposeIgnoreSafeArea(edges: .trailing) .disabled(!model.canPerformCommandLineToolAction) } label: { HStack { diff --git a/LoopCLI/CLIErrorFormatter.swift b/LoopCLI/CLIErrorFormatter.swift new file mode 100644 index 00000000..07c03d89 --- /dev/null +++ b/LoopCLI/CLIErrorFormatter.swift @@ -0,0 +1,96 @@ +// +// CLIErrorFormatter.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import Foundation + +struct CLICommandError: LocalizedError, CustomStringConvertible { + let message: String + + var errorDescription: String? { + message + } + + var description: String { + message + } +} + +struct CLIErrorFormatter { + private let executableName: String + + init(executableName: String) { + self.executableName = executableName + } + + func runtimeError(_ message: String) -> CLICommandError { + CLICommandError(message: message) + } + + func error(from response: CLIResponse) -> CLICommandError { + var lines: [String] = [] + + if let errorMessage = response.errorMessage, !errorMessage.isEmpty { + lines.append(errorMessage) + } else if !response.rawOutput.isEmpty { + lines.append(response.rawOutput) + } else { + lines.append("Command failed") + } + + if let replacement = response.replacement, !replacement.isEmpty { + lines.append("Try: \(displayString(for: replacement))") + } + + if !response.availableCommands.isEmpty { + lines.append("Available commands: \(response.availableCommands.joined(separator: ", "))") + } + + if !response.availableRoutes.isEmpty { + let displayedRoutes = response.availableRoutes.map(displayString) + lines.append("Available routes: \(displayedRoutes.joined(separator: ", "))") + } + + return CLICommandError(message: lines.joined(separator: "\n")) + } + + private func displayString(for route: String) -> String { + guard + let url = URL(string: route), + url.scheme?.lowercased() == "loop" + else { + return route + } + + let components = (url.host.map { [$0.lowercased()] } ?? []) + + url.pathComponents.filter { $0 != "/" && !$0.isEmpty } + + switch components { + case ["list", "windows"]: + return "\(executableName) list windows" + case ["list", "screens"]: + return "\(executableName) list screens" + case ["list", "actions"]: + return "\(executableName) list actions" + case ["list", "actions", "directions"]: + return "\(executableName) list actions --directions-only" + case ["list", "actions", "keybinds"]: + return "\(executableName) list actions --keybinds-only" + default: + break + } + + if components.count == 2, components[0] == "direction" { + return "\(executableName) exec --direction \(components[1])" + } + + if components.count == 2, components[0] == "id" { + return "\(executableName) exec --id \(components[1])" + } + + return route + } +} diff --git a/LoopCLI/CLIOutputFormatter.swift b/LoopCLI/CLIOutputFormatter.swift new file mode 100644 index 00000000..a1fb9826 --- /dev/null +++ b/LoopCLI/CLIOutputFormatter.swift @@ -0,0 +1,504 @@ +// +// CLIOutputFormatter.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-30. +// + +import Foundation + +struct CLIOutputFormatter { + func format(_ response: CLIResponse, mode: CLIOutputMode) -> String { + switch mode { + case .json: + return response.rawOutput + case .human: + guard let jsonBody = response.jsonBody else { + return response.rawOutput + } + + return formatHuman(jsonBody) ?? formatGenericValue(jsonBody, indentLevel: 0) + } + } + + private func formatHuman(_ body: [String: Any]) -> String? { + guard let command = stringValue(body["command"])?.lowercased() else { + return nil + } + + switch command { + case "list": + return formatList(body) + case "direction", "keybind", "id": + return formatExecution(body) + default: + return nil + } + } + + private func formatList(_ body: [String: Any]) -> String? { + guard let type = stringValue(body["type"])?.lowercased() else { + return nil + } + + switch type { + case "windows": + return formatWindows(body) + case "screens": + return formatScreens(body) + case "actions": + return formatActions(body) + default: + return nil + } + } + + private func formatWindows(_ body: [String: Any]) -> String { + let windows = dictionaryArray(body["windows"]) + var lines = ["Windows (\(windows.count))"] + + guard !windows.isEmpty else { + return lines[0] + } + + for (index, window) in windows.enumerated() { + lines.append("") + lines.append("\(index + 1). \(windowHeading(window, fallback: "Window \(index + 1)"))") + + if let title = nonEmptyString(window["windowTitle"]) { + lines.append(" Title: \(sanitizeInline(title))") + } + + if let bundleID = nonEmptyString(window["bundleID"]) { + lines.append(" Bundle ID: \(bundleID)") + } + + if let windowID = integerString(window["windowID"]) { + lines.append(" Window ID: \(windowID)") + } + + if let frame = frameString(from: dictionaryValue(window["frame"])) { + lines.append(" Frame: \(frame)") + } + } + + return lines.joined(separator: "\n") + } + + private func formatScreens(_ body: [String: Any]) -> String { + let screens = dictionaryArray(body["screens"]) + var lines = ["Screens (\(screens.count))"] + + guard !screens.isEmpty else { + return lines[0] + } + + for (index, screen) in screens.enumerated() { + let name = nonEmptyString(screen["name"]) ?? "Screen \(index + 1)" + let isMain = booleanValue(screen["isMain"]) ?? false + let suffix = isMain ? " [main]" : "" + + lines.append("") + lines.append("\(index + 1). \(sanitizeInline(name))\(suffix)") + + if let screenID = integerString(screen["screenID"]) { + lines.append(" Screen ID: \(screenID)") + } + + if let frame = frameString(from: dictionaryValue(screen["frame"])) { + lines.append(" Frame: \(frame)") + } + } + + return lines.joined(separator: "\n") + } + + private func formatActions(_ body: [String: Any]) -> String { + let subtype = stringValue(body["subtype"])?.lowercased() + var sections: [String] = [] + + if subtype == nil || subtype == "directions" { + sections.append(formatDirectionSections(dictionaryArray(body["directionActions"]))) + } + + if subtype == nil || subtype == "keybinds" { + sections.append(formatKeybindSection(dictionaryArray(body["keybindActions"]))) + } + + let nonEmptySections = sections.filter { !$0.isEmpty } + if nonEmptySections.isEmpty { + return "Actions\n\nNone" + } + + return nonEmptySections.joined(separator: "\n\n") + } + + private func formatDirectionSections(_ categories: [[String: Any]]) -> String { + guard !categories.isEmpty else { + return "Direction Actions\n\nNone" + } + + var lines = ["Direction Actions"] + + for category in categories { + let categoryName = nonEmptyString(category["category"]) ?? "Actions" + let actions = dictionaryArray(category["actions"]) + let rows = actions.compactMap(directionRow) + + guard !rows.isEmpty else { + continue + } + + lines.append("") + lines.append(sanitizeInline(categoryName)) + lines.append(contentsOf: formatAlignedRows(rows, indent: " ")) + } + + return lines.joined(separator: "\n") + } + + private func formatKeybindSection(_ keybinds: [[String: Any]]) -> String { + var lines = ["Keybind Actions (\(keybinds.count))"] + + guard !keybinds.isEmpty else { + lines.append("") + lines.append("None") + return lines.joined(separator: "\n") + } + + let duplicateNames = duplicateNameSet(for: keybinds) + lines.append("") + let rows = keybinds.compactMap(keybindRow) + let width = rows.map(\.0.count).max() ?? 0 + + for keybind in keybinds { + guard let row = keybindRow(from: keybind) else { + continue + } + + lines.append(formatAlignedRow(row, width: width, indent: " ")) + + guard + let name = nonEmptyString(keybind["name"]), + duplicateNames.contains(name.caseInsensitiveCompareKey), + let id = nonEmptyString(keybind["id"]) + else { + continue + } + + lines.append(" id: \(id.lowercased())") + } + + return lines.joined(separator: "\n") + } + + private func formatExecution(_ body: [String: Any]) -> String { + let name = nonEmptyString(body["name"]) ?? "Action" + var lines = ["Executed \(sanitizeInline(name))"] + + if let kind = nonEmptyString(body["kind"]) { + lines.append("Kind: \(sanitizeInline(kind))") + } + + if let slug = nonEmptyString(body["slug"]) { + lines.append("Slug: \(sanitizeInline(slug))") + } + + if let id = nonEmptyString(body["id"]) { + lines.append("ID: \(id.lowercased())") + } + + if let window = dictionaryValue(body["window"]) { + lines.append("") + lines.append("Target Window") + lines.append(contentsOf: formatWindowDetails(window, indent: " ")) + } + + return lines.joined(separator: "\n") + } + + private func formatWindowDetails(_ window: [String: Any], indent: String) -> [String] { + var lines = ["\(indent)App: \(windowHeading(window, fallback: "Unknown"))"] + + if let title = nonEmptyString(window["windowTitle"]) { + lines.append("\(indent)Title: \(sanitizeInline(title))") + } + + if let bundleID = nonEmptyString(window["bundleID"]) { + lines.append("\(indent)Bundle ID: \(bundleID)") + } + + if let windowID = integerString(window["windowID"]) { + lines.append("\(indent)Window ID: \(windowID)") + } + + if let frame = frameString(from: dictionaryValue(window["frame"])) { + lines.append("\(indent)Frame: \(frame)") + } + + return lines + } + + private func formatAlignedRows(_ rows: [(String, String)], indent: String) -> [String] { + let width = rows.map(\.0.count).max() ?? 0 + + return rows.map { primary, secondary in + formatAlignedRow((primary, secondary), width: width, indent: indent) + } + } + + private func formatAlignedRow(_ row: (String, String), width: Int, indent: String) -> String { + let (primary, secondary) = row + guard !secondary.isEmpty else { + return "\(indent)\(primary)" + } + + let padding = String(repeating: " ", count: max(2, width - primary.count + 2)) + return "\(indent)\(primary)\(padding)\(secondary)" + } + + private func directionRow(from action: [String: Any]) -> (String, String)? { + guard let slug = nonEmptyString(action["slug"]) else { + return nil + } + + let name = nonEmptyString(action["name"]) ?? slug + return (sanitizeInline(slug), sanitizeInline(name)) + } + + private func keybindRow(from action: [String: Any]) -> (String, String)? { + guard let slug = nonEmptyString(action["slug"]) else { + return nil + } + + let name = nonEmptyString(action["name"]) ?? slug + return (sanitizeInline(slug), sanitizeInline(name)) + } + + private func duplicateNameSet(for keybinds: [[String: Any]]) -> Set { + var counts: [String: Int] = [:] + + for keybind in keybinds { + guard let name = nonEmptyString(keybind["name"]) else { + continue + } + + counts[name.caseInsensitiveCompareKey, default: 0] += 1 + } + + return Set(counts.compactMap { key, value in + value > 1 ? key : nil + }) + } + + private func windowHeading(_ window: [String: Any], fallback: String) -> String { + if let appName = nonEmptyString(window["appName"]) { + return sanitizeInline(appName) + } + + if let title = nonEmptyString(window["windowTitle"]) { + return sanitizeInline(title) + } + + return fallback + } + + private func frameString(from frame: [String: Any]?) -> String? { + guard + let frame, + let width = integerString(frame["width"]), + let height = integerString(frame["height"]), + let x = integerString(frame["x"]), + let y = integerString(frame["y"]) + else { + return nil + } + + return "\(width)x\(height) @ \(x),\(y)" + } + + private func dictionaryValue(_ value: Any?) -> [String: Any]? { + value as? [String: Any] + } + + private func dictionaryArray(_ value: Any?) -> [[String: Any]] { + (value as? [Any])?.compactMap { $0 as? [String: Any] } ?? [] + } + + private func stringValue(_ value: Any?) -> String? { + value as? String + } + + private func nonEmptyString(_ value: Any?) -> String? { + guard let string = stringValue(value)?.trimmingCharacters(in: .whitespacesAndNewlines), + !string.isEmpty + else { + return nil + } + + return string + } + + private func booleanValue(_ value: Any?) -> Bool? { + switch value { + case let bool as Bool: + return bool + case let number as NSNumber: + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue + } + return nil + default: + return nil + } + } + + private func integerString(_ value: Any?) -> String? { + switch value { + case let int as Int: + return String(int) + case let int32 as Int32: + return String(int32) + case let int64 as Int64: + return String(int64) + case let number as NSNumber: + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return nil + } + return String(number.int64Value) + default: + return nil + } + } + + private func sanitizeInline(_ string: String) -> String { + string + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .joined(separator: " ") + } + + private func formatGenericValue(_ value: Any, indentLevel: Int) -> String { + if let dictionary = value as? [String: Any] { + return formatGenericDictionary(dictionary, indentLevel: indentLevel) + } + + if let array = value as? [Any] { + return formatGenericArray(array, indentLevel: indentLevel) + } + + return formatGenericScalar(value) + } + + private func formatGenericDictionary(_ dictionary: [String: Any], indentLevel: Int) -> String { + if dictionary.isEmpty { + return "{}" + } + + let indent = String(repeating: " ", count: indentLevel) + let sortedKeys = dictionary.keys.sorted() + + return sortedKeys.map { key in + let value = dictionary[key]! + if isCollection(value) { + let renderedValue = formatGenericValue(value, indentLevel: indentLevel + 1) + if renderedValue == "{}" || renderedValue == "[]" { + return "\(indent)\(key): \(renderedValue)" + } + return "\(indent)\(key):\n\(renderedValue)" + } + + return "\(indent)\(key): \(formatGenericScalar(value))" + }.joined(separator: "\n") + } + + private func formatGenericArray(_ array: [Any], indentLevel: Int) -> String { + if array.isEmpty { + return "[]" + } + + let indent = String(repeating: " ", count: indentLevel) + let childIndentLevel = indentLevel + 1 + + return array.map { item in + if isCollection(item) { + let renderedValue = formatGenericValue(item, indentLevel: childIndentLevel) + if renderedValue == "{}" || renderedValue == "[]" { + return "\(indent)- \(renderedValue)" + } + + return "\(indent)-\n\(renderedValue)" + } + + return "\(indent)- \(formatGenericScalar(item))" + }.joined(separator: "\n") + } + + private func formatGenericScalar(_ value: Any) -> String { + switch value { + case let string as String: + return formatGenericString(string) + case let number as NSNumber: + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue ? "true" : "false" + } + return number.stringValue + case _ as NSNull: + return "null" + default: + return formatGenericString(String(describing: value)) + } + } + + private func formatGenericString(_ string: String) -> String { + guard requiresQuoting(string) else { + return string + } + + return "\"\(escape(string))\"" + } + + private func requiresQuoting(_ string: String) -> Bool { + if string.isEmpty { + return true + } + + if string.trimmingCharacters(in: .whitespacesAndNewlines) != string { + return true + } + + if string.contains("\n") || string.contains("\r") { + return true + } + + if string.contains(": ") || string.contains("#") { + return true + } + + if string.contains("{") || string.contains("}") || string.contains("[") || string.contains("]") { + return true + } + + return false + } + + private func escape(_ string: String) -> String { + string + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\t", with: "\\t") + } + + private func isCollection(_ value: Any) -> Bool { + value is [String: Any] || value is [Any] + } +} + +private extension String { + var caseInsensitiveCompareKey: String { + lowercased() + } +} diff --git a/LoopCLI/CLIOutputMode.swift b/LoopCLI/CLIOutputMode.swift new file mode 100644 index 00000000..ccaa43ad --- /dev/null +++ b/LoopCLI/CLIOutputMode.swift @@ -0,0 +1,11 @@ +// +// CLIOutputMode.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-30. +// + +enum CLIOutputMode { + case human + case json +} diff --git a/LoopCLI/CLIRequest.swift b/LoopCLI/CLIRequest.swift new file mode 100644 index 00000000..05402e2c --- /dev/null +++ b/LoopCLI/CLIRequest.swift @@ -0,0 +1,53 @@ +// +// CLIRequest.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import ArgumentParser +import Foundation + +protocol CLIRequestCommand: ParsableCommand { + var outputMode: CLIOutputMode { get } + func makeRequest(using application: LoopCLIApplication) throws -> CLIRequest +} + +extension CLIRequestCommand { + func run() throws { + try LoopCLIApplication.shared.execute( + makeRequest(using: .shared), + outputMode: outputMode + ) + } +} + +struct CLIRequest { + let url: URL + + init(routeComponents: [String], queryItems: [URLQueryItem] = []) { + precondition(!routeComponents.isEmpty, "CLIRequest requires at least one route component") + + var components = URLComponents() + components.scheme = "loop" + components.host = routeComponents[0] + + if routeComponents.count > 1 { + components.path = "/" + routeComponents.dropFirst().joined(separator: "/") + } + + if !queryItems.isEmpty { + components.queryItems = queryItems + } + + guard let url = components.url else { + preconditionFailure("Failed to construct loop:// request for \(routeComponents)") + } + + self.url = url + } + + var serializedRequest: String { + url.absoluteString + } +} diff --git a/LoopCLI/CLIResponse.swift b/LoopCLI/CLIResponse.swift new file mode 100644 index 00000000..2fdbc553 --- /dev/null +++ b/LoopCLI/CLIResponse.swift @@ -0,0 +1,89 @@ +// +// CLIResponse.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import Foundation + +struct CLIActionDescriptor { + let id: UUID + let kind: String + let name: String + let slug: String + let urlPath: String + let idPath: String + + var idString: String { + id.uuidString.lowercased() + } +} + +struct CLIResponse { + let rawOutput: String + let jsonBody: [String: Any]? + let isSuccess: Bool + let errorMessage: String? + let replacement: String? + let availableCommands: [String] + let availableRoutes: [String] + let keybindActions: [CLIActionDescriptor] + + init(rawOutput: String) { + let trimmedOutput = rawOutput.trimmingCharacters(in: .whitespacesAndNewlines) + self.rawOutput = trimmedOutput + + if let data = trimmedOutput.data(using: .utf8), + let jsonBody = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + self.jsonBody = jsonBody + self.isSuccess = jsonBody["success"] as? Bool ?? false + self.errorMessage = jsonBody["error"] as? String + self.replacement = jsonBody["replacement"] as? String + self.availableCommands = Self.stringArray(from: jsonBody["availableCommands"]) + self.availableRoutes = Self.stringArray(from: jsonBody["availableRoutes"]) + self.keybindActions = Self.actionDescriptors(from: jsonBody["keybindActions"]) + } else { + self.jsonBody = nil + self.isSuccess = false + self.errorMessage = nil + self.replacement = nil + self.availableCommands = [] + self.availableRoutes = [] + self.keybindActions = [] + } + } + + private static func stringArray(from value: Any?) -> [String] { + (value as? [Any])?.compactMap { $0 as? String } ?? [] + } + + private static func actionDescriptors(from value: Any?) -> [CLIActionDescriptor] { + guard let objects = value as? [[String: Any]] else { + return [] + } + + return objects.compactMap { object in + guard + let idValue = object["id"] as? String, + let id = UUID(uuidString: idValue), + let kind = object["kind"] as? String, + let name = object["name"] as? String, + let slug = object["slug"] as? String, + let urlPath = object["urlPath"] as? String, + let idPath = object["idPath"] as? String + else { + return nil + } + + return CLIActionDescriptor( + id: id, + kind: kind, + name: name, + slug: slug, + urlPath: urlPath, + idPath: idPath + ) + } + } +} diff --git a/LoopCLI/ExecCommand.swift b/LoopCLI/ExecCommand.swift new file mode 100644 index 00000000..e8e0d45a --- /dev/null +++ b/LoopCLI/ExecCommand.swift @@ -0,0 +1,84 @@ +// +// ExecCommand.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import ArgumentParser +import Foundation + +struct ActionIdentifier: ExpressibleByArgument { + let value: UUID + + init?(argument: String) { + guard let value = UUID(uuidString: argument) else { + return nil + } + + self.value = value + } +} + +struct ExecCommand: ParsableCommand, CLIRequestCommand { + static let configuration = CommandConfiguration( + commandName: "exec", + abstract: "Execute a direction action, keybind-backed action, or UUID-addressed action" + ) + + @Option(name: .customLong("direction"), help: "Execute a built-in direction action") + var direction: String? + + @Option(name: .customLong("keybind"), help: "Execute a keybind-backed action by display name") + var keybind: String? + + @Option(name: .customLong("id"), help: "Execute any action by UUID") + var actionID: ActionIdentifier? + + @OptionGroup + var targetOptions: TargetOptions + + @OptionGroup + var outputOptions: OutputOptions + + var outputMode: CLIOutputMode { + outputOptions.outputMode + } + + func validate() throws { + let selectorCount = (direction == nil ? 0 : 1) + + (keybind == nil ? 0 : 1) + + (actionID == nil ? 0 : 1) + guard selectorCount == 1 else { + throw ValidationError("Exactly one of --direction, --keybind, or --id is required") + } + } + + func makeRequest(using application: LoopCLIApplication) throws -> CLIRequest { + let queryItems = targetOptions.queryItems + + if let direction { + return CLIRequest( + routeComponents: ["direction", direction], + queryItems: queryItems + ) + } + + if let keybind { + let descriptor = try application.resolveKeybind(named: keybind) + return CLIRequest( + routeComponents: ["id", descriptor.idString], + queryItems: queryItems + ) + } + + if let actionID { + return CLIRequest( + routeComponents: ["id", actionID.value.uuidString.lowercased()], + queryItems: queryItems + ) + } + + throw ValidationError("Exactly one of --direction, --keybind, or --id is required") + } +} diff --git a/LoopCLI/ListCommand.swift b/LoopCLI/ListCommand.swift new file mode 100644 index 00000000..32f17367 --- /dev/null +++ b/LoopCLI/ListCommand.swift @@ -0,0 +1,58 @@ +// +// ListCommand.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import ArgumentParser + +struct ListCommand: ParsableCommand, CLIRequestCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List windows, screens, or executable actions" + ) + + @Argument(help: "What to list") + var subject: ListSubject + + @Flag(name: .customLong("directions-only"), help: "List only built-in direction actions") + var directionsOnly = false + + @Flag(name: .customLong("keybinds-only"), help: "List only keybind-backed actions") + var keybindsOnly = false + + @OptionGroup + var outputOptions: OutputOptions + + var outputMode: CLIOutputMode { + outputOptions.outputMode + } + + func validate() throws { + if directionsOnly, keybindsOnly { + throw ValidationError("--directions-only and --keybinds-only are mutually exclusive") + } + + if subject != .actions, directionsOnly || keybindsOnly { + throw ValidationError("--directions-only and --keybinds-only are only valid with `list actions`") + } + } + + func makeRequest(using _: LoopCLIApplication) throws -> CLIRequest { + let routeComponents: [String] = switch subject { + case .windows: + ["list", "windows"] + case .screens: + ["list", "screens"] + case .actions where directionsOnly: + ["list", "actions", "directions"] + case .actions where keybindsOnly: + ["list", "actions", "keybinds"] + case .actions: + ["list", "actions"] + } + + return CLIRequest(routeComponents: routeComponents) + } +} diff --git a/LoopCLI/ListSubject.swift b/LoopCLI/ListSubject.swift new file mode 100644 index 00000000..b36e90f2 --- /dev/null +++ b/LoopCLI/ListSubject.swift @@ -0,0 +1,14 @@ +// +// ListSubject.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import ArgumentParser + +enum ListSubject: String, CaseIterable, ExpressibleByArgument { + case windows + case screens + case actions +} diff --git a/LoopCLI/LoopCLIApplication.swift b/LoopCLI/LoopCLIApplication.swift new file mode 100644 index 00000000..ca1f16c5 --- /dev/null +++ b/LoopCLI/LoopCLIApplication.swift @@ -0,0 +1,82 @@ +// +// LoopCLIApplication.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import Foundation + +final class LoopCLIApplication { + static let shared = LoopCLIApplication() + + static let executableName = URL( + fileURLWithPath: CommandLine.arguments.first ?? "loop-cli" + ).lastPathComponent + + private let socketClient: LoopSocketClient + private let errorFormatter: CLIErrorFormatter + private let outputFormatter: CLIOutputFormatter + + init( + socketClient: LoopSocketClient = LoopSocketClient(), + errorFormatter: CLIErrorFormatter = CLIErrorFormatter(executableName: LoopCLIApplication.executableName), + outputFormatter: CLIOutputFormatter = CLIOutputFormatter() + ) { + self.socketClient = socketClient + self.errorFormatter = errorFormatter + self.outputFormatter = outputFormatter + } + + func execute(_ request: CLIRequest, outputMode: CLIOutputMode) throws { + let response = try socketClient.send(request) + guard response.isSuccess else { + throw errorFormatter.error(from: response) + } + + print(outputFormatter.format(response, mode: outputMode)) + } + + func resolveKeybind(named displayName: String) throws -> CLIActionDescriptor { + let response = try socketClient.send( + CLIRequest(routeComponents: ["list", "actions", "keybinds"]) + ) + + guard response.isSuccess else { + throw errorFormatter.error(from: response) + } + + let normalizedDisplayName = displayName.trimmingCharacters(in: .whitespacesAndNewlines) + let matches = response.keybindActions.filter { + $0.name.caseInsensitiveCompare(normalizedDisplayName) == .orderedSame + } + + if let match = matches.only { + return match + } + + if matches.isEmpty { + throw errorFormatter.runtimeError( + """ + Unknown keybind name: \(displayName) + Try: \(Self.executableName) list actions --keybinds-only + """ + ) + } + + let matchingIdentifiers = matches.map(\.idString).joined(separator: ", ") + throw errorFormatter.runtimeError( + """ + Multiple keybind actions share the name "\(displayName)". + Matching IDs: \(matchingIdentifiers) + Try: \(Self.executableName) exec --id + """ + ) + } +} + +private extension Array { + var only: Element? { + count == 1 ? first : nil + } +} diff --git a/LoopCLI/LoopCLICommand.swift b/LoopCLI/LoopCLICommand.swift new file mode 100644 index 00000000..4a64cf17 --- /dev/null +++ b/LoopCLI/LoopCLICommand.swift @@ -0,0 +1,29 @@ +// +// LoopCLICommand.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import ArgumentParser + +struct LoopCLICommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: LoopCLIApplication.executableName, + abstract: "Command-line interface for Loop window manager.", + discussion: """ + Successful commands print human-readable text by default. Use --json to print raw JSON. + Failures print plain-text errors to stderr. + + Examples: + \(LoopCLIApplication.executableName) list windows + \(LoopCLIApplication.executableName) list windows --json + \(LoopCLIApplication.executableName) list actions --directions-only + \(LoopCLIApplication.executableName) exec --direction right + \(LoopCLIApplication.executableName) exec --direction right --json + \(LoopCLIApplication.executableName) exec --keybind "My Layout" + \(LoopCLIApplication.executableName) exec --id 123e4567-e89b-12d3-a456-426614174000 + """, + subcommands: [ListCommand.self, ExecCommand.self] + ) +} diff --git a/LoopCLI/LoopSocketClient.swift b/LoopCLI/LoopSocketClient.swift new file mode 100644 index 00000000..f5e23efd --- /dev/null +++ b/LoopCLI/LoopSocketClient.swift @@ -0,0 +1,90 @@ +// +// LoopSocketClient.swift +// LoopCLI +// +// Created by Kai Azim on 2026-03-29. +// + +import Foundation + +final class LoopSocketClient { + private struct SocketRuntimeError: LocalizedError { + let message: String + + var errorDescription: String? { + message + } + } + + private let socketPath: String + + init(socketPath: String = "/tmp/loop-\(getuid()).socket") { + self.socketPath = socketPath + } + + func send(_ request: CLIRequest) throws -> CLIResponse { + let fileDescriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard fileDescriptor >= 0 else { + throw SocketRuntimeError(message: "Failed to create socket") + } + defer { close(fileDescriptor) } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + + let pathBytes = socketPath.utf8CString + withUnsafeMutablePointer(to: &address.sun_path) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { destination in + pathBytes.withUnsafeBufferPointer { source in + _ = memcpy(destination, source.baseAddress!, source.count) + } + } + } + + let connectResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in + connect(fileDescriptor, socketAddress, socklen_t(MemoryLayout.size)) + } + } + + guard connectResult == 0 else { + throw SocketRuntimeError( + message: "Loop is not running (could not connect to \(socketPath))" + ) + } + + var timeout = timeval(tv_sec: 5, tv_usec: 0) + setsockopt(fileDescriptor, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + setsockopt(fileDescriptor, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + + let serializedRequest = request.serializedRequest + "\n" + let bytesSent = serializedRequest.utf8.withContiguousStorageIfAvailable { buffer in + Darwin.write(fileDescriptor, buffer.baseAddress!, buffer.count) + } ?? -1 + + guard bytesSent > 0 else { + throw SocketRuntimeError(message: "Failed to send request") + } + + var responseData = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + + while true { + let bytesRead = read(fileDescriptor, &buffer, buffer.count) + if bytesRead <= 0 { + break + } + + responseData.append(contentsOf: buffer[.. (response: String, success: Bool) { - let socketPath = "/tmp/loop-\(getuid()).socket" - - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { - return (makeError("Failed to create socket"), false) - } - defer { close(fd) } - - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - - let pathBytes = socketPath.utf8CString - withUnsafeMutablePointer(to: &addr.sun_path) { ptr in - ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in - pathBytes.withUnsafeBufferPointer { src in - _ = memcpy(dest, src.baseAddress!, src.count) - } - } - } - - let connectResult = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in - connect(fd, sockaddrPtr, socklen_t(MemoryLayout.size)) - } - } - - guard connectResult == 0 else { - return (makeError("Loop is not running (could not connect to \(socketPath))"), false) - } - - var timeout = timeval(tv_sec: 5, tv_usec: 0) - setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) - setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) - - let request = commandString + "\n" - let sent = request.utf8.withContiguousStorageIfAvailable { buffer in - Darwin.write(fd, buffer.baseAddress!, buffer.count) - } ?? -1 - - guard sent > 0 else { - return (makeError("Failed to send command"), false) - } - - var responseData = Data() - var buffer = [UInt8](repeating: 0, count: 4096) - - while true { - let bytesRead = read(fd, &buffer, buffer.count) - if bytesRead <= 0 { break } - responseData.append(contentsOf: buffer[.. String { - #"{"success":false,"error":"\#(message)"}"# -} - -// MARK: - Command Building - -/// Builds a raw command string from CLI arguments. -/// Arguments are shell-escaped so quoted values such as `--keybind "My Layout"` -/// survive the socket transport and can be re-tokenized in the app. -func buildCommand(from args: [String]) -> String? { - guard !args.isEmpty else { return nil } - - let flagsRequiringValues: Set = [ - "--window-id", - "--bundle-id", - "--screen-id", - "--direction", - "--keybind", - "--id" - ] - - var index = 0 - while index < args.count { - if flagsRequiringValues.contains(args[index]) { - guard index + 1 < args.count else { - fputs("Error: \(args[index]) requires a value\n", stderr) - return nil - } - index += 2 - } else { - index += 1 - } - } - - return args.map(escapeArgument).joined(separator: " ") -} - -func escapeArgument(_ argument: String) -> String { - if argument.isEmpty { - return "\"\"" - } - - let escaped = argument - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - - return escaped.contains(where: \.isWhitespace) || escaped.contains("\"") - ? "\"\(escaped)\"" - : escaped -} - -// MARK: - Help - -let executableName = URL(fileURLWithPath: CommandLine.arguments.first ?? "loop-cli").lastPathComponent - -let helpText = """ -\(executableName) — Command-line interface for Loop window manager - -USAGE: - \(executableName) list [--directions-only | --keybinds-only] - \(executableName) exec --direction | --keybind | --id [options] - -READ COMMANDS: - list windows List all visible windows - list screens List all connected screens - list actions List all executable actions - list actions --directions-only List only built-in direction actions - list actions --keybinds-only List only keybind-backed actions - -WRITE COMMANDS: - exec --direction Execute a built-in direction action - exec --keybind Execute a keybind-backed action by display name - exec --id Execute any action by UUID - -TARGET OPTIONS: - --window-id Target a specific window by ID (from `list windows`) - --bundle-id Target an app by bundle identifier (launches if needed) - --screen-id Target a specific screen by ID (from `list screens`) - -OTHER OPTIONS: - --help, -h Show this help message - -EXAMPLES: - \(executableName) list windows - \(executableName) list actions --directions-only - \(executableName) exec --direction right - \(executableName) exec --direction next_screen --bundle-id com.apple.Safari - \(executableName) exec --keybind "My Layout" - \(executableName) exec --id 123e4567-e89b-12d3-a456-426614174000 - -All commands return JSON. Exit code is 0 on success, 1 on failure. -""" - -// MARK: - Main - -let args = Array(CommandLine.arguments.dropFirst()) - -if args.isEmpty || args.contains("--help") || args.contains("-h") { - print(helpText) - exit(args.isEmpty ? 1 : 0) -} - -guard let command = buildCommand(from: args) else { - fputs("Error: Invalid command. Run '\(executableName) --help' for usage.\n", stderr) - exit(1) -} - -let (response, success) = sendCommand(command) -print(response) -exit(success ? 0 : 1) +LoopCLICommand.main() diff --git a/README.md b/README.md index f92547b6..e646a501 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,12 @@ open "loop://list/actions/directions" # List built-in direction actions open "loop://list/actions/keybinds" # List keybind-backed actions ``` +You can also execute an action directly by UUID when you already have one from `list/actions`: + +```bash +open "loop://id/123e4567-e89b-12d3-a456-426614174000" +``` + For machine-readable shell output, install the CLI from Loop's Advanced tab. This creates `/usr/local/bin/loop`, which points at the bundled `loop-cli` binary: ```bash @@ -155,8 +161,12 @@ loop list actions --directions-only loop exec --direction right loop exec --keybind "My Layout" loop exec --id 123e4567-e89b-12d3-a456-426614174000 +loop list windows --json +loop exec --direction right --json ``` +Successful `loop` commands print human-readable structured text by default. Pass `--json` to print the raw JSON response. Runtime failures print plain-text errors to `stderr`, and local usage errors are handled by the CLI's built-in help and validation output. + ### Keyboard Shortcuts From 576b7d19e9bef3396931f1d82c37cb4114b95525 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Tue, 31 Mar 2026 00:40:22 -0600 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=8E=A8=20Type-safe=20JSON=20coding=20?= =?UTF-8?q?between=20CLI=20and=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Loop.xcodeproj/project.pbxproj | 1 + Loop/Localizable.xcstrings | 33 +- Loop/Scripting/LoopCommandHandler.swift | 569 +++++++++++------------- Loop/Scripting/LoopSocketManager.swift | 24 +- LoopCLI/CLIErrorFormatter.swift | 17 +- LoopCLI/CLIOutputFormatter.swift | 500 ++++++--------------- LoopCLI/CLIRequest.swift | 14 +- LoopCLI/CLIResponse.swift | 78 +--- LoopCLI/ExecCommand.swift | 7 +- LoopCLI/ListCommand.swift | 22 +- LoopCLI/LoopCLIApplication.swift | 8 +- LoopCLI/LoopCLICommand.swift | 3 +- README.md | 5 +- Shared/LoopAutomationJSON.swift | 39 ++ Shared/LoopAutomationModels.swift | 264 +++++++++++ 15 files changed, 789 insertions(+), 795 deletions(-) create mode 100644 Shared/LoopAutomationJSON.swift create mode 100644 Shared/LoopAutomationModels.swift diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 2bef4611..c65422ed 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -254,6 +254,7 @@ dependencies = ( ); fileSystemSynchronizedGroups = ( + 2A6A87F02F4D20D2004E995D /* Shared */, C1BB00022F30000200AABBCC /* LoopCLI */, ); name = LoopCLI; diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 65683dda..890670a8 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -620,6 +620,17 @@ } } } + }, + "`/usr/local/bin/loop` is already in use by another file or symlink. Remove it manually before installing Loop CLI." : { + "comment" : "Description of a `Status` case when the `/usr/local/bin/loop` symlink is blocked.", + "isCommentAutoGenerated" : true + }, + "`/usr/local/bin/loop` is installed and points to this version of Loop." : { + "comment" : "Description of a `Status` case when the installed `/usr/local/bin/loop` is the same as the one currently running.", + "isCommentAutoGenerated" : true + }, + "`/usr/local/bin/loop` points to a different or moved version of Loop. Repair it to update the symlink." : { + }, "A single %@ action can only track one window. To stash\nmultiple windows, add additional %@ actions." : { "comment" : "Information in a popover displaying how a stash action can only keep track of a single window. Both %1$@ and %2$@ are replaced with the language's localization of the \"Stash\" action.", @@ -12226,9 +12237,6 @@ } } } - }, - "Install CLI" : { - }, "Install failed" : { "comment" : "The text that appears when the update fails to install.", @@ -12497,6 +12505,14 @@ } } }, + "Install…" : { + "comment" : "The title of a button that installs the Loop command-line tool.", + "isCommentAutoGenerated" : true + }, + "Installs `/usr/local/bin/loop` to run Loop from the shell." : { + "comment" : "Description of the status when the Loop CLI is not installed.", + "isCommentAutoGenerated" : true + }, "Instant" : { "comment" : "Animation speed setting", "localizations" : { @@ -16470,6 +16486,10 @@ } } }, + "Loop CLI" : { + "comment" : "The name of the command-line interface for the app.", + "isCommentAutoGenerated" : true + }, "Loops left to unlock new icon" : { "localizations" : { "ar" : { @@ -24961,9 +24981,6 @@ } } } - }, - "Re-install CLI" : { - }, "Relaunch to complete" : { "comment" : "A button title that appears when an update is being installed, instructing the user to relaunch the app to complete the installation.", @@ -25320,6 +25337,10 @@ } } }, + "Repair…" : { + "comment" : "The text that appears when the \"Repair…\" button is tapped, indicating that the user is being prompted to reinstall the command-line tool.", + "isCommentAutoGenerated" : true + }, "Request…" : { "comment" : "Button to request accessibility access", "localizations" : { diff --git a/Loop/Scripting/LoopCommandHandler.swift b/Loop/Scripting/LoopCommandHandler.swift index 531a13c3..e7acb3f7 100644 --- a/Loop/Scripting/LoopCommandHandler.swift +++ b/Loop/Scripting/LoopCommandHandler.swift @@ -22,6 +22,10 @@ Socket / CLI transport: - loop-cli parses CLI arguments locally and sends canonical loop:// URLs over the socket + Response JSON: + - success responses use `{ "success": true, "result": { ... } }` + - failures use `{ "success": false, "error": { "message": "...", ... } }` + Query parameters: - ?windowID= - ?bundleID= @@ -99,15 +103,26 @@ final class LoopCommandHandler { } } - private enum ListActionFilter { + private enum ListActionFilter: Equatable { case all case directionsOnly case keybindsOnly + + var automationFilter: LoopActionListFilter { + switch self { + case .all: + .all + case .directionsOnly: + .directionsOnly + case .keybindsOnly: + .keybindsOnly + } + } } private enum ResponseResult { case success(Value) - case failure([String: Any]) + case failure(LoopAutomationResponse) } private enum MessageResult { @@ -165,6 +180,15 @@ final class LoopCommandHandler { case direction(DirectionActionDescriptor) case keybind(KeybindActionDescriptor) + var id: UUID { + switch self { + case let .direction(descriptor): + descriptor.id + case let .keybind(descriptor): + descriptor.id + } + } + var idString: String { switch self { case let .direction(descriptor): @@ -201,6 +225,15 @@ final class LoopCommandHandler { } } + var actionKind: LoopActionKind { + switch self { + case .direction: + .direction + case .keybind: + .keybind + } + } + var urlPath: String { switch self { case let .direction(descriptor): @@ -266,10 +299,9 @@ final class LoopCommandHandler { source: source, kind: .write, components: [], - response: [ - "success": false, - "error": "Invalid scheme: \(url.scheme ?? "nil"). Required: loop://" - ] + response: failureResponse( + message: "Invalid scheme: \(url.scheme ?? "nil"). Required: loop://" + ) ) } @@ -295,10 +327,7 @@ final class LoopCommandHandler { source: source, kind: .write, components: [], - response: [ - "success": false, - "error": "Invalid request URL: \(request)" - ] + response: failureResponse(message: "Invalid request URL: \(request)") ) } @@ -317,10 +346,7 @@ final class LoopCommandHandler { source: source, kind: .write, components: components, - response: [ - "success": false, - "error": "windowID and bundleID are mutually exclusive" - ] + response: failureResponse(message: "windowID and bundleID are mutually exclusive") ) } @@ -392,7 +418,7 @@ final class LoopCommandHandler { // MARK: - List Commands - private func handleListCommand(_ parameters: [String]) -> [String: Any] { + private func handleListCommand(_ parameters: [String]) -> LoopAutomationResponse { guard let type = parameters.first?.lowercased() else { return invalidListRootResponse() } @@ -450,32 +476,22 @@ final class LoopCommandHandler { } } - private func buildActionsResponse(filter: ListActionFilter) -> [String: Any] { - let directionActions = buildDirectionActionCategoriesJSON() - let keybindActions = keybindActionDescriptors().map(keybindActionJSON) - - var response: [String: Any] = [ - "success": true, - "command": "list", - "type": "actions" - ] - - switch filter { - case .all: - response["directionActions"] = directionActions - response["keybindActions"] = keybindActions - case .directionsOnly: - response["subtype"] = "directions" - response["directionActions"] = directionActions - case .keybindsOnly: - response["subtype"] = "keybinds" - response["keybindActions"] = keybindActions + private func buildActionsResponse(filter: ListActionFilter) -> LoopAutomationResponse { + let allDirectionCategories = buildDirectionActionCategories() + let allKeybindActions = keybindActionDescriptors().map { descriptor in + sharedActionDescriptor(.keybind(descriptor)) } - return response + let result = LoopActionListResult( + filter: filter.automationFilter, + directionCategories: filter == .keybindsOnly ? [] : allDirectionCategories, + keybindActions: filter == .directionsOnly ? [] : allKeybindActions + ) + + return LoopAutomationResponse(result: .actionList(result)) } - private func buildWindowListResponse() -> [String: Any] { + private func buildWindowListResponse() -> LoopAutomationResponse { let visibleWindows = WindowUtility.windowList().filter { window in guard let app = window.nsRunningApplication else { return false @@ -487,172 +503,127 @@ final class LoopCommandHandler { && !window.minimized } - return [ - "success": true, - "command": "list", - "type": "windows", - "windowCount": visibleWindows.count, - "windows": visibleWindows.map(windowJSON) - ] + return LoopAutomationResponse( + result: .windowList( + LoopWindowListResult( + windows: visibleWindows.map(windowSummary) + ) + ) + ) } - private func buildScreenListResponse() -> [String: Any] { + private func buildScreenListResponse() -> LoopAutomationResponse { let screens = NSScreen.screens - return [ - "success": true, - "command": "list", - "type": "screens", - "screenCount": screens.count, - "screens": screens.map { screen in - [ - "screenID": screen.displayID ?? 0, - "name": screen.localizedName, - "frame": [ - "x": Int(screen.frame.origin.x), - "y": Int(screen.frame.origin.y), - "width": Int(screen.frame.width), - "height": Int(screen.frame.height) - ], - "isMain": screen == NSScreen.main - ] as [String: Any] - } - ] + return LoopAutomationResponse( + result: .screenList( + LoopScreenListResult( + screens: screens.map(screenSummary) + ) + ) + ) } // MARK: - Write Commands - private func handleDirectionCommand(_ parameters: [String], params: TargetParams) -> [String: Any] { + private func handleDirectionCommand(_ parameters: [String], params: TargetParams) -> LoopAutomationResponse { guard parameters.count == 1 else { - return [ - "success": false, - "command": "direction", - "error": "Direction execution requires exactly one slug", - "replacement": urlCommandString(["list", "actions", "directions"]) - ] + return failureResponse( + message: "Direction execution requires exactly one slug", + replacementRoute: urlCommandString(["list", "actions", "directions"]) + ) } let token = parameters[0] if let descriptor = directionActionDescriptor(slug: token) { - return executeAction(.direction(descriptor), params: params, command: "direction") + return executeAction(.direction(descriptor), params: params) } if let descriptor = legacyDirectionDescriptor(for: token) { - return migrationErrorResponse( - command: "direction", - error: "Use the canonical direction slug", - replacement: urlCommandString(["direction", descriptor.slug]) + return failureResponse( + message: "Use the canonical direction slug", + replacementRoute: urlCommandString(["direction", descriptor.slug]) ) } - return [ - "success": false, - "command": "direction", - "error": "Unknown direction slug: \(token)", - "replacement": urlCommandString(["list", "actions", "directions"]) - ] + return failureResponse( + message: "Unknown direction slug: \(token)", + replacementRoute: urlCommandString(["list", "actions", "directions"]) + ) } - private func handleKeybindCommand(_ parameters: [String], params: TargetParams) -> [String: Any] { + private func handleKeybindCommand(_ parameters: [String], params: TargetParams) -> LoopAutomationResponse { guard parameters.count == 1 else { - return [ - "success": false, - "command": "keybind", - "error": "Keybind execution requires exactly one slug", - "replacement": urlCommandString(["list", "actions", "keybinds"]) - ] + return failureResponse( + message: "Keybind execution requires exactly one slug", + replacementRoute: urlCommandString(["list", "actions", "keybinds"]) + ) } let token = parameters[0] if let descriptor = keybindActionDescriptor(slug: token) { - return executeAction(.keybind(descriptor), params: params, command: "keybind") + return executeAction(.keybind(descriptor), params: params) } let legacyMatches = legacyKeybindDescriptors(for: token) if legacyMatches.count == 1, let descriptor = legacyMatches.first { - return migrationErrorResponse( - command: "keybind", - error: "Use the canonical keybind slug", - replacement: urlCommandString(["keybind", descriptor.slug]) + return failureResponse( + message: "Use the canonical keybind slug", + replacementRoute: urlCommandString(["keybind", descriptor.slug]) ) } if legacyMatches.count > 1 { - return [ - "success": false, - "command": "keybind", - "error": "Multiple keybind actions match \(token). Use list/actions/keybinds to find the canonical slug." - ] - } - - return [ - "success": false, - "command": "keybind", - "error": "Unknown keybind slug: \(token)", - "replacement": urlCommandString(["list", "actions", "keybinds"]) - ] + return failureResponse( + message: "Multiple keybind actions match \(token). Use list/actions/keybinds to find the canonical slug." + ) + } + + return failureResponse( + message: "Unknown keybind slug: \(token)", + replacementRoute: urlCommandString(["list", "actions", "keybinds"]) + ) } - private func handleIDCommand(_ parameters: [String], params: TargetParams) -> [String: Any] { + private func handleIDCommand(_ parameters: [String], params: TargetParams) -> LoopAutomationResponse { guard parameters.count == 1 else { - return [ - "success": false, - "command": "id", - "error": "ID execution requires exactly one UUID", - "replacement": urlCommandString(["list", "actions"]) - ] + return failureResponse( + message: "ID execution requires exactly one UUID", + replacementRoute: urlCommandString(["list", "actions"]) + ) } let token = parameters[0] guard let identifier = UUID(uuidString: token) else { - return [ - "success": false, - "command": "id", - "error": "Invalid UUID: \(token)", - "replacement": urlCommandString(["list", "actions"]) - ] + return failureResponse( + message: "Invalid UUID: \(token)", + replacementRoute: urlCommandString(["list", "actions"]) + ) } guard let descriptor = executableActionDescriptor(id: identifier) else { - return [ - "success": false, - "command": "id", - "error": "Unknown action ID: \(token)", - "replacement": urlCommandString(["list", "actions"]) - ] + return failureResponse( + message: "Unknown action ID: \(token)", + replacementRoute: urlCommandString(["list", "actions"]) + ) } - return executeAction(descriptor, params: params, command: "id") + return executeAction(descriptor, params: params) } private func executeAction( _ descriptor: ExecutableActionDescriptor, - params: TargetParams, - command: String - ) -> [String: Any] { + params: TargetParams + ) -> LoopAutomationResponse { let action = descriptor.windowAction let resolvedWindow = resolveWindow(params: params) let resolvedAction = resolveActionForCommandExecution(action, window: resolvedWindow) if resolvedAction.direction.isNoOp || resolvedAction.direction == .cycle { - return [ - "success": false, - "command": command, - "id": descriptor.idString, - "name": descriptor.name, - "kind": descriptor.kind, - "error": "Action is not executable: \(descriptor.name)" - ] + return failureResponse(message: "Action is not executable: \(descriptor.name)") } if !resolvedAction.direction.willFocusWindow, resolvedWindow == nil { - return [ - "success": false, - "command": command, - "id": descriptor.idString, - "name": descriptor.name, - "kind": descriptor.kind, - "error": windowResolveError(params) - ] + return failureResponse(message: windowResolveError(params)) } let targetScreen: NSScreen @@ -660,71 +631,34 @@ final class LoopCommandHandler { case let .success(screen): targetScreen = screen case let .failure(error): - return [ - "success": false, - "command": command, - "id": descriptor.idString, - "name": descriptor.name, - "kind": descriptor.kind, - "error": error - ] + return failureResponse(message: error) } dispatchAction(resolvedAction, on: resolvedWindow, screen: targetScreen) - var response: [String: Any] = [ - "success": true, - "command": command, - "id": descriptor.idString, - "kind": descriptor.kind, - "name": descriptor.name, - "slug": descriptor.slug, - "urlPath": descriptor.urlPath, - "idPath": descriptor.idPath - ] - - if let window = resolvedWindow { - response["window"] = windowJSON(window) - } - - return response + return LoopAutomationResponse( + result: .execution( + LoopExecutionResult( + action: sharedActionDescriptor(descriptor), + targetWindow: resolvedWindow.map(executionTargetWindowSummary) + ) + ) + ) } // MARK: - Action Catalog - private func buildDirectionActionCategoriesJSON() -> [[String: Any]] { + private func buildDirectionActionCategories() -> [LoopActionCategory] { Self.directionCategories.map { category, directions in - [ - "category": category, - "actions": directions.map { direction in - directionActionJSON(directionActionDescriptor(for: direction)) + LoopActionCategory( + name: category, + actions: directions.map { direction in + sharedActionDescriptor(.direction(directionActionDescriptor(for: direction))) } - ] + ) } } - private func directionActionJSON(_ descriptor: DirectionActionDescriptor) -> [String: Any] { - [ - "id": descriptor.idString, - "kind": "direction", - "name": descriptor.name, - "slug": descriptor.slug, - "urlPath": descriptor.urlPath, - "idPath": descriptor.idPath - ] - } - - private func keybindActionJSON(_ descriptor: KeybindActionDescriptor) -> [String: Any] { - [ - "id": descriptor.idString, - "kind": "keybind", - "name": descriptor.name, - "slug": descriptor.slug, - "urlPath": descriptor.urlPath, - "idPath": descriptor.idPath - ] - } - private func allDirectionActionDescriptors() -> [DirectionActionDescriptor] { Self.directionCategories.flatMap { category, directions in directions.map { direction in @@ -842,25 +776,23 @@ final class LoopCommandHandler { private func removedLegacyCommandResponse( command: String, parameters: [String] - ) -> (kind: CommandKind, response: [String: Any])? { + ) -> (kind: CommandKind, response: LoopAutomationResponse)? { switch command { case "windowlist": ( .read, - migrationErrorResponse( - command: "windowlist", - error: "windowlist has been removed", - replacement: listRouteReplacement(["windows"]) + failureResponse( + message: "windowlist has been removed", + replacementRoute: listRouteReplacement(["windows"]) ) ) case "screenlist": ( .read, - migrationErrorResponse( - command: "screenlist", - error: "screenlist has been removed", - replacement: listRouteReplacement(["screens"]) + failureResponse( + message: "screenlist has been removed", + replacementRoute: listRouteReplacement(["screens"]) ) ) @@ -887,91 +819,81 @@ final class LoopCommandHandler { } } - private func removedExecuteResponse(parameters: [String]) -> [String: Any] { + private func removedExecuteResponse(parameters: [String]) -> LoopAutomationResponse { if parameters.count == 1, let identifier = UUID(uuidString: parameters[0]), let descriptor = executableActionDescriptor(id: identifier) { - return migrationErrorResponse( - command: "execute", - error: "execute has been removed", - replacement: urlCommandString(["id", descriptor.idString]) + return failureResponse( + message: "execute has been removed", + replacementRoute: urlCommandString(["id", descriptor.idString]) ) } if parameters.count == 2, parameters[0].lowercased() == "direction", let descriptor = legacyDirectionDescriptor(for: parameters[1]) { - return migrationErrorResponse( - command: "execute", - error: "execute has been removed", - replacement: replacementCommand(for: .direction(descriptor)) + return failureResponse( + message: "execute has been removed", + replacementRoute: replacementCommand(for: .direction(descriptor)) ) } if parameters.count == 2, parameters[0].lowercased() == "keybind", let descriptor = keybindActionDescriptor(slug: parameters[1]) ?? legacyKeybindDescriptors(for: parameters[1]).only { - return migrationErrorResponse( - command: "execute", - error: "execute has been removed", - replacement: replacementCommand(for: .keybind(descriptor)) + return failureResponse( + message: "execute has been removed", + replacementRoute: replacementCommand(for: .keybind(descriptor)) ) } - return migrationErrorResponse( - command: "execute", - error: "execute has been removed", + return failureResponse( + message: "execute has been removed", availableRoutes: publicWriteRoutes() ) } - private func removedScreenResponse(parameters: [String]) -> [String: Any] { + private func removedScreenResponse(parameters: [String]) -> LoopAutomationResponse { if let parameter = parameters.first, let descriptor = legacyDirectionDescriptor(for: parameter) { - return migrationErrorResponse( - command: "screen", - error: "screen has been removed", - replacement: replacementCommand(for: .direction(descriptor)) + return failureResponse( + message: "screen has been removed", + replacementRoute: replacementCommand(for: .direction(descriptor)) ) } - return migrationErrorResponse( - command: "screen", - error: "screen has been removed", - replacement: urlCommandString(["list", "actions", "directions"]) + return failureResponse( + message: "screen has been removed", + replacementRoute: urlCommandString(["list", "actions", "directions"]) ) } - private func removedActionResponse(parameters: [String]) -> [String: Any] { + private func removedActionResponse(parameters: [String]) -> LoopAutomationResponse { if parameters.isEmpty || parameters.first?.lowercased() == "list" { - return migrationErrorResponse( - command: "action", - error: "action has been removed", - replacement: listRouteReplacement(["actions"]) + return failureResponse( + message: "action has been removed", + replacementRoute: listRouteReplacement(["actions"]) ) } if let descriptor = legacyDirectionDescriptor(for: parameters[0]) { - return migrationErrorResponse( - command: "action", - error: "action has been removed", - replacement: replacementCommand(for: .direction(descriptor)) + return failureResponse( + message: "action has been removed", + replacementRoute: replacementCommand(for: .direction(descriptor)) ) } let keybindMatches = legacyKeybindDescriptors(for: parameters[0]) if let descriptor = keybindMatches.only { - return migrationErrorResponse( - command: "action", - error: "action has been removed", - replacement: replacementCommand(for: .keybind(descriptor)) + return failureResponse( + message: "action has been removed", + replacementRoute: replacementCommand(for: .keybind(descriptor)) ) } - return migrationErrorResponse( - command: "action", - error: "action has been removed", - replacement: listRouteReplacement(["actions"]) + return failureResponse( + message: "action has been removed", + replacementRoute: listRouteReplacement(["actions"]) ) } // MARK: - Response Helpers - private func publicCommandNames() -> [String] { - ["list", "direction", "keybind", "id"] + private func publicRoutes() -> [String] { + publicListRoutes() + publicWriteRoutes() } private func publicListRoutes() -> [String] { @@ -1006,96 +928,78 @@ final class LoopCommandHandler { urlCommandString(descriptor.urlPath.split(separator: "/").map(String.init)) } - private func migrationErrorResponse( - command: String, - error: String, - replacement: String? = nil, + private func failureResponse( + message: String, + replacementRoute: String? = nil, availableRoutes: [String] = [] - ) -> [String: Any] { - var response: [String: Any] = [ - "success": false, - "command": command, - "error": error - ] - - if let replacement { - response["replacement"] = replacement - } - - if !availableRoutes.isEmpty { - response["availableRoutes"] = availableRoutes - } - - return response + ) -> LoopAutomationResponse { + LoopAutomationResponse( + error: LoopAutomationError( + message: message, + replacementRoute: replacementRoute, + availableRoutes: availableRoutes.isEmpty ? nil : availableRoutes + ) + ) } - private func invalidListRootResponse() -> [String: Any] { - migrationErrorResponse( - command: "list", - error: "No list type specified", + private func invalidListRootResponse() -> LoopAutomationResponse { + failureResponse( + message: "No list type specified", availableRoutes: publicListRoutes() ) } - private func removedListAllResponse() -> [String: Any] { - migrationErrorResponse( - command: "list", - error: "list/all has been removed", + private func removedListAllResponse() -> LoopAutomationResponse { + failureResponse( + message: "list/all has been removed", availableRoutes: publicListRoutes() ) } - private func removedListKeybindsResponse() -> [String: Any] { - migrationErrorResponse( - command: "list", - error: "list/keybinds has been removed", - replacement: listRouteReplacement(["actions", "keybinds"]) + private func removedListKeybindsResponse() -> LoopAutomationResponse { + failureResponse( + message: "list/keybinds has been removed", + replacementRoute: listRouteReplacement(["actions", "keybinds"]) ) } - private func invalidListRouteResponse(_ parameters: [String]) -> [String: Any] { - migrationErrorResponse( - command: "list", - error: "Unknown list route: list/\(parameters.joined(separator: "/"))", + private func invalidListRouteResponse(_ parameters: [String]) -> LoopAutomationResponse { + failureResponse( + message: "Unknown list route: list/\(parameters.joined(separator: "/"))", availableRoutes: publicListRoutes() ) } - private func unknownCommandResponse(_ command: String?) -> [String: Any] { - [ - "success": false, - "error": "Unknown command: \(command ?? "nil")", - "availableCommands": publicCommandNames() - ] + private func unknownCommandResponse(_ command: String?) -> LoopAutomationResponse { + failureResponse( + message: "Unknown command: \(command ?? "nil")", + availableRoutes: publicRoutes() + ) } // MARK: - JSON Helpers - /// Serializes a response dictionary to a pretty-printed JSON string. - private func jsonString(_ dict: [String: Any]) -> String { - guard let data = try? JSONSerialization.data( - withJSONObject: dict, - options: [.prettyPrinted, .sortedKeys] - ) else { - return #"{"success":false,"error":"Failed to serialize response"}"# + private func jsonString(_ response: LoopAutomationResponse) -> String { + do { + return try LoopAutomationJSON.encodeString(response) + } catch { + return #"{"error":{"message":"Failed to serialize response"},"success":false}"# } - return String(data: data, encoding: .utf8) - ?? #"{"success":false,"error":"Failed to encode response"}"# } private func makeExecutionResult( source: InvocationSource, kind: CommandKind, components: [String], - response: [String: Any] + response: LoopAutomationResponse ) -> CommandExecutionResult { CommandExecutionResult( source: source, kind: kind, title: outputTitle(for: components), jsonResponse: jsonString(response), - isSuccess: response["success"] as? Bool ?? false, - errorMessage: response["error"] as? String + isSuccess: response.success, + errorMessage: response.error?.message ) } @@ -1104,22 +1008,45 @@ final class LoopCommandHandler { return commandPath.isEmpty ? "Loop Output" : "Loop Output: \(commandPath)" } - /// Builds a JSON-serializable dictionary for a window. - private func windowJSON(_ window: Window) -> [String: Any] { + private func windowSummary(_ window: Window) -> LoopWindowSummary { let app = window.nsRunningApplication - let frame = window.frame - return [ - "windowID": window.cgWindowID, - "bundleID": app?.bundleIdentifier ?? "", - "appName": app?.localizedName ?? "", - "windowTitle": window.title ?? "", - "frame": [ - "x": Int(frame.origin.x), - "y": Int(frame.origin.y), - "width": Int(frame.width), - "height": Int(frame.height) - ] - ] + return LoopWindowSummary( + id: window.cgWindowID, + bundleID: app?.bundleIdentifier ?? "", + appName: app?.localizedName ?? "", + title: window.title ?? "", + frame: LoopRect(window.frame) + ) + } + + private func executionTargetWindowSummary(_ window: Window) -> LoopExecutionTargetWindow { + let app = window.nsRunningApplication + return LoopExecutionTargetWindow( + id: window.cgWindowID, + bundleID: app?.bundleIdentifier ?? "", + appName: app?.localizedName ?? "", + title: window.title ?? "" + ) + } + + private func screenSummary(_ screen: NSScreen) -> LoopScreenSummary { + LoopScreenSummary( + id: screen.displayID ?? 0, + name: screen.localizedName, + frame: LoopRect(screen.frame), + isMain: screen == NSScreen.main + ) + } + + private func sharedActionDescriptor(_ descriptor: ExecutableActionDescriptor) -> LoopActionDescriptor { + LoopActionDescriptor( + id: descriptor.id, + kind: descriptor.actionKind, + name: descriptor.name, + slug: descriptor.slug, + route: urlCommandString(descriptor.urlPath.split(separator: "/").map(String.init)), + idRoute: urlCommandString(descriptor.idPath.split(separator: "/").map(String.init)) + ) } // MARK: - Slug and ID Helpers diff --git a/Loop/Scripting/LoopSocketManager.swift b/Loop/Scripting/LoopSocketManager.swift index da64d3ea..04baaad7 100644 --- a/Loop/Scripting/LoopSocketManager.swift +++ b/Loop/Scripting/LoopSocketManager.swift @@ -164,7 +164,7 @@ final class LoopSocketManager { } guard totalRead > 0 else { - writeResponse(clientFD, #"{"success":false,"error":"Empty request"}"#) + writeResponse(clientFD, encodedErrorResponse(message: "Empty request")) return } @@ -173,7 +173,7 @@ final class LoopSocketManager { .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !requestString.isEmpty else { - writeResponse(clientFD, #"{"success":false,"error":"Empty request"}"#) + writeResponse(clientFD, encodedErrorResponse(message: "Empty request")) return } @@ -183,7 +183,7 @@ final class LoopSocketManager { DispatchQueue.main.async { [weak self] in guard let self else { - response = #"{"success":false,"error":"Server shutting down"}"# + response = Self.fallbackEncodedErrorResponse(message: "Server shutting down") semaphore.signal() return } @@ -194,12 +194,28 @@ final class LoopSocketManager { _ = semaphore.wait(timeout: .now() + Self.connectionTimeout) if response.isEmpty { - response = #"{"success":false,"error":"Request timed out"}"# + response = encodedErrorResponse(message: "Request timed out") } writeResponse(clientFD, response) } + private func encodedErrorResponse(message: String) -> String { + do { + return try LoopAutomationJSON.encodeString( + LoopAutomationResponse( + error: LoopAutomationError(message: message) + ) + ) + } catch { + return Self.fallbackEncodedErrorResponse(message: message) + } + } + + private static func fallbackEncodedErrorResponse(message: String) -> String { + #"{"error":{"message":"\#(message)"},"success":false}"# + } + private func writeResponse(_ fd: Int32, _ response: String) { let data = response + "\n" data.utf8.withContiguousStorageIfAvailable { buffer in diff --git a/LoopCLI/CLIErrorFormatter.swift b/LoopCLI/CLIErrorFormatter.swift index 07c03d89..2fb5aed2 100644 --- a/LoopCLI/CLIErrorFormatter.swift +++ b/LoopCLI/CLIErrorFormatter.swift @@ -33,7 +33,7 @@ struct CLIErrorFormatter { func error(from response: CLIResponse) -> CLICommandError { var lines: [String] = [] - if let errorMessage = response.errorMessage, !errorMessage.isEmpty { + if let errorMessage = response.automationError?.message, !errorMessage.isEmpty { lines.append(errorMessage) } else if !response.rawOutput.isEmpty { lines.append(response.rawOutput) @@ -41,16 +41,13 @@ struct CLIErrorFormatter { lines.append("Command failed") } - if let replacement = response.replacement, !replacement.isEmpty { + if let replacement = response.automationError?.replacementRoute, !replacement.isEmpty { lines.append("Try: \(displayString(for: replacement))") } - if !response.availableCommands.isEmpty { - lines.append("Available commands: \(response.availableCommands.joined(separator: ", "))") - } - - if !response.availableRoutes.isEmpty { - let displayedRoutes = response.availableRoutes.map(displayString) + let availableRoutes = response.automationError?.availableRoutes ?? [] + if !availableRoutes.isEmpty { + let displayedRoutes = availableRoutes.map(displayString) lines.append("Available routes: \(displayedRoutes.joined(separator: ", "))") } @@ -76,9 +73,9 @@ struct CLIErrorFormatter { case ["list", "actions"]: return "\(executableName) list actions" case ["list", "actions", "directions"]: - return "\(executableName) list actions --directions-only" + return "\(executableName) list actions --directions" case ["list", "actions", "keybinds"]: - return "\(executableName) list actions --keybinds-only" + return "\(executableName) list actions --keybinds" default: break } diff --git a/LoopCLI/CLIOutputFormatter.swift b/LoopCLI/CLIOutputFormatter.swift index a1fb9826..284974d0 100644 --- a/LoopCLI/CLIOutputFormatter.swift +++ b/LoopCLI/CLIOutputFormatter.swift @@ -5,370 +5,222 @@ // Created by Kai Azim on 2026-03-30. // +import Darwin import Foundation +import CoreGraphics struct CLIOutputFormatter { - func format(_ response: CLIResponse, mode: CLIOutputMode) -> String { - switch mode { + private let supportsANSIStyle = isatty(STDOUT_FILENO) != 0 + && ProcessInfo.processInfo.environment["NO_COLOR"] == nil + && ProcessInfo.processInfo.environment["TERM"]?.lowercased() != "dumb" + + func format(_ response: CLIResponse, configuration: CLIOutputConfiguration) -> String { + switch configuration.mode { case .json: return response.rawOutput case .human: - guard let jsonBody = response.jsonBody else { + guard let result = response.result else { return response.rawOutput } - return formatHuman(jsonBody) ?? formatGenericValue(jsonBody, indentLevel: 0) - } - } - - private func formatHuman(_ body: [String: Any]) -> String? { - guard let command = stringValue(body["command"])?.lowercased() else { - return nil - } - - switch command { - case "list": - return formatList(body) - case "direction", "keybind", "id": - return formatExecution(body) - default: - return nil - } - } - - private func formatList(_ body: [String: Any]) -> String? { - guard let type = stringValue(body["type"])?.lowercased() else { - return nil - } - - switch type { - case "windows": - return formatWindows(body) - case "screens": - return formatScreens(body) - case "actions": - return formatActions(body) - default: - return nil + switch result { + case let .windowList(result): + return formatWindows(result.windows) + case let .screenList(result): + return formatScreens(result.screens) + case let .actionList(result): + return formatActions(result, showIDs: configuration.showIDs) + case let .execution(result): + return formatExecution(result) + } } } - private func formatWindows(_ body: [String: Any]) -> String { - let windows = dictionaryArray(body["windows"]) - var lines = ["Windows (\(windows.count))"] - + private func formatWindows(_ windows: [LoopWindowSummary]) -> String { guard !windows.isEmpty else { - return lines[0] + return "No windows" } - for (index, window) in windows.enumerated() { - lines.append("") - lines.append("\(index + 1). \(windowHeading(window, fallback: "Window \(index + 1)"))") - - if let title = nonEmptyString(window["windowTitle"]) { - lines.append(" Title: \(sanitizeInline(title))") - } - - if let bundleID = nonEmptyString(window["bundleID"]) { - lines.append(" Bundle ID: \(bundleID)") - } + return windows.enumerated().map { index, window in + var lines = [windowPrimaryLine(appName: window.appName, title: window.title, fallback: "Window \(index + 1)")] - if let windowID = integerString(window["windowID"]) { - lines.append(" Window ID: \(windowID)") + if let metadata = windowMetadataLine( + bundleID: window.bundleID, + idLabel: "Window ID", + id: window.id, + frame: window.frame + ) { + lines.append(dim(metadata)) } - if let frame = frameString(from: dictionaryValue(window["frame"])) { - lines.append(" Frame: \(frame)") - } - } - - return lines.joined(separator: "\n") + return lines.joined(separator: "\n") + }.joined(separator: "\n\n") } - private func formatScreens(_ body: [String: Any]) -> String { - let screens = dictionaryArray(body["screens"]) - var lines = ["Screens (\(screens.count))"] - + private func formatScreens(_ screens: [LoopScreenSummary]) -> String { guard !screens.isEmpty else { - return lines[0] + return "No screens" } - for (index, screen) in screens.enumerated() { - let name = nonEmptyString(screen["name"]) ?? "Screen \(index + 1)" - let isMain = booleanValue(screen["isMain"]) ?? false - let suffix = isMain ? " [main]" : "" - - lines.append("") - lines.append("\(index + 1). \(sanitizeInline(name))\(suffix)") - - if let screenID = integerString(screen["screenID"]) { - lines.append(" Screen ID: \(screenID)") - } + return screens.enumerated().map { index, screen in + var lines = [screenPrimaryLine(screen, fallback: "Screen \(index + 1)")] - if let frame = frameString(from: dictionaryValue(screen["frame"])) { - lines.append(" Frame: \(frame)") + if let metadata = screenMetadataLine(screen) { + lines.append(dim(metadata)) } - } - return lines.joined(separator: "\n") + return lines.joined(separator: "\n") + }.joined(separator: "\n\n") } - private func formatActions(_ body: [String: Any]) -> String { - let subtype = stringValue(body["subtype"])?.lowercased() + private func formatActions(_ result: LoopActionListResult, showIDs: Bool) -> String { var sections: [String] = [] - if subtype == nil || subtype == "directions" { - sections.append(formatDirectionSections(dictionaryArray(body["directionActions"]))) + if !result.directionCategories.isEmpty { + sections.append(formatDirectionSections(result.directionCategories, showIDs: showIDs)) } - if subtype == nil || subtype == "keybinds" { - sections.append(formatKeybindSection(dictionaryArray(body["keybindActions"]))) + if !result.keybindActions.isEmpty || result.filter == .keybindsOnly || result.filter == .all { + sections.append(formatKeybindSection(result.keybindActions, showIDs: showIDs)) } let nonEmptySections = sections.filter { !$0.isEmpty } if nonEmptySections.isEmpty { - return "Actions\n\nNone" + return "No actions" } return nonEmptySections.joined(separator: "\n\n") } - private func formatDirectionSections(_ categories: [[String: Any]]) -> String { + private func formatDirectionSections(_ categories: [LoopActionCategory], showIDs: Bool) -> String { guard !categories.isEmpty else { - return "Direction Actions\n\nNone" + return "\(bold("- Direction Actions (Built-in) -"))\n\n\(dim("None"))" } - var lines = ["Direction Actions"] - - for category in categories { - let categoryName = nonEmptyString(category["category"]) ?? "Actions" - let actions = dictionaryArray(category["actions"]) - let rows = actions.compactMap(directionRow) - - guard !rows.isEmpty else { - continue - } + var lines = [bold("- Direction Actions (Built-in) -")] + for category in categories where !category.actions.isEmpty { lines.append("") - lines.append(sanitizeInline(categoryName)) - lines.append(contentsOf: formatAlignedRows(rows, indent: " ")) + lines.append(bold(sanitizeInline(category.name))) + lines.append(contentsOf: formatActionRows(category.actions, showIDs: showIDs)) } return lines.joined(separator: "\n") } - private func formatKeybindSection(_ keybinds: [[String: Any]]) -> String { - var lines = ["Keybind Actions (\(keybinds.count))"] + private func formatKeybindSection(_ keybinds: [LoopActionDescriptor], showIDs: Bool) -> String { + var lines = [bold("- User-Configured Keybind Actions -")] guard !keybinds.isEmpty else { lines.append("") - lines.append("None") + lines.append(dim("None")) return lines.joined(separator: "\n") } - let duplicateNames = duplicateNameSet(for: keybinds) lines.append("") - let rows = keybinds.compactMap(keybindRow) - let width = rows.map(\.0.count).max() ?? 0 - - for keybind in keybinds { - guard let row = keybindRow(from: keybind) else { - continue - } - - lines.append(formatAlignedRow(row, width: width, indent: " ")) - - guard - let name = nonEmptyString(keybind["name"]), - duplicateNames.contains(name.caseInsensitiveCompareKey), - let id = nonEmptyString(keybind["id"]) - else { - continue - } - - lines.append(" id: \(id.lowercased())") - } + lines.append(contentsOf: formatActionRows(keybinds, showIDs: showIDs)) return lines.joined(separator: "\n") } - private func formatExecution(_ body: [String: Any]) -> String { - let name = nonEmptyString(body["name"]) ?? "Action" - var lines = ["Executed \(sanitizeInline(name))"] - - if let kind = nonEmptyString(body["kind"]) { - lines.append("Kind: \(sanitizeInline(kind))") - } - - if let slug = nonEmptyString(body["slug"]) { - lines.append("Slug: \(sanitizeInline(slug))") - } + private func formatExecution(_ result: LoopExecutionResult) -> String { + let slug = sanitizeInline(result.action.slug) - if let id = nonEmptyString(body["id"]) { - lines.append("ID: \(id.lowercased())") + guard let window = result.targetWindow else { + return "Successfully executed \(slug)" } - if let window = dictionaryValue(body["window"]) { - lines.append("") - lines.append("Target Window") - lines.append(contentsOf: formatWindowDetails(window, indent: " ")) + if let appName = nonEmptyString(window.appName).map(sanitizeInline) { + return "Successfully executed \(slug) on \(appName) (Window ID: \(window.id))" } - return lines.joined(separator: "\n") + return "Successfully executed \(slug) (Window ID: \(window.id))" } - private func formatWindowDetails(_ window: [String: Any], indent: String) -> [String] { - var lines = ["\(indent)App: \(windowHeading(window, fallback: "Unknown"))"] - - if let title = nonEmptyString(window["windowTitle"]) { - lines.append("\(indent)Title: \(sanitizeInline(title))") + private func formatActionRows(_ actions: [LoopActionDescriptor], showIDs: Bool) -> [String] { + let rows = actions.map { action in + (slug: sanitizeInline(action.slug), id: action.idString) } - if let bundleID = nonEmptyString(window["bundleID"]) { - lines.append("\(indent)Bundle ID: \(bundleID)") + guard showIDs else { + return rows.map { blue($0.slug) } } - if let windowID = integerString(window["windowID"]) { - lines.append("\(indent)Window ID: \(windowID)") - } + let slugColumnWidth = rows.map(\.slug.count).max() ?? 0 - if let frame = frameString(from: dictionaryValue(window["frame"])) { - lines.append("\(indent)Frame: \(frame)") + return rows.map { row in + let paddedSlug = row.slug.padding(toLength: slugColumnWidth, withPad: " ", startingAt: 0) + return "\(blue(paddedSlug)) \(dim(row.id))" } - - return lines } - private func formatAlignedRows(_ rows: [(String, String)], indent: String) -> [String] { - let width = rows.map(\.0.count).max() ?? 0 + private func windowPrimaryLine(appName: String, title: String, fallback: String) -> String { + let sanitizedAppName = nonEmptyString(appName).map(sanitizeInline) + let sanitizedTitle = nonEmptyString(title).map(quotedTitle) - return rows.map { primary, secondary in - formatAlignedRow((primary, secondary), width: width, indent: indent) + switch (sanitizedAppName, sanitizedTitle) { + case let (appName?, title?): + return "\(bold(appName)) \(title)" + case let (appName?, nil): + return bold(appName) + case let (nil, title?): + return title + case (nil, nil): + return fallback } } - private func formatAlignedRow(_ row: (String, String), width: Int, indent: String) -> String { - let (primary, secondary) = row - guard !secondary.isEmpty else { - return "\(indent)\(primary)" - } - - let padding = String(repeating: " ", count: max(2, width - primary.count + 2)) - return "\(indent)\(primary)\(padding)\(secondary)" - } + private func windowMetadataLine( + bundleID: String, + idLabel: String, + id: UInt32, + frame: LoopRect? + ) -> String? { + var parts: [String] = [] - private func directionRow(from action: [String: Any]) -> (String, String)? { - guard let slug = nonEmptyString(action["slug"]) else { - return nil + if let bundleID = nonEmptyString(bundleID) { + parts.append("Bundle ID: \(bundleID)") } - let name = nonEmptyString(action["name"]) ?? slug - return (sanitizeInline(slug), sanitizeInline(name)) - } + parts.append("\(idLabel): \(id)") - private func keybindRow(from action: [String: Any]) -> (String, String)? { - guard let slug = nonEmptyString(action["slug"]) else { - return nil + if let frame { + parts.append("Frame: \(formatLength(frame.width))x\(formatLength(frame.height)) @ \(formatCoordinate(frame.x)),\(formatCoordinate(frame.y))") } - let name = nonEmptyString(action["name"]) ?? slug - return (sanitizeInline(slug), sanitizeInline(name)) + return parts.isEmpty ? nil : parts.joined(separator: " | ") } - private func duplicateNameSet(for keybinds: [[String: Any]]) -> Set { - var counts: [String: Int] = [:] - - for keybind in keybinds { - guard let name = nonEmptyString(keybind["name"]) else { - continue - } - - counts[name.caseInsensitiveCompareKey, default: 0] += 1 - } - - return Set(counts.compactMap { key, value in - value > 1 ? key : nil - }) - } - - private func windowHeading(_ window: [String: Any], fallback: String) -> String { - if let appName = nonEmptyString(window["appName"]) { - return sanitizeInline(appName) - } - - if let title = nonEmptyString(window["windowTitle"]) { - return sanitizeInline(title) - } - - return fallback + private func screenPrimaryLine(_ screen: LoopScreenSummary, fallback: String) -> String { + let name = nonEmptyString(screen.name).map(sanitizeInline) ?? fallback + return screen.isMain ? "\(bold(name)) [main]" : bold(name) } - private func frameString(from frame: [String: Any]?) -> String? { - guard - let frame, - let width = integerString(frame["width"]), - let height = integerString(frame["height"]), - let x = integerString(frame["x"]), - let y = integerString(frame["y"]) - else { - return nil - } - - return "\(width)x\(height) @ \(x),\(y)" + private func screenMetadataLine(_ screen: LoopScreenSummary) -> String? { + "Screen ID: \(screen.id) | Frame: \(formatLength(screen.frame.width))x\(formatLength(screen.frame.height)) @ \(formatCoordinate(screen.frame.x)),\(formatCoordinate(screen.frame.y))" } - private func dictionaryValue(_ value: Any?) -> [String: Any]? { - value as? [String: Any] + private func formatLength(_ value: CGFloat) -> String { + formatCGFloat(value) } - private func dictionaryArray(_ value: Any?) -> [[String: Any]] { - (value as? [Any])?.compactMap { $0 as? [String: Any] } ?? [] + private func formatCoordinate(_ value: CGFloat) -> String { + formatCGFloat(value) } - private func stringValue(_ value: Any?) -> String? { - value as? String - } - - private func nonEmptyString(_ value: Any?) -> String? { - guard let string = stringValue(value)?.trimmingCharacters(in: .whitespacesAndNewlines), - !string.isEmpty - else { - return nil + private func formatCGFloat(_ value: CGFloat) -> String { + if value.rounded() == value { + return String(Int(value)) } - return string - } - - private func booleanValue(_ value: Any?) -> Bool? { - switch value { - case let bool as Bool: - return bool - case let number as NSNumber: - if CFGetTypeID(number) == CFBooleanGetTypeID() { - return number.boolValue - } - return nil - default: - return nil - } + let formatted = String(format: "%.2f", Double(value)) + return formatted + .replacingOccurrences(of: #"\.?0+$"#, with: "", options: .regularExpression) } - private func integerString(_ value: Any?) -> String? { - switch value { - case let int as Int: - return String(int) - case let int32 as Int32: - return String(int32) - case let int64 as Int64: - return String(int64) - case let number as NSNumber: - if CFGetTypeID(number) == CFBooleanGetTypeID() { - return nil - } - return String(number.int64Value) - default: - return nil - } + private func nonEmptyString(_ string: String) -> String? { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed } private func sanitizeInline(_ string: String) -> String { @@ -379,126 +231,32 @@ struct CLIOutputFormatter { .joined(separator: " ") } - private func formatGenericValue(_ value: Any, indentLevel: Int) -> String { - if let dictionary = value as? [String: Any] { - return formatGenericDictionary(dictionary, indentLevel: indentLevel) - } - - if let array = value as? [Any] { - return formatGenericArray(array, indentLevel: indentLevel) - } - - return formatGenericScalar(value) + private func quotedTitle(_ string: String) -> String { + let sanitized = sanitizeInline(string).replacingOccurrences(of: "'", with: "\\'") + return "'\(sanitized)'" } - private func formatGenericDictionary(_ dictionary: [String: Any], indentLevel: Int) -> String { - if dictionary.isEmpty { - return "{}" - } - - let indent = String(repeating: " ", count: indentLevel) - let sortedKeys = dictionary.keys.sorted() - - return sortedKeys.map { key in - let value = dictionary[key]! - if isCollection(value) { - let renderedValue = formatGenericValue(value, indentLevel: indentLevel + 1) - if renderedValue == "{}" || renderedValue == "[]" { - return "\(indent)\(key): \(renderedValue)" - } - return "\(indent)\(key):\n\(renderedValue)" - } - - return "\(indent)\(key): \(formatGenericScalar(value))" - }.joined(separator: "\n") - } - - private func formatGenericArray(_ array: [Any], indentLevel: Int) -> String { - if array.isEmpty { - return "[]" + private func bold(_ string: String) -> String { + guard supportsANSIStyle else { + return string } - let indent = String(repeating: " ", count: indentLevel) - let childIndentLevel = indentLevel + 1 - - return array.map { item in - if isCollection(item) { - let renderedValue = formatGenericValue(item, indentLevel: childIndentLevel) - if renderedValue == "{}" || renderedValue == "[]" { - return "\(indent)- \(renderedValue)" - } - - return "\(indent)-\n\(renderedValue)" - } - - return "\(indent)- \(formatGenericScalar(item))" - }.joined(separator: "\n") - } - - private func formatGenericScalar(_ value: Any) -> String { - switch value { - case let string as String: - return formatGenericString(string) - case let number as NSNumber: - if CFGetTypeID(number) == CFBooleanGetTypeID() { - return number.boolValue ? "true" : "false" - } - return number.stringValue - case _ as NSNull: - return "null" - default: - return formatGenericString(String(describing: value)) - } + return "\u{001B}[1m\(string)\u{001B}[22m" } - private func formatGenericString(_ string: String) -> String { - guard requiresQuoting(string) else { + private func dim(_ string: String) -> String { + guard supportsANSIStyle else { return string } - return "\"\(escape(string))\"" + return "\u{001B}[2m\(string)\u{001B}[22m" } - private func requiresQuoting(_ string: String) -> Bool { - if string.isEmpty { - return true - } - - if string.trimmingCharacters(in: .whitespacesAndNewlines) != string { - return true - } - - if string.contains("\n") || string.contains("\r") { - return true - } - - if string.contains(": ") || string.contains("#") { - return true - } - - if string.contains("{") || string.contains("}") || string.contains("[") || string.contains("]") { - return true + private func blue(_ string: String) -> String { + guard supportsANSIStyle else { + return string } - return false - } - - private func escape(_ string: String) -> String { - string - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - .replacingOccurrences(of: "\t", with: "\\t") - } - - private func isCollection(_ value: Any) -> Bool { - value is [String: Any] || value is [Any] - } -} - -private extension String { - var caseInsensitiveCompareKey: String { - lowercased() + return "\u{001B}[34m\(string)\u{001B}[39m" } } diff --git a/LoopCLI/CLIRequest.swift b/LoopCLI/CLIRequest.swift index 05402e2c..9994f339 100644 --- a/LoopCLI/CLIRequest.swift +++ b/LoopCLI/CLIRequest.swift @@ -9,7 +9,7 @@ import ArgumentParser import Foundation protocol CLIRequestCommand: ParsableCommand { - var outputMode: CLIOutputMode { get } + var outputConfiguration: CLIOutputConfiguration { get } func makeRequest(using application: LoopCLIApplication) throws -> CLIRequest } @@ -17,7 +17,7 @@ extension CLIRequestCommand { func run() throws { try LoopCLIApplication.shared.execute( makeRequest(using: .shared), - outputMode: outputMode + outputConfiguration: outputConfiguration ) } } @@ -51,3 +51,13 @@ struct CLIRequest { url.absoluteString } } + +struct CLIOutputConfiguration { + let mode: CLIOutputMode + let showIDs: Bool + + static let `default` = CLIOutputConfiguration( + mode: .human, + showIDs: false + ) +} diff --git a/LoopCLI/CLIResponse.swift b/LoopCLI/CLIResponse.swift index 2fdbc553..df800cc5 100644 --- a/LoopCLI/CLIResponse.swift +++ b/LoopCLI/CLIResponse.swift @@ -7,83 +7,29 @@ import Foundation -struct CLIActionDescriptor { - let id: UUID - let kind: String - let name: String - let slug: String - let urlPath: String - let idPath: String - - var idString: String { - id.uuidString.lowercased() - } -} - struct CLIResponse { let rawOutput: String - let jsonBody: [String: Any]? - let isSuccess: Bool - let errorMessage: String? - let replacement: String? - let availableCommands: [String] - let availableRoutes: [String] - let keybindActions: [CLIActionDescriptor] + let automationResponse: LoopAutomationResponse? init(rawOutput: String) { let trimmedOutput = rawOutput.trimmingCharacters(in: .whitespacesAndNewlines) self.rawOutput = trimmedOutput - - if let data = trimmedOutput.data(using: .utf8), - let jsonBody = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - self.jsonBody = jsonBody - self.isSuccess = jsonBody["success"] as? Bool ?? false - self.errorMessage = jsonBody["error"] as? String - self.replacement = jsonBody["replacement"] as? String - self.availableCommands = Self.stringArray(from: jsonBody["availableCommands"]) - self.availableRoutes = Self.stringArray(from: jsonBody["availableRoutes"]) - self.keybindActions = Self.actionDescriptors(from: jsonBody["keybindActions"]) - } else { - self.jsonBody = nil - self.isSuccess = false - self.errorMessage = nil - self.replacement = nil - self.availableCommands = [] - self.availableRoutes = [] - self.keybindActions = [] - } + self.automationResponse = try? LoopAutomationJSON.decodeResponse(from: trimmedOutput) } - private static func stringArray(from value: Any?) -> [String] { - (value as? [Any])?.compactMap { $0 as? String } ?? [] + var isSuccess: Bool { + automationResponse?.success == true } - private static func actionDescriptors(from value: Any?) -> [CLIActionDescriptor] { - guard let objects = value as? [[String: Any]] else { - return [] - } + var result: LoopAutomationResult? { + automationResponse?.result + } - return objects.compactMap { object in - guard - let idValue = object["id"] as? String, - let id = UUID(uuidString: idValue), - let kind = object["kind"] as? String, - let name = object["name"] as? String, - let slug = object["slug"] as? String, - let urlPath = object["urlPath"] as? String, - let idPath = object["idPath"] as? String - else { - return nil - } + var automationError: LoopAutomationError? { + automationResponse?.error + } - return CLIActionDescriptor( - id: id, - kind: kind, - name: name, - slug: slug, - urlPath: urlPath, - idPath: idPath - ) - } + var keybindActions: [LoopActionDescriptor] { + automationResponse?.result?.actionList?.keybindActions ?? [] } } diff --git a/LoopCLI/ExecCommand.swift b/LoopCLI/ExecCommand.swift index e8e0d45a..ce81b129 100644 --- a/LoopCLI/ExecCommand.swift +++ b/LoopCLI/ExecCommand.swift @@ -41,8 +41,11 @@ struct ExecCommand: ParsableCommand, CLIRequestCommand { @OptionGroup var outputOptions: OutputOptions - var outputMode: CLIOutputMode { - outputOptions.outputMode + var outputConfiguration: CLIOutputConfiguration { + CLIOutputConfiguration( + mode: outputOptions.outputMode, + showIDs: false + ) } func validate() throws { diff --git a/LoopCLI/ListCommand.swift b/LoopCLI/ListCommand.swift index 32f17367..89b15256 100644 --- a/LoopCLI/ListCommand.swift +++ b/LoopCLI/ListCommand.swift @@ -16,26 +16,36 @@ struct ListCommand: ParsableCommand, CLIRequestCommand { @Argument(help: "What to list") var subject: ListSubject - @Flag(name: .customLong("directions-only"), help: "List only built-in direction actions") + @Flag(name: .customLong("directions"), help: "List only built-in direction actions") var directionsOnly = false - @Flag(name: .customLong("keybinds-only"), help: "List only keybind-backed actions") + @Flag(name: .customLong("keybinds"), help: "List only keybind-backed actions") var keybindsOnly = false + @Flag(name: .customLong("ids"), help: "Show action UUIDs in `list actions` output") + var ids = false + @OptionGroup var outputOptions: OutputOptions - var outputMode: CLIOutputMode { - outputOptions.outputMode + var outputConfiguration: CLIOutputConfiguration { + CLIOutputConfiguration( + mode: outputOptions.outputMode, + showIDs: ids + ) } func validate() throws { if directionsOnly, keybindsOnly { - throw ValidationError("--directions-only and --keybinds-only are mutually exclusive") + throw ValidationError("--directions and --keybinds are mutually exclusive") } if subject != .actions, directionsOnly || keybindsOnly { - throw ValidationError("--directions-only and --keybinds-only are only valid with `list actions`") + throw ValidationError("--directions and --keybinds are only valid with `list actions`") + } + + if subject != .actions, ids { + throw ValidationError("--ids is only valid with `list actions`") } } diff --git a/LoopCLI/LoopCLIApplication.swift b/LoopCLI/LoopCLIApplication.swift index ca1f16c5..aebf6e1a 100644 --- a/LoopCLI/LoopCLIApplication.swift +++ b/LoopCLI/LoopCLIApplication.swift @@ -28,16 +28,16 @@ final class LoopCLIApplication { self.outputFormatter = outputFormatter } - func execute(_ request: CLIRequest, outputMode: CLIOutputMode) throws { + func execute(_ request: CLIRequest, outputConfiguration: CLIOutputConfiguration) throws { let response = try socketClient.send(request) guard response.isSuccess else { throw errorFormatter.error(from: response) } - print(outputFormatter.format(response, mode: outputMode)) + print(outputFormatter.format(response, configuration: outputConfiguration)) } - func resolveKeybind(named displayName: String) throws -> CLIActionDescriptor { + func resolveKeybind(named displayName: String) throws -> LoopActionDescriptor { let response = try socketClient.send( CLIRequest(routeComponents: ["list", "actions", "keybinds"]) ) @@ -59,7 +59,7 @@ final class LoopCLIApplication { throw errorFormatter.runtimeError( """ Unknown keybind name: \(displayName) - Try: \(Self.executableName) list actions --keybinds-only + Try: \(Self.executableName) list actions --keybinds """ ) } diff --git a/LoopCLI/LoopCLICommand.swift b/LoopCLI/LoopCLICommand.swift index 4a64cf17..348706a6 100644 --- a/LoopCLI/LoopCLICommand.swift +++ b/LoopCLI/LoopCLICommand.swift @@ -18,7 +18,8 @@ struct LoopCLICommand: ParsableCommand { Examples: \(LoopCLIApplication.executableName) list windows \(LoopCLIApplication.executableName) list windows --json - \(LoopCLIApplication.executableName) list actions --directions-only + \(LoopCLIApplication.executableName) list actions --directions + \(LoopCLIApplication.executableName) list actions --ids \(LoopCLIApplication.executableName) exec --direction right \(LoopCLIApplication.executableName) exec --direction right --json \(LoopCLIApplication.executableName) exec --keybind "My Layout" diff --git a/README.md b/README.md index e646a501..3f73f41a 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,8 @@ For machine-readable shell output, install the CLI from Loop's Advanced tab. Thi ```bash loop list windows loop list screens -loop list actions --directions-only +loop list actions --directions +loop list actions --ids loop exec --direction right loop exec --keybind "My Layout" loop exec --id 123e4567-e89b-12d3-a456-426614174000 @@ -165,7 +166,7 @@ loop list windows --json loop exec --direction right --json ``` -Successful `loop` commands print human-readable structured text by default. Pass `--json` to print the raw JSON response. Runtime failures print plain-text errors to `stderr`, and local usage errors are handled by the CLI's built-in help and validation output. +Successful `loop` commands print human-readable structured text by default. Pass `--json` to print the raw JSON response. Successful JSON now uses a shared envelope of `{ "success": true, "result": { ... } }`, and failures use `{ "success": false, "error": { ... } }`. Runtime failures print plain-text errors to `stderr`, and local usage errors are handled by the CLI's built-in help and validation output. ### Keyboard Shortcuts diff --git a/Shared/LoopAutomationJSON.swift b/Shared/LoopAutomationJSON.swift new file mode 100644 index 00000000..75b55e5d --- /dev/null +++ b/Shared/LoopAutomationJSON.swift @@ -0,0 +1,39 @@ +// +// LoopAutomationJSON.swift +// Loop +// +// Created by Kai Azim on 2026-03-30. +// + +import Foundation + +enum LoopAutomationJSON { + static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return encoder + } + + static func makeDecoder() -> JSONDecoder { + JSONDecoder() + } + + static func encodeString(_ response: LoopAutomationResponse) throws -> String { + let data = try makeEncoder().encode(response) + guard let string = String(data: data, encoding: .utf8) else { + throw EncodingError.invalidValue( + response, + EncodingError.Context( + codingPath: [], + debugDescription: "Failed to encode Loop automation response as UTF-8" + ) + ) + } + + return string + } + + static func decodeResponse(from string: String) throws -> LoopAutomationResponse { + try makeDecoder().decode(LoopAutomationResponse.self, from: Data(string.utf8)) + } +} diff --git a/Shared/LoopAutomationModels.swift b/Shared/LoopAutomationModels.swift new file mode 100644 index 00000000..5293c307 --- /dev/null +++ b/Shared/LoopAutomationModels.swift @@ -0,0 +1,264 @@ +// +// LoopAutomationModels.swift +// Loop +// +// Created by Kai Azim on 2026-03-30. +// + +import CoreGraphics +import Foundation + +enum LoopActionKind: String, Codable { + case direction + case keybind +} + +enum LoopActionListFilter: String, Codable { + case all + case directionsOnly + case keybindsOnly +} + +enum LoopAutomationResultKind: String, Codable { + case windowList + case screenList + case actionList + case execution +} + +struct LoopRect: Codable { + let x: CGFloat + let y: CGFloat + let width: CGFloat + let height: CGFloat + + init(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat) { + self.x = x + self.y = y + self.width = width + self.height = height + } + + init(_ rect: CGRect) { + self.init( + x: rect.origin.x, + y: rect.origin.y, + width: rect.width, + height: rect.height + ) + } + + var cgRect: CGRect { + CGRect(x: x, y: y, width: width, height: height) + } +} + +struct LoopWindowSummary: Codable { + let id: UInt32 + let bundleID: String + let appName: String + let title: String + let frame: LoopRect +} + +struct LoopExecutionTargetWindow: Codable { + let id: UInt32 + let bundleID: String + let appName: String + let title: String +} + +struct LoopScreenSummary: Codable { + let id: UInt32 + let name: String + let frame: LoopRect + let isMain: Bool +} + +struct LoopActionDescriptor: Codable { + let id: UUID + let kind: LoopActionKind + let name: String + let slug: String + let route: String + let idRoute: String + + var idString: String { + id.uuidString.lowercased() + } +} + +struct LoopActionCategory: Codable { + let name: String + let actions: [LoopActionDescriptor] +} + +struct LoopWindowListResult: Codable { + let windows: [LoopWindowSummary] +} + +struct LoopScreenListResult: Codable { + let screens: [LoopScreenSummary] +} + +struct LoopActionListResult: Codable { + let filter: LoopActionListFilter + let directionCategories: [LoopActionCategory] + let keybindActions: [LoopActionDescriptor] +} + +struct LoopExecutionResult: Codable { + let action: LoopActionDescriptor + let targetWindow: LoopExecutionTargetWindow? +} + +enum LoopAutomationResult: Codable { + case windowList(LoopWindowListResult) + case screenList(LoopScreenListResult) + case actionList(LoopActionListResult) + case execution(LoopExecutionResult) + + private enum CodingKeys: String, CodingKey { + case kind + case windows + case screens + case filter + case directionCategories + case keybindActions + case action + case targetWindow + } + + var kind: LoopAutomationResultKind { + switch self { + case .windowList: + .windowList + case .screenList: + .screenList + case .actionList: + .actionList + case .execution: + .execution + } + } + + var windowList: LoopWindowListResult? { + guard case let .windowList(result) = self else { + return nil + } + + return result + } + + var screenList: LoopScreenListResult? { + guard case let .screenList(result) = self else { + return nil + } + + return result + } + + var actionList: LoopActionListResult? { + guard case let .actionList(result) = self else { + return nil + } + + return result + } + + var execution: LoopExecutionResult? { + guard case let .execution(result) = self else { + return nil + } + + return result + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(LoopAutomationResultKind.self, forKey: .kind) + + switch kind { + case .windowList: + self = .windowList( + LoopWindowListResult( + windows: try container.decode([LoopWindowSummary].self, forKey: .windows) + ) + ) + case .screenList: + self = .screenList( + LoopScreenListResult( + screens: try container.decode([LoopScreenSummary].self, forKey: .screens) + ) + ) + case .actionList: + self = .actionList( + LoopActionListResult( + filter: try container.decode(LoopActionListFilter.self, forKey: .filter), + directionCategories: try container.decode([LoopActionCategory].self, forKey: .directionCategories), + keybindActions: try container.decode([LoopActionDescriptor].self, forKey: .keybindActions) + ) + ) + case .execution: + self = .execution( + LoopExecutionResult( + action: try container.decode(LoopActionDescriptor.self, forKey: .action), + targetWindow: try container.decodeIfPresent(LoopExecutionTargetWindow.self, forKey: .targetWindow) + ) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(kind, forKey: .kind) + + switch self { + case let .windowList(result): + try container.encode(result.windows, forKey: .windows) + case let .screenList(result): + try container.encode(result.screens, forKey: .screens) + case let .actionList(result): + try container.encode(result.filter, forKey: .filter) + try container.encode(result.directionCategories, forKey: .directionCategories) + try container.encode(result.keybindActions, forKey: .keybindActions) + case let .execution(result): + try container.encode(result.action, forKey: .action) + try container.encodeIfPresent(result.targetWindow, forKey: .targetWindow) + } + } +} + +struct LoopAutomationError: Codable { + let message: String + let replacementRoute: String? + let availableRoutes: [String]? + + init( + message: String, + replacementRoute: String? = nil, + availableRoutes: [String]? = nil + ) { + self.message = message + self.replacementRoute = replacementRoute + self.availableRoutes = availableRoutes + } +} + +struct LoopAutomationResponse: Codable { + let success: Bool + let result: LoopAutomationResult? + let error: LoopAutomationError? + + init(result: LoopAutomationResult) { + self.success = true + self.result = result + self.error = nil + } + + init(error: LoopAutomationError) { + self.success = false + self.result = nil + self.error = error + } +} From b0bd619dc913e38279a105275f17490cc3402680 Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 4 Apr 2026 17:10:07 -0600 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=94=A5=20Remove=20dead=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CommandOutputWindowController.swift | 2 +- .../CommandOutputWindowManager.swift | 2 +- Loop/Scripting/LoopCommandHandler.swift | 382 +++--------------- LoopCLI/CLIErrorFormatter.swift | 4 - LoopCLI/CLIOutputFormatter.swift | 21 +- LoopCLI/CLIOutputMode.swift | 11 - LoopCLI/CLIRequest.swift | 7 +- LoopCLI/CLIResponse.swift | 4 - LoopCLI/ExecCommand.swift | 7 +- LoopCLI/LoopCLIApplication.swift | 43 -- LoopCLI/OutputOptions.swift | 7 +- Shared/LoopAutomationModels.swift | 45 +-- 12 files changed, 82 insertions(+), 453 deletions(-) delete mode 100644 LoopCLI/CLIOutputMode.swift diff --git a/Loop/Scripting/CommandOutputWindowController.swift b/Loop/Scripting/CommandOutputWindowController.swift index 499bfded..6a3723ba 100644 --- a/Loop/Scripting/CommandOutputWindowController.swift +++ b/Loop/Scripting/CommandOutputWindowController.swift @@ -2,7 +2,7 @@ // CommandOutputWindowController.swift // Loop // -// Created by Codex on 2026-03-28. +// Created by Kai Azim on 2026-03-28. // import AppKit diff --git a/Loop/Scripting/CommandOutputWindowManager.swift b/Loop/Scripting/CommandOutputWindowManager.swift index e41a4c4d..5cc6a185 100644 --- a/Loop/Scripting/CommandOutputWindowManager.swift +++ b/Loop/Scripting/CommandOutputWindowManager.swift @@ -2,7 +2,7 @@ // CommandOutputWindowManager.swift // Loop // -// Created by Codex on 2026-03-28. +// Created by Kai Azim on 2026-03-28. // import AppKit diff --git a/Loop/Scripting/LoopCommandHandler.swift b/Loop/Scripting/LoopCommandHandler.swift index e7acb3f7..33412e6a 100644 --- a/Loop/Scripting/LoopCommandHandler.swift +++ b/Loop/Scripting/LoopCommandHandler.swift @@ -15,8 +15,8 @@ - loop://list/actions - loop://list/actions/directions - loop://list/actions/keybinds - - loop://direction/ - - loop://keybind/ + - loop://direction/ + - loop://keybind/ - loop://id/ Socket / CLI transport: @@ -33,7 +33,6 @@ */ import AppKit -import CryptoKit import Defaults import Foundation import Scribe @@ -138,37 +137,27 @@ final class LoopCommandHandler { } private struct DirectionActionDescriptor { - let category: String let direction: WindowDirection - let id: UUID - let slug: String let name: String - - var idString: String { - id.uuidString.lowercased() - } + let title: String var urlPath: String { - "direction/\(slug)" - } - - var idPath: String { - "id/\(idString)" + "direction/\(name)" } } private struct KeybindActionDescriptor { let action: WindowAction let id: UUID - let slug: String let name: String + let title: String var idString: String { id.uuidString.lowercased() } var urlPath: String { - "keybind/\(slug)" + "keybind/\(name)" } var idPath: String { @@ -180,33 +169,15 @@ final class LoopCommandHandler { case direction(DirectionActionDescriptor) case keybind(KeybindActionDescriptor) - var id: UUID { + var id: UUID? { switch self { - case let .direction(descriptor): - descriptor.id + case .direction: + nil case let .keybind(descriptor): descriptor.id } } - var idString: String { - switch self { - case let .direction(descriptor): - descriptor.idString - case let .keybind(descriptor): - descriptor.idString - } - } - - var slug: String { - switch self { - case let .direction(descriptor): - descriptor.slug - case let .keybind(descriptor): - descriptor.slug - } - } - var name: String { switch self { case let .direction(descriptor): @@ -216,12 +187,12 @@ final class LoopCommandHandler { } } - var kind: String { + var title: String { switch self { - case .direction: - "direction" - case .keybind: - "keybind" + case let .direction(descriptor): + descriptor.title + case let .keybind(descriptor): + descriptor.title } } @@ -243,10 +214,10 @@ final class LoopCommandHandler { } } - var idPath: String { + var idPath: String? { switch self { - case let .direction(descriptor): - descriptor.idPath + case .direction: + nil case let .keybind(descriptor): descriptor.idPath } @@ -280,8 +251,6 @@ final class LoopCommandHandler { ("Other", [.initialFrame, .undo]) ] - private static let directionIDNamespace = UUID(uuidString: "6c6e0e9d-2da7-4b3d-bf5b-4e868e4b6d7b")! - // MARK: - Public Methods /// Handles incoming `loop://` requests and returns command metadata. @@ -361,18 +330,6 @@ final class LoopCommandHandler { let parameters = Array(components.dropFirst()) - if let legacy = removedLegacyCommandResponse( - command: commandString, - parameters: parameters - ) { - return makeExecutionResult( - source: source, - kind: legacy.kind, - components: components, - response: legacy.response - ) - } - switch commandString { case "list": return makeExecutionResult( @@ -444,12 +401,6 @@ final class LoopCommandHandler { return error } - case "all": - return removedListAllResponse() - - case "keybinds": - return removedListKeybindsResponse() - default: return invalidListRouteResponse(parameters) } @@ -528,60 +479,39 @@ final class LoopCommandHandler { private func handleDirectionCommand(_ parameters: [String], params: TargetParams) -> LoopAutomationResponse { guard parameters.count == 1 else { return failureResponse( - message: "Direction execution requires exactly one slug", + message: "Direction execution requires exactly one name", replacementRoute: urlCommandString(["list", "actions", "directions"]) ) } let token = parameters[0] - if let descriptor = directionActionDescriptor(slug: token) { - return executeAction(.direction(descriptor), params: params) - } - - if let descriptor = legacyDirectionDescriptor(for: token) { + guard let descriptor = directionActionDescriptor(name: token) else { return failureResponse( - message: "Use the canonical direction slug", - replacementRoute: urlCommandString(["direction", descriptor.slug]) + message: "Unknown direction name: \(token)", + replacementRoute: urlCommandString(["list", "actions", "directions"]) ) } - return failureResponse( - message: "Unknown direction slug: \(token)", - replacementRoute: urlCommandString(["list", "actions", "directions"]) - ) + return executeAction(.direction(descriptor), params: params) } private func handleKeybindCommand(_ parameters: [String], params: TargetParams) -> LoopAutomationResponse { guard parameters.count == 1 else { return failureResponse( - message: "Keybind execution requires exactly one slug", + message: "Keybind execution requires exactly one name", replacementRoute: urlCommandString(["list", "actions", "keybinds"]) ) } let token = parameters[0] - if let descriptor = keybindActionDescriptor(slug: token) { - return executeAction(.keybind(descriptor), params: params) - } - - let legacyMatches = legacyKeybindDescriptors(for: token) - if legacyMatches.count == 1, let descriptor = legacyMatches.first { - return failureResponse( - message: "Use the canonical keybind slug", - replacementRoute: urlCommandString(["keybind", descriptor.slug]) - ) - } - - if legacyMatches.count > 1 { + guard let descriptor = keybindActionDescriptor(name: token) else { return failureResponse( - message: "Multiple keybind actions match \(token). Use list/actions/keybinds to find the canonical slug." + message: "Unknown keybind name: \(token)", + replacementRoute: urlCommandString(["list", "actions", "keybinds"]) ) } - return failureResponse( - message: "Unknown keybind slug: \(token)", - replacementRoute: urlCommandString(["list", "actions", "keybinds"]) - ) + return executeAction(.keybind(descriptor), params: params) } private func handleIDCommand(_ parameters: [String], params: TargetParams) -> LoopAutomationResponse { @@ -660,36 +590,27 @@ final class LoopCommandHandler { } private func allDirectionActionDescriptors() -> [DirectionActionDescriptor] { - Self.directionCategories.flatMap { category, directions in + Self.directionCategories.flatMap { _, directions in directions.map { direction in DirectionActionDescriptor( - category: category, direction: direction, - id: deterministicDirectionID(for: direction), - slug: canonicalDirectionSlug(for: direction), - name: direction.name + name: canonicalDirectionName(for: direction), + title: direction.name ) } } } private func directionActionDescriptor(for direction: WindowDirection) -> DirectionActionDescriptor { - let category = Self.directionCategories.first { $0.1.contains(direction) }?.0 ?? "Actions" - return DirectionActionDescriptor( - category: category, + DirectionActionDescriptor( direction: direction, - id: deterministicDirectionID(for: direction), - slug: canonicalDirectionSlug(for: direction), - name: direction.name + name: canonicalDirectionName(for: direction), + title: direction.name ) } - private func directionActionDescriptor(slug: String) -> DirectionActionDescriptor? { - allDirectionActionDescriptors().first { $0.slug == slug.lowercased() } - } - - private func directionActionDescriptor(id: UUID) -> DirectionActionDescriptor? { - allDirectionActionDescriptors().first { $0.id == id } + private func directionActionDescriptor(name: String) -> DirectionActionDescriptor? { + allDirectionActionDescriptors().first { $0.name == name.lowercased() } } private func keybindActionDescriptors() -> [KeybindActionDescriptor] { @@ -709,26 +630,26 @@ final class LoopCommandHandler { return (action, displayName, slugifyDisplayString(displayName)) } - let groupedByBaseSlug = Dictionary(grouping: candidates, by: \.2) + let groupedByBaseName = Dictionary(grouping: candidates, by: \.2) - return candidates.map { action, name, baseSlug in - let finalSlug: String = if groupedByBaseSlug[baseSlug, default: []].count > 1 { - "\(baseSlug)_\(shortIdentifier(for: action.id))" + return candidates.map { action, displayName, baseName in + let finalName: String = if groupedByBaseName[baseName, default: []].count > 1 { + "\(baseName)_\(shortIdentifier(for: action.id))" } else { - baseSlug + baseName } return KeybindActionDescriptor( action: action, id: action.id, - slug: finalSlug, - name: name + name: finalName, + title: displayName ) } } - private func keybindActionDescriptor(slug: String) -> KeybindActionDescriptor? { - keybindActionDescriptors().first { $0.slug == slug.lowercased() } + private func keybindActionDescriptor(name: String) -> KeybindActionDescriptor? { + keybindActionDescriptors().first { $0.name == name.lowercased() } } private func keybindActionDescriptor(id: UUID) -> KeybindActionDescriptor? { @@ -736,10 +657,6 @@ final class LoopCommandHandler { } private func executableActionDescriptor(id: UUID) -> ExecutableActionDescriptor? { - if let descriptor = directionActionDescriptor(id: id) { - return .direction(descriptor) - } - if let descriptor = keybindActionDescriptor(id: id) { return .keybind(descriptor) } @@ -747,149 +664,6 @@ final class LoopCommandHandler { return nil } - private func legacyDirectionDescriptor(for token: String) -> DirectionActionDescriptor? { - let lowered = token.lowercased() - - switch lowered { - case "next": - return directionActionDescriptor(for: .nextScreen) - case "previous": - return directionActionDescriptor(for: .previousScreen) - default: - return allDirectionActionDescriptors().first { descriptor in - descriptor.slug == lowered - || slugifyDisplayString(descriptor.direction.rawValue, treatCamelCaseAsWords: true) == lowered - || descriptor.direction.rawValue.lowercased() == lowered - } - } - } - - private func legacyKeybindDescriptors(for token: String) -> [KeybindActionDescriptor] { - let lowered = token.lowercased() - return keybindActionDescriptors().filter { - $0.slug == lowered || $0.name.caseInsensitiveCompare(token) == .orderedSame - } - } - - // MARK: - Removed Public Commands - - private func removedLegacyCommandResponse( - command: String, - parameters: [String] - ) -> (kind: CommandKind, response: LoopAutomationResponse)? { - switch command { - case "windowlist": - ( - .read, - failureResponse( - message: "windowlist has been removed", - replacementRoute: listRouteReplacement(["windows"]) - ) - ) - - case "screenlist": - ( - .read, - failureResponse( - message: "screenlist has been removed", - replacementRoute: listRouteReplacement(["screens"]) - ) - ) - - case "execute": - ( - .write, - removedExecuteResponse(parameters: parameters) - ) - - case "screen": - ( - .write, - removedScreenResponse(parameters: parameters) - ) - - case "action": - ( - parameters.first?.lowercased() == "list" ? .read : .write, - removedActionResponse(parameters: parameters) - ) - - default: - nil - } - } - - private func removedExecuteResponse(parameters: [String]) -> LoopAutomationResponse { - if parameters.count == 1, let identifier = UUID(uuidString: parameters[0]), let descriptor = executableActionDescriptor(id: identifier) { - return failureResponse( - message: "execute has been removed", - replacementRoute: urlCommandString(["id", descriptor.idString]) - ) - } - - if parameters.count == 2, parameters[0].lowercased() == "direction", let descriptor = legacyDirectionDescriptor(for: parameters[1]) { - return failureResponse( - message: "execute has been removed", - replacementRoute: replacementCommand(for: .direction(descriptor)) - ) - } - - if parameters.count == 2, parameters[0].lowercased() == "keybind", let descriptor = keybindActionDescriptor(slug: parameters[1]) ?? legacyKeybindDescriptors(for: parameters[1]).only { - return failureResponse( - message: "execute has been removed", - replacementRoute: replacementCommand(for: .keybind(descriptor)) - ) - } - - return failureResponse( - message: "execute has been removed", - availableRoutes: publicWriteRoutes() - ) - } - - private func removedScreenResponse(parameters: [String]) -> LoopAutomationResponse { - if let parameter = parameters.first, let descriptor = legacyDirectionDescriptor(for: parameter) { - return failureResponse( - message: "screen has been removed", - replacementRoute: replacementCommand(for: .direction(descriptor)) - ) - } - - return failureResponse( - message: "screen has been removed", - replacementRoute: urlCommandString(["list", "actions", "directions"]) - ) - } - - private func removedActionResponse(parameters: [String]) -> LoopAutomationResponse { - if parameters.isEmpty || parameters.first?.lowercased() == "list" { - return failureResponse( - message: "action has been removed", - replacementRoute: listRouteReplacement(["actions"]) - ) - } - - if let descriptor = legacyDirectionDescriptor(for: parameters[0]) { - return failureResponse( - message: "action has been removed", - replacementRoute: replacementCommand(for: .direction(descriptor)) - ) - } - - let keybindMatches = legacyKeybindDescriptors(for: parameters[0]) - if let descriptor = keybindMatches.only { - return failureResponse( - message: "action has been removed", - replacementRoute: replacementCommand(for: .keybind(descriptor)) - ) - } - - return failureResponse( - message: "action has been removed", - replacementRoute: listRouteReplacement(["actions"]) - ) - } - // MARK: - Response Helpers private func publicRoutes() -> [String] { @@ -898,11 +672,11 @@ final class LoopCommandHandler { private func publicListRoutes() -> [String] { [ - listRouteReplacement(["windows"]), - listRouteReplacement(["screens"]), - listRouteReplacement(["actions"]), - listRouteReplacement(["actions", "directions"]), - listRouteReplacement(["actions", "keybinds"]) + urlCommandString(["list", "windows"]), + urlCommandString(["list", "screens"]), + urlCommandString(["list", "actions"]), + urlCommandString(["list", "actions", "directions"]), + urlCommandString(["list", "actions", "keybinds"]) ] } @@ -920,14 +694,6 @@ final class LoopCommandHandler { "loop://\(components.joined(separator: "/"))" } - private func listRouteReplacement(_ components: [String]) -> String { - urlCommandString(["list"] + components) - } - - private func replacementCommand(for descriptor: ExecutableActionDescriptor) -> String { - urlCommandString(descriptor.urlPath.split(separator: "/").map(String.init)) - } - private func failureResponse( message: String, replacementRoute: String? = nil, @@ -949,20 +715,6 @@ final class LoopCommandHandler { ) } - private func removedListAllResponse() -> LoopAutomationResponse { - failureResponse( - message: "list/all has been removed", - availableRoutes: publicListRoutes() - ) - } - - private func removedListKeybindsResponse() -> LoopAutomationResponse { - failureResponse( - message: "list/keybinds has been removed", - replacementRoute: listRouteReplacement(["actions", "keybinds"]) - ) - } - private func invalidListRouteResponse(_ parameters: [String]) -> LoopAutomationResponse { failureResponse( message: "Unknown list route: list/\(parameters.joined(separator: "/"))", @@ -1042,14 +794,14 @@ final class LoopCommandHandler { LoopActionDescriptor( id: descriptor.id, kind: descriptor.actionKind, + title: descriptor.title, name: descriptor.name, - slug: descriptor.slug, route: urlCommandString(descriptor.urlPath.split(separator: "/").map(String.init)), - idRoute: urlCommandString(descriptor.idPath.split(separator: "/").map(String.init)) + idRoute: descriptor.idPath.map { urlCommandString($0.split(separator: "/").map(String.init)) } ) } - // MARK: - Slug and ID Helpers + // MARK: - Name and ID Helpers private func slugifyDisplayString(_ string: String, treatCamelCaseAsWords: Bool = false) -> String { let source = if treatCamelCaseAsWords { @@ -1077,32 +829,10 @@ final class LoopCommandHandler { return slug.isEmpty ? "unnamed" : slug } - private func canonicalDirectionSlug(for direction: WindowDirection) -> String { + private func canonicalDirectionName(for direction: WindowDirection) -> String { slugifyDisplayString(direction.rawValue, treatCamelCaseAsWords: true) } - private func deterministicDirectionID(for direction: WindowDirection) -> UUID { - uuidV5(namespace: Self.directionIDNamespace, name: direction.rawValue) - } - - private func uuidV5(namespace: UUID, name: String) -> UUID { - var namespaceUUID = namespace.uuid - let namespaceData = withUnsafeBytes(of: &namespaceUUID) { Data($0) } - let nameData = Data(name.utf8) - let digest = Insecure.SHA1.hash(data: namespaceData + nameData) - - var bytes = Array(digest.prefix(16)) - bytes[6] = (bytes[6] & 0x0F) | 0x50 - bytes[8] = (bytes[8] & 0x3F) | 0x80 - - return UUID(uuid: ( - bytes[0], bytes[1], bytes[2], bytes[3], - bytes[4], bytes[5], bytes[6], bytes[7], - bytes[8], bytes[9], bytes[10], bytes[11], - bytes[12], bytes[13], bytes[14], bytes[15] - )) - } - private func shortIdentifier(for uuid: UUID) -> String { String(uuid.uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(8)) } @@ -1291,9 +1021,3 @@ final class LoopCommandHandler { return "No frontmost window found" } } - -private extension Array { - var only: Element? { - count == 1 ? first : nil - } -} diff --git a/LoopCLI/CLIErrorFormatter.swift b/LoopCLI/CLIErrorFormatter.swift index 2fb5aed2..f9813a59 100644 --- a/LoopCLI/CLIErrorFormatter.swift +++ b/LoopCLI/CLIErrorFormatter.swift @@ -26,10 +26,6 @@ struct CLIErrorFormatter { self.executableName = executableName } - func runtimeError(_ message: String) -> CLICommandError { - CLICommandError(message: message) - } - func error(from response: CLIResponse) -> CLICommandError { var lines: [String] = [] diff --git a/LoopCLI/CLIOutputFormatter.swift b/LoopCLI/CLIOutputFormatter.swift index 284974d0..f6f2b256 100644 --- a/LoopCLI/CLIOutputFormatter.swift +++ b/LoopCLI/CLIOutputFormatter.swift @@ -124,33 +124,36 @@ struct CLIOutputFormatter { } private func formatExecution(_ result: LoopExecutionResult) -> String { - let slug = sanitizeInline(result.action.slug) + let name = sanitizeInline(result.action.name) guard let window = result.targetWindow else { - return "Successfully executed \(slug)" + return "Successfully executed \(name)" } if let appName = nonEmptyString(window.appName).map(sanitizeInline) { - return "Successfully executed \(slug) on \(appName) (Window ID: \(window.id))" + return "Successfully executed \(name) on \(appName) (Window ID: \(window.id))" } - return "Successfully executed \(slug) (Window ID: \(window.id))" + return "Successfully executed \(name) (Window ID: \(window.id))" } private func formatActionRows(_ actions: [LoopActionDescriptor], showIDs: Bool) -> [String] { let rows = actions.map { action in - (slug: sanitizeInline(action.slug), id: action.idString) + (name: sanitizeInline(action.name), id: action.idString) } guard showIDs else { - return rows.map { blue($0.slug) } + return rows.map { blue($0.name) } } - let slugColumnWidth = rows.map(\.slug.count).max() ?? 0 + let nameColumnWidth = rows.map(\.name.count).max() ?? 0 return rows.map { row in - let paddedSlug = row.slug.padding(toLength: slugColumnWidth, withPad: " ", startingAt: 0) - return "\(blue(paddedSlug)) \(dim(row.id))" + let paddedName = row.name.padding(toLength: nameColumnWidth, withPad: " ", startingAt: 0) + if let id = row.id { + return "\(blue(paddedName)) \(dim(id))" + } + return blue(paddedName) } } diff --git a/LoopCLI/CLIOutputMode.swift b/LoopCLI/CLIOutputMode.swift deleted file mode 100644 index ccaa43ad..00000000 --- a/LoopCLI/CLIOutputMode.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// CLIOutputMode.swift -// LoopCLI -// -// Created by Kai Azim on 2026-03-30. -// - -enum CLIOutputMode { - case human - case json -} diff --git a/LoopCLI/CLIRequest.swift b/LoopCLI/CLIRequest.swift index 9994f339..a6802c6b 100644 --- a/LoopCLI/CLIRequest.swift +++ b/LoopCLI/CLIRequest.swift @@ -53,11 +53,6 @@ struct CLIRequest { } struct CLIOutputConfiguration { - let mode: CLIOutputMode + let mode: OutputOptions.Mode let showIDs: Bool - - static let `default` = CLIOutputConfiguration( - mode: .human, - showIDs: false - ) } diff --git a/LoopCLI/CLIResponse.swift b/LoopCLI/CLIResponse.swift index df800cc5..2bd28fe3 100644 --- a/LoopCLI/CLIResponse.swift +++ b/LoopCLI/CLIResponse.swift @@ -28,8 +28,4 @@ struct CLIResponse { var automationError: LoopAutomationError? { automationResponse?.error } - - var keybindActions: [LoopActionDescriptor] { - automationResponse?.result?.actionList?.keybindActions ?? [] - } } diff --git a/LoopCLI/ExecCommand.swift b/LoopCLI/ExecCommand.swift index ce81b129..7435d123 100644 --- a/LoopCLI/ExecCommand.swift +++ b/LoopCLI/ExecCommand.swift @@ -29,10 +29,10 @@ struct ExecCommand: ParsableCommand, CLIRequestCommand { @Option(name: .customLong("direction"), help: "Execute a built-in direction action") var direction: String? - @Option(name: .customLong("keybind"), help: "Execute a keybind-backed action by display name") + @Option(name: .customLong("keybind"), help: "Execute a keybind-backed action by name") var keybind: String? - @Option(name: .customLong("id"), help: "Execute any action by UUID") + @Option(name: .customLong("id"), help: "Execute an action by UUID") var actionID: ActionIdentifier? @OptionGroup @@ -68,9 +68,8 @@ struct ExecCommand: ParsableCommand, CLIRequestCommand { } if let keybind { - let descriptor = try application.resolveKeybind(named: keybind) return CLIRequest( - routeComponents: ["id", descriptor.idString], + routeComponents: ["keybind", keybind], queryItems: queryItems ) } diff --git a/LoopCLI/LoopCLIApplication.swift b/LoopCLI/LoopCLIApplication.swift index aebf6e1a..f08bfdc9 100644 --- a/LoopCLI/LoopCLIApplication.swift +++ b/LoopCLI/LoopCLIApplication.swift @@ -36,47 +36,4 @@ final class LoopCLIApplication { print(outputFormatter.format(response, configuration: outputConfiguration)) } - - func resolveKeybind(named displayName: String) throws -> LoopActionDescriptor { - let response = try socketClient.send( - CLIRequest(routeComponents: ["list", "actions", "keybinds"]) - ) - - guard response.isSuccess else { - throw errorFormatter.error(from: response) - } - - let normalizedDisplayName = displayName.trimmingCharacters(in: .whitespacesAndNewlines) - let matches = response.keybindActions.filter { - $0.name.caseInsensitiveCompare(normalizedDisplayName) == .orderedSame - } - - if let match = matches.only { - return match - } - - if matches.isEmpty { - throw errorFormatter.runtimeError( - """ - Unknown keybind name: \(displayName) - Try: \(Self.executableName) list actions --keybinds - """ - ) - } - - let matchingIdentifiers = matches.map(\.idString).joined(separator: ", ") - throw errorFormatter.runtimeError( - """ - Multiple keybind actions share the name "\(displayName)". - Matching IDs: \(matchingIdentifiers) - Try: \(Self.executableName) exec --id - """ - ) - } -} - -private extension Array { - var only: Element? { - count == 1 ? first : nil - } } diff --git a/LoopCLI/OutputOptions.swift b/LoopCLI/OutputOptions.swift index 482f0e45..364b837c 100644 --- a/LoopCLI/OutputOptions.swift +++ b/LoopCLI/OutputOptions.swift @@ -11,7 +11,12 @@ struct OutputOptions: ParsableArguments { @Flag(name: .customLong("json"), help: "Print raw JSON instead of human-readable text") var json = false - var outputMode: CLIOutputMode { + var outputMode: Mode { json ? .json : .human } + + enum Mode { + case human + case json + } } diff --git a/Shared/LoopAutomationModels.swift b/Shared/LoopAutomationModels.swift index 5293c307..60c226c5 100644 --- a/Shared/LoopAutomationModels.swift +++ b/Shared/LoopAutomationModels.swift @@ -48,9 +48,6 @@ struct LoopRect: Codable { ) } - var cgRect: CGRect { - CGRect(x: x, y: y, width: width, height: height) - } } struct LoopWindowSummary: Codable { @@ -76,15 +73,15 @@ struct LoopScreenSummary: Codable { } struct LoopActionDescriptor: Codable { - let id: UUID + let id: UUID? let kind: LoopActionKind + let title: String let name: String - let slug: String let route: String - let idRoute: String + let idRoute: String? - var idString: String { - id.uuidString.lowercased() + var idString: String? { + id?.uuidString.lowercased() } } @@ -142,38 +139,6 @@ enum LoopAutomationResult: Codable { } } - var windowList: LoopWindowListResult? { - guard case let .windowList(result) = self else { - return nil - } - - return result - } - - var screenList: LoopScreenListResult? { - guard case let .screenList(result) = self else { - return nil - } - - return result - } - - var actionList: LoopActionListResult? { - guard case let .actionList(result) = self else { - return nil - } - - return result - } - - var execution: LoopExecutionResult? { - guard case let .execution(result) = self else { - return nil - } - - return result - } - init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) let kind = try container.decode(LoopAutomationResultKind.self, forKey: .kind) From 78d40e74ca56e132b450972750800a5badd902ec Mon Sep 17 00:00:00 2001 From: Kai Azim Date: Sat, 4 Apr 2026 17:58:52 -0600 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=8E=A8=20Format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Updater/PrivilegedHelperCoordinator.swift | 2 +- Loop/Updater/UpdateInstaller.swift | 4 ++-- LoopCLI/CLIOutputFormatter.swift | 2 +- LoopCLI/ExecCommand.swift | 2 +- LoopCLI/OutputOptions.swift | 2 +- Shared/LoopAutomationModels.swift | 23 +++++++++---------- 6 files changed, 17 insertions(+), 18 deletions(-) diff --git a/Loop/Updater/PrivilegedHelperCoordinator.swift b/Loop/Updater/PrivilegedHelperCoordinator.swift index fcd0ce5c..e4e45525 100644 --- a/Loop/Updater/PrivilegedHelperCoordinator.swift +++ b/Loop/Updater/PrivilegedHelperCoordinator.swift @@ -20,7 +20,7 @@ private struct PrivilegedHelperCoordinatorError: LocalizedError { @Loggable final class PrivilegedHelperCoordinator { - enum PrivilegedHelperReadiness: Sendable { + enum PrivilegedHelperReadiness { case available case unavailable(reason: String) } diff --git a/Loop/Updater/UpdateInstaller.swift b/Loop/Updater/UpdateInstaller.swift index aa7a10fc..a30709c8 100644 --- a/Loop/Updater/UpdateInstaller.swift +++ b/Loop/Updater/UpdateInstaller.swift @@ -12,7 +12,7 @@ import Security @Loggable actor UpdateInstaller { - enum InstallationPermissionState: Sendable { + enum InstallationPermissionState { case writable case needsElevation(reason: String) case notWritableNoElevationPossible(reason: String) @@ -1181,7 +1181,7 @@ actor UpdateInstaller { // MARK: - AppLocation -enum AppLocation: CustomStringConvertible, Sendable { +enum AppLocation: CustomStringConvertible { case systemApplications case userApplications case other(String) diff --git a/LoopCLI/CLIOutputFormatter.swift b/LoopCLI/CLIOutputFormatter.swift index f6f2b256..19024a87 100644 --- a/LoopCLI/CLIOutputFormatter.swift +++ b/LoopCLI/CLIOutputFormatter.swift @@ -5,9 +5,9 @@ // Created by Kai Azim on 2026-03-30. // +import CoreGraphics import Darwin import Foundation -import CoreGraphics struct CLIOutputFormatter { private let supportsANSIStyle = isatty(STDOUT_FILENO) != 0 diff --git a/LoopCLI/ExecCommand.swift b/LoopCLI/ExecCommand.swift index 7435d123..3e128144 100644 --- a/LoopCLI/ExecCommand.swift +++ b/LoopCLI/ExecCommand.swift @@ -57,7 +57,7 @@ struct ExecCommand: ParsableCommand, CLIRequestCommand { } } - func makeRequest(using application: LoopCLIApplication) throws -> CLIRequest { + func makeRequest(using _: LoopCLIApplication) throws -> CLIRequest { let queryItems = targetOptions.queryItems if let direction { diff --git a/LoopCLI/OutputOptions.swift b/LoopCLI/OutputOptions.swift index 364b837c..65175a63 100644 --- a/LoopCLI/OutputOptions.swift +++ b/LoopCLI/OutputOptions.swift @@ -14,7 +14,7 @@ struct OutputOptions: ParsableArguments { var outputMode: Mode { json ? .json : .human } - + enum Mode { case human case json diff --git a/Shared/LoopAutomationModels.swift b/Shared/LoopAutomationModels.swift index 60c226c5..bc94dce3 100644 --- a/Shared/LoopAutomationModels.swift +++ b/Shared/LoopAutomationModels.swift @@ -47,7 +47,6 @@ struct LoopRect: Codable { height: rect.height ) } - } struct LoopWindowSummary: Codable { @@ -145,30 +144,30 @@ enum LoopAutomationResult: Codable { switch kind { case .windowList: - self = .windowList( + self = try .windowList( LoopWindowListResult( - windows: try container.decode([LoopWindowSummary].self, forKey: .windows) + windows: container.decode([LoopWindowSummary].self, forKey: .windows) ) ) case .screenList: - self = .screenList( + self = try .screenList( LoopScreenListResult( - screens: try container.decode([LoopScreenSummary].self, forKey: .screens) + screens: container.decode([LoopScreenSummary].self, forKey: .screens) ) ) case .actionList: - self = .actionList( + self = try .actionList( LoopActionListResult( - filter: try container.decode(LoopActionListFilter.self, forKey: .filter), - directionCategories: try container.decode([LoopActionCategory].self, forKey: .directionCategories), - keybindActions: try container.decode([LoopActionDescriptor].self, forKey: .keybindActions) + filter: container.decode(LoopActionListFilter.self, forKey: .filter), + directionCategories: container.decode([LoopActionCategory].self, forKey: .directionCategories), + keybindActions: container.decode([LoopActionDescriptor].self, forKey: .keybindActions) ) ) case .execution: - self = .execution( + self = try .execution( LoopExecutionResult( - action: try container.decode(LoopActionDescriptor.self, forKey: .action), - targetWindow: try container.decodeIfPresent(LoopExecutionTargetWindow.self, forKey: .targetWindow) + action: container.decode(LoopActionDescriptor.self, forKey: .action), + targetWindow: container.decodeIfPresent(LoopExecutionTargetWindow.self, forKey: .targetWindow) ) ) }