From 2619f2a916229ef09ecbab76cc5efe43e6159dfe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 23 May 2025 05:40:27 +0200 Subject: [PATCH] Add swift CLI --- swift-cli/Package.swift | 30 ++ .../Sources/peekaboo/ApplicationFinder.swift | 164 ++++++++++ swift-cli/Sources/peekaboo/ImageCommand.swift | 300 ++++++++++++++++++ swift-cli/Sources/peekaboo/JSONOutput.swift | 190 +++++++++++ swift-cli/Sources/peekaboo/ListCommand.swift | 179 +++++++++++ swift-cli/Sources/peekaboo/Logger.swift | 54 ++++ swift-cli/Sources/peekaboo/Models.swift | 135 ++++++++ .../Sources/peekaboo/PermissionsChecker.swift | 47 +++ .../Sources/peekaboo/WindowManager.swift | 113 +++++++ swift-cli/Sources/peekaboo/main.swift | 15 + .../Tests/peekabooTests/ModelsTests.swift | 268 ++++++++++++++++ 11 files changed, 1495 insertions(+) create mode 100644 swift-cli/Package.swift create mode 100644 swift-cli/Sources/peekaboo/ApplicationFinder.swift create mode 100644 swift-cli/Sources/peekaboo/ImageCommand.swift create mode 100644 swift-cli/Sources/peekaboo/JSONOutput.swift create mode 100644 swift-cli/Sources/peekaboo/ListCommand.swift create mode 100644 swift-cli/Sources/peekaboo/Logger.swift create mode 100644 swift-cli/Sources/peekaboo/Models.swift create mode 100644 swift-cli/Sources/peekaboo/PermissionsChecker.swift create mode 100644 swift-cli/Sources/peekaboo/WindowManager.swift create mode 100644 swift-cli/Sources/peekaboo/main.swift create mode 100644 swift-cli/Tests/peekabooTests/ModelsTests.swift diff --git a/swift-cli/Package.swift b/swift-cli/Package.swift new file mode 100644 index 0000000..69c0dce --- /dev/null +++ b/swift-cli/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.7 +import PackageDescription + +let package = Package( + name: "peekaboo", + platforms: [ + .macOS(.v12) + ], + products: [ + .executable( + name: "peekaboo", + targets: ["peekaboo"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"), + ], + targets: [ + .executableTarget( + name: "peekaboo", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ] + ), + .testTarget( + name: "peekabooTests", + dependencies: ["peekaboo"] + ), + ] +) \ No newline at end of file diff --git a/swift-cli/Sources/peekaboo/ApplicationFinder.swift b/swift-cli/Sources/peekaboo/ApplicationFinder.swift new file mode 100644 index 0000000..96e3f9a --- /dev/null +++ b/swift-cli/Sources/peekaboo/ApplicationFinder.swift @@ -0,0 +1,164 @@ +import Foundation +import AppKit + +struct AppMatch { + let app: NSRunningApplication + let score: Double + let matchType: String +} + +class ApplicationFinder { + + static func findApplication(identifier: String) throws -> NSRunningApplication { + Logger.shared.debug("Searching for application: \(identifier)") + + let runningApps = NSWorkspace.shared.runningApplications + var matches: [AppMatch] = [] + + // Exact bundle ID match (highest priority) + if let exactBundleMatch = runningApps.first(where: { $0.bundleIdentifier == identifier }) { + Logger.shared.debug("Found exact bundle ID match: \(exactBundleMatch.localizedName ?? "Unknown")") + return exactBundleMatch + } + + // Exact name match (case insensitive) + for app in runningApps { + if let appName = app.localizedName, appName.lowercased() == identifier.lowercased() { + matches.append(AppMatch(app: app, score: 1.0, matchType: "exact_name")) + } + } + + // Partial name matches + for app in runningApps { + if let appName = app.localizedName { + let lowerAppName = appName.lowercased() + let lowerIdentifier = identifier.lowercased() + + // Check if app name starts with identifier + if lowerAppName.hasPrefix(lowerIdentifier) { + let score = Double(lowerIdentifier.count) / Double(lowerAppName.count) + matches.append(AppMatch(app: app, score: score, matchType: "prefix")) + } + // Check if app name contains identifier + else if lowerAppName.contains(lowerIdentifier) { + let score = Double(lowerIdentifier.count) / Double(lowerAppName.count) * 0.8 + matches.append(AppMatch(app: app, score: score, matchType: "contains")) + } + } + + // Check bundle ID partial matches + if let bundleId = app.bundleIdentifier { + let lowerBundleId = bundleId.lowercased() + let lowerIdentifier = identifier.lowercased() + + if lowerBundleId.contains(lowerIdentifier) { + let score = Double(lowerIdentifier.count) / Double(lowerBundleId.count) * 0.6 + matches.append(AppMatch(app: app, score: score, matchType: "bundle_contains")) + } + } + } + + // Sort by score (highest first) + matches.sort { $0.score > $1.score } + + // Remove duplicates (same app might match multiple ways) + var uniqueMatches: [AppMatch] = [] + var seenPIDs: Set = [] + + for match in matches { + if !seenPIDs.contains(match.app.processIdentifier) { + uniqueMatches.append(match) + seenPIDs.insert(match.app.processIdentifier) + } + } + + if uniqueMatches.isEmpty { + Logger.shared.error("No applications found matching: \(identifier)") + outputError( + message: "No running applications found matching identifier: \(identifier)", + code: .APP_NOT_FOUND, + details: "Available applications: \(runningApps.compactMap { $0.localizedName }.joined(separator: ", "))" + ) + throw ApplicationError.notFound(identifier) + } + + // Check for ambiguous matches (multiple high-scoring matches) + let topScore = uniqueMatches[0].score + let topMatches = uniqueMatches.filter { abs($0.score - topScore) < 0.1 } + + if topMatches.count > 1 { + let matchDescriptions = topMatches.map { match in + "\(match.app.localizedName ?? "Unknown") (\(match.app.bundleIdentifier ?? "unknown.bundle"))" + } + + Logger.shared.error("Ambiguous application identifier: \(identifier)") + outputError( + message: "Multiple applications match identifier '\(identifier)'. Please be more specific.", + code: .AMBIGUOUS_APP_IDENTIFIER, + details: "Matches found: \(matchDescriptions.joined(separator: ", "))" + ) + throw ApplicationError.ambiguous(identifier, topMatches.map { $0.app }) + } + + let bestMatch = uniqueMatches[0] + Logger.shared.debug("Found application: \(bestMatch.app.localizedName ?? "Unknown") (score: \(bestMatch.score), type: \(bestMatch.matchType))") + + return bestMatch.app + } + + static func getAllRunningApplications() -> [ApplicationInfo] { + Logger.shared.debug("Retrieving all running applications") + + let runningApps = NSWorkspace.shared.runningApplications + var result: [ApplicationInfo] = [] + + for app in runningApps { + // Skip background-only apps without a name + guard let appName = app.localizedName, !appName.isEmpty else { + continue + } + + // Count windows for this app + let windowCount = countWindowsForApp(pid: app.processIdentifier) + + let appInfo = ApplicationInfo( + app_name: appName, + bundle_id: app.bundleIdentifier ?? "", + pid: app.processIdentifier, + is_active: app.isActive, + window_count: windowCount + ) + + result.append(appInfo) + } + + // Sort by name for consistent output + result.sort { $0.app_name.lowercased() < $1.app_name.lowercased() } + + Logger.shared.debug("Found \(result.count) running applications") + return result + } + + private static func countWindowsForApp(pid: pid_t) -> Int { + let options = CGWindowListOption(arrayLiteral: .optionOnScreenOnly, .excludeDesktopElements) + + guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { + return 0 + } + + var count = 0 + for windowInfo in windowList { + if let windowPID = windowInfo[kCGWindowOwnerPID as String] as? Int32, + windowPID == pid { + count += 1 + } + } + + return count + } +} + +enum ApplicationError: Error { + case notFound(String) + case ambiguous(String, [NSRunningApplication]) +} \ No newline at end of file diff --git a/swift-cli/Sources/peekaboo/ImageCommand.swift b/swift-cli/Sources/peekaboo/ImageCommand.swift new file mode 100644 index 0000000..f0d4de5 --- /dev/null +++ b/swift-cli/Sources/peekaboo/ImageCommand.swift @@ -0,0 +1,300 @@ +import Foundation +import ArgumentParser +import AppKit +import CoreGraphics +import UniformTypeIdentifiers + +struct ImageCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "image", + abstract: "Capture screen or window images" + ) + + @Option(name: .long, help: "Target application identifier") + var app: String? + + @Option(name: .long, help: "Base output path for saved images") + var path: String? + + @Option(name: .long, help: "Capture mode") + var mode: CaptureMode? + + @Option(name: .long, help: "Window title to capture") + var windowTitle: String? + + @Option(name: .long, help: "Window index to capture (0=frontmost)") + var windowIndex: Int? + + @Option(name: .long, help: "Image format") + var format: ImageFormat = .png + + @Option(name: .long, help: "Capture focus behavior") + var captureFocus: CaptureFocus = .background + + @Flag(name: .long, help: "Output results in JSON format") + var jsonOutput = false + + func run() throws { + Logger.shared.setJsonOutputMode(jsonOutput) + + do { + try PermissionsChecker.requireScreenRecordingPermission() + + let captureMode = determineMode() + let savedFiles: [SavedFile] + + switch captureMode { + case .screen: + savedFiles = try captureAllScreens() + case .window: + if let app = app { + savedFiles = try captureApplicationWindow(app) + } else { + throw CaptureError.appNotFound("No application specified for window capture") + } + case .multi: + if let app = app { + savedFiles = try captureAllApplicationWindows(app) + } else { + savedFiles = try captureAllScreens() + } + } + + let data = ImageCaptureData(saved_files: savedFiles) + + if jsonOutput { + outputSuccess(data: data) + } else { + print("Captured \(savedFiles.count) image(s):") + for file in savedFiles { + print(" \(file.path)") + } + } + + } catch { + if jsonOutput { + let code: ErrorCode = .CAPTURE_FAILED + outputError( + message: error.localizedDescription, + code: code, + details: "Image capture operation failed" + ) + } else { + print("Error: \(error.localizedDescription)", to: &standardError) + } + throw ExitCode.failure + } + } + + private func determineMode() -> CaptureMode { + if let mode = mode { + return mode + } + return app != nil ? .window : .screen + } + + private func captureAllScreens() throws -> [SavedFile] { + var savedFiles: [SavedFile] = [] + + var displayCount: UInt32 = 0 + let result = CGGetActiveDisplayList(0, nil, &displayCount) + guard result == .success && displayCount > 0 else { + throw CaptureError.noDisplaysAvailable + } + + var displays = Array(repeating: 0, count: Int(displayCount)) + let listResult = CGGetActiveDisplayList(displayCount, &displays, nil) + guard listResult == .success else { + throw CaptureError.noDisplaysAvailable + } + + for (index, displayID) in displays.enumerated() { + let fileName = generateFileName(displayIndex: index) + let filePath = getOutputPath(fileName) + + try captureDisplay(displayID, to: filePath) + + let savedFile = SavedFile( + path: filePath, + item_label: "Display \(index + 1)", + window_title: nil, + window_id: nil, + window_index: nil, + mime_type: format == .png ? "image/png" : "image/jpeg" + ) + savedFiles.append(savedFile) + } + + return savedFiles + } + + private func captureApplicationWindow(_ appIdentifier: String) throws -> [SavedFile] { + let targetApp = try ApplicationFinder.findApplication(identifier: appIdentifier) + + if captureFocus == .foreground { + try PermissionsChecker.requireAccessibilityPermission() + targetApp.activate(options: [.activateIgnoringOtherApps]) + Thread.sleep(forTimeInterval: 0.2) // Brief delay for activation + } + + let windows = try WindowManager.getWindowsForApp(pid: targetApp.processIdentifier) + guard !windows.isEmpty else { + throw CaptureError.windowNotFound + } + + let targetWindow: WindowData + if let windowTitle = windowTitle { + guard let window = windows.first(where: { $0.title.contains(windowTitle) }) else { + throw CaptureError.windowNotFound + } + targetWindow = window + } else if let windowIndex = windowIndex { + guard windowIndex >= 0 && windowIndex < windows.count else { + throw CaptureError.invalidWindowIndex(windowIndex) + } + targetWindow = windows[windowIndex] + } else { + targetWindow = windows[0] // frontmost window + } + + let fileName = generateFileName(appName: targetApp.localizedName, windowTitle: targetWindow.title) + let filePath = getOutputPath(fileName) + + try captureWindow(targetWindow, to: filePath) + + let savedFile = SavedFile( + path: filePath, + item_label: targetApp.localizedName, + window_title: targetWindow.title, + window_id: targetWindow.windowId, + window_index: targetWindow.windowIndex, + mime_type: format == .png ? "image/png" : "image/jpeg" + ) + + return [savedFile] + } + + private func captureAllApplicationWindows(_ appIdentifier: String) throws -> [SavedFile] { + let targetApp = try ApplicationFinder.findApplication(identifier: appIdentifier) + + if captureFocus == .foreground { + try PermissionsChecker.requireAccessibilityPermission() + targetApp.activate(options: [.activateIgnoringOtherApps]) + Thread.sleep(forTimeInterval: 0.2) + } + + let windows = try WindowManager.getWindowsForApp(pid: targetApp.processIdentifier) + guard !windows.isEmpty else { + throw CaptureError.windowNotFound + } + + var savedFiles: [SavedFile] = [] + + for (index, window) in windows.enumerated() { + let fileName = generateFileName(appName: targetApp.localizedName, windowIndex: index, windowTitle: window.title) + let filePath = getOutputPath(fileName) + + try captureWindow(window, to: filePath) + + let savedFile = SavedFile( + path: filePath, + item_label: targetApp.localizedName, + window_title: window.title, + window_id: window.windowId, + window_index: index, + mime_type: format == .png ? "image/png" : "image/jpeg" + ) + savedFiles.append(savedFile) + } + + return savedFiles + } + + private func captureDisplay(_ displayID: CGDirectDisplayID, to path: String) throws { + guard let image = CGDisplayCreateImage(displayID) else { + throw CaptureError.captureCreationFailed + } + try saveImage(image, to: path) + } + + private func captureWindow(_ window: WindowData, to path: String) throws { + let options: CGWindowImageOption = [.boundsIgnoreFraming, .shouldBeOpaque] + + guard let image = CGWindowListCreateImage( + window.bounds, + .optionIncludingWindow, + window.windowId, + options + ) else { + throw CaptureError.windowCaptureFailed + } + + try saveImage(image, to: path) + } + + private func saveImage(_ image: CGImage, to path: String) throws { + let url = URL(fileURLWithPath: path) + + let utType: UTType = format == .png ? .png : .jpeg + guard let destination = CGImageDestinationCreateWithURL( + url as CFURL, + utType.identifier as CFString, + 1, + nil + ) else { + throw CaptureError.fileWriteError(path) + } + + CGImageDestinationAddImage(destination, image, nil) + + guard CGImageDestinationFinalize(destination) else { + throw CaptureError.fileWriteError(path) + } + } + + private func generateFileName(displayIndex: Int? = nil, appName: String? = nil, windowIndex: Int? = nil, windowTitle: String? = nil) -> String { + let timestamp = DateFormatter.timestamp.string(from: Date()) + let ext = format.rawValue + + if let displayIndex = displayIndex { + return "screen_\(displayIndex + 1)_\(timestamp).\(ext)" + } else if let appName = appName { + let cleanAppName = appName.replacingOccurrences(of: " ", with: "_") + if let windowIndex = windowIndex { + return "\(cleanAppName)_window_\(windowIndex)_\(timestamp).\(ext)" + } else if let windowTitle = windowTitle { + let cleanTitle = windowTitle.replacingOccurrences(of: " ", with: "_").prefix(20) + return "\(cleanAppName)_\(cleanTitle)_\(timestamp).\(ext)" + } else { + return "\(cleanAppName)_\(timestamp).\(ext)" + } + } else { + return "capture_\(timestamp).\(ext)" + } + } + + private func getOutputPath(_ fileName: String) -> String { + if let basePath = path { + return "\(basePath)/\(fileName)" + } else { + return "/tmp/\(fileName)" + } + } +} + +extension DateFormatter { + static let timestamp: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd_HHmmss" + return formatter + }() +} + +var standardError = FileHandle.standardError + +extension FileHandle: TextOutputStream { + public func write(_ string: String) { + guard let data = string.data(using: .utf8) else { return } + self.write(data) + } +} \ No newline at end of file diff --git a/swift-cli/Sources/peekaboo/JSONOutput.swift b/swift-cli/Sources/peekaboo/JSONOutput.swift new file mode 100644 index 0000000..e9a0572 --- /dev/null +++ b/swift-cli/Sources/peekaboo/JSONOutput.swift @@ -0,0 +1,190 @@ +import Foundation + +struct JSONResponse: Codable { + let success: Bool + let data: AnyCodable? + let messages: [String]? + let debug_logs: [String] + let error: ErrorInfo? + + init(success: Bool, data: Any? = nil, messages: [String]? = nil, error: ErrorInfo? = nil) { + self.success = success + self.data = data.map(AnyCodable.init) + self.messages = messages + self.debug_logs = Logger.shared.getDebugLogs() + self.error = error + } +} + +struct ErrorInfo: Codable { + let message: String + let code: String + let details: String? + + init(message: String, code: ErrorCode, details: String? = nil) { + self.message = message + self.code = code.rawValue + self.details = details + } +} + +enum ErrorCode: String { + case PERMISSION_DENIED_SCREEN_RECORDING = "PERMISSION_DENIED_SCREEN_RECORDING" + case PERMISSION_DENIED_ACCESSIBILITY = "PERMISSION_DENIED_ACCESSIBILITY" + case APP_NOT_FOUND = "APP_NOT_FOUND" + case AMBIGUOUS_APP_IDENTIFIER = "AMBIGUOUS_APP_IDENTIFIER" + case WINDOW_NOT_FOUND = "WINDOW_NOT_FOUND" + case CAPTURE_FAILED = "CAPTURE_FAILED" + case FILE_IO_ERROR = "FILE_IO_ERROR" + case INVALID_ARGUMENT = "INVALID_ARGUMENT" + case SIPS_ERROR = "SIPS_ERROR" + case INTERNAL_SWIFT_ERROR = "INTERNAL_SWIFT_ERROR" +} + +// Helper for encoding arbitrary data as JSON +struct AnyCodable: Codable { + let value: Any + + init(_ value: Any) { + self.value = value + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + if let codable = value as? Codable { + // Handle Codable types by encoding them directly as JSON + let jsonEncoder = JSONEncoder() + let jsonData = try jsonEncoder.encode(AnyEncodable(codable)) + let jsonObject = try JSONSerialization.jsonObject(with: jsonData) + try container.encode(AnyCodable(jsonObject)) + } else { + switch value { + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let array as [Any]: + try container.encode(array.map(AnyCodable.init)) + case let dict as [String: Any]: + try container.encode(dict.mapValues(AnyCodable.init)) + default: + // Try to encode as a string representation + try container.encode(String(describing: value)) + } + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let bool = try? container.decode(Bool.self) { + value = bool + } else if let int = try? container.decode(Int.self) { + value = int + } else if let double = try? container.decode(Double.self) { + value = double + } else if let string = try? container.decode(String.self) { + value = string + } else if let array = try? container.decode([AnyCodable].self) { + value = array.map { $0.value } + } else if let dict = try? container.decode([String: AnyCodable].self) { + value = dict.mapValues { $0.value } + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Cannot decode value") + ) + } + } +} + +// Helper for encoding any Codable type +private struct AnyEncodable: Encodable { + let encodable: Encodable + + init(_ encodable: Encodable) { + self.encodable = encodable + } + + func encode(to encoder: Encoder) throws { + try encodable.encode(to: encoder) + } +} + +func outputJSON(_ response: JSONResponse) { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(response) + if let jsonString = String(data: data, encoding: .utf8) { + print(jsonString) + } + } catch { + Logger.shared.error("Failed to encode JSON response: \(error)") + // Fallback to simple error JSON + print(""" + { + "success": false, + "error": { + "message": "Failed to encode JSON response", + "code": "INTERNAL_SWIFT_ERROR" + }, + "debug_logs": [] + } + """) + } +} + +func outputSuccess(data: Any? = nil, messages: [String]? = nil) { + // Special handling for Codable types + if let codableData = data as? Codable { + outputSuccessCodable(data: codableData, messages: messages) + } else { + outputJSON(JSONResponse(success: true, data: data, messages: messages)) + } +} + +func outputSuccessCodable(data: T, messages: [String]? = nil) { + let response = CodableJSONResponse(success: true, data: data, messages: messages, debug_logs: Logger.shared.getDebugLogs()) + outputJSONCodable(response) +} + +func outputJSONCodable(_ response: T) { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(response) + if let jsonString = String(data: data, encoding: .utf8) { + print(jsonString) + } + } catch { + Logger.shared.error("Failed to encode JSON response: \(error)") + // Fallback to simple error JSON + print(""" + { + "success": false, + "error": { + "message": "Failed to encode JSON response", + "code": "INTERNAL_SWIFT_ERROR" + }, + "debug_logs": [] + } + """) + } +} + +struct CodableJSONResponse: Codable { + let success: Bool + let data: T + let messages: [String]? + let debug_logs: [String] +} + +func outputError(message: String, code: ErrorCode, details: String? = nil) { + let error = ErrorInfo(message: message, code: code, details: details) + outputJSON(JSONResponse(success: false, error: error)) +} \ No newline at end of file diff --git a/swift-cli/Sources/peekaboo/ListCommand.swift b/swift-cli/Sources/peekaboo/ListCommand.swift new file mode 100644 index 0000000..4a0ad47 --- /dev/null +++ b/swift-cli/Sources/peekaboo/ListCommand.swift @@ -0,0 +1,179 @@ +import Foundation +import ArgumentParser +import AppKit + +struct ListCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List running applications or windows", + subcommands: [AppsSubcommand.self, WindowsSubcommand.self], + defaultSubcommand: AppsSubcommand.self + ) +} + +struct AppsSubcommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "apps", + abstract: "List all running applications" + ) + + @Flag(name: .long, help: "Output results in JSON format") + var jsonOutput = false + + func run() throws { + Logger.shared.setJsonOutputMode(jsonOutput) + + do { + try PermissionsChecker.requireScreenRecordingPermission() + + let applications = ApplicationFinder.getAllRunningApplications() + let data = ApplicationListData(applications: applications) + + if jsonOutput { + outputSuccess(data: data) + } else { + printApplicationList(applications) + } + + } catch { + Logger.shared.error("Failed to list applications: \(error)") + if jsonOutput { + outputError(message: error.localizedDescription, code: .INTERNAL_SWIFT_ERROR) + } else { + fputs("Error: \(error.localizedDescription)\n", stderr) + } + throw ExitCode.failure + } + } + + private func printApplicationList(_ applications: [ApplicationInfo]) { + print("Running Applications (\(applications.count)):") + print() + + for (index, app) in applications.enumerated() { + print("\(index + 1). \(app.app_name)") + print(" Bundle ID: \(app.bundle_id)") + print(" PID: \(app.pid)") + print(" Status: \(app.is_active ? "Active" : "Background")") + print(" Windows: \(app.window_count)") + print() + } + } +} + +struct WindowsSubcommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "windows", + abstract: "List windows for a specific application" + ) + + @Option(name: .long, help: "Target application identifier") + var app: String + + @Option(name: .long, help: "Include additional window details (comma-separated: off_screen,bounds,ids)") + var includeDetails: String? + + @Flag(name: .long, help: "Output results in JSON format") + var jsonOutput = false + + func run() throws { + Logger.shared.setJsonOutputMode(jsonOutput) + + do { + try PermissionsChecker.requireScreenRecordingPermission() + + // Find the target application + let targetApp = try ApplicationFinder.findApplication(identifier: app) + + // Parse include details options + let detailOptions = parseIncludeDetails() + + // Get windows for the app + let windows = try WindowManager.getWindowsInfoForApp( + pid: targetApp.processIdentifier, + includeOffScreen: detailOptions.contains(.off_screen), + includeBounds: detailOptions.contains(.bounds), + includeIDs: detailOptions.contains(.ids) + ) + + let targetAppInfo = TargetApplicationInfo( + app_name: targetApp.localizedName ?? "Unknown", + bundle_id: targetApp.bundleIdentifier, + pid: targetApp.processIdentifier + ) + + let data = WindowListData( + windows: windows, + target_application_info: targetAppInfo + ) + + if jsonOutput { + outputSuccess(data: data) + } else { + printWindowList(data) + } + + } catch { + Logger.shared.error("Failed to list windows: \(error)") + if jsonOutput { + outputError(message: error.localizedDescription, code: .INTERNAL_SWIFT_ERROR) + } else { + fputs("Error: \(error.localizedDescription)\n", stderr) + } + throw ExitCode.failure + } + } + + private func parseIncludeDetails() -> Set { + guard let detailsString = includeDetails else { + return [] + } + + let components = detailsString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + var options: Set = [] + + for component in components { + if let option = WindowDetailOption(rawValue: component) { + options.insert(option) + } + } + + return options + } + + private func printWindowList(_ data: WindowListData) { + let app = data.target_application_info + let windows = data.windows + + print("Windows for \(app.app_name)") + if let bundleId = app.bundle_id { + print("Bundle ID: \(bundleId)") + } + print("PID: \(app.pid)") + print("Total Windows: \(windows.count)") + print() + + if windows.isEmpty { + print("No windows found.") + return + } + + for (index, window) in windows.enumerated() { + print("\(index + 1). \"\(window.window_title)\"") + + if let windowId = window.window_id { + print(" Window ID: \(windowId)") + } + + if let isOnScreen = window.is_on_screen { + print(" On Screen: \(isOnScreen ? "Yes" : "No")") + } + + if let bounds = window.bounds { + print(" Bounds: (\(bounds.x), \(bounds.y)) \(bounds.width)×\(bounds.height)") + } + + print() + } + } +} \ No newline at end of file diff --git a/swift-cli/Sources/peekaboo/Logger.swift b/swift-cli/Sources/peekaboo/Logger.swift new file mode 100644 index 0000000..30e27bb --- /dev/null +++ b/swift-cli/Sources/peekaboo/Logger.swift @@ -0,0 +1,54 @@ +import Foundation + +class Logger { + static let shared = Logger() + private var debugLogs: [String] = [] + private var isJsonOutputMode = false + + private init() {} + + func setJsonOutputMode(_ enabled: Bool) { + isJsonOutputMode = enabled + debugLogs.removeAll() + } + + func debug(_ message: String) { + if isJsonOutputMode { + debugLogs.append(message) + } else { + fputs("DEBUG: \(message)\n", stderr) + } + } + + func info(_ message: String) { + if isJsonOutputMode { + debugLogs.append("INFO: \(message)") + } else { + fputs("INFO: \(message)\n", stderr) + } + } + + func warn(_ message: String) { + if isJsonOutputMode { + debugLogs.append("WARN: \(message)") + } else { + fputs("WARN: \(message)\n", stderr) + } + } + + func error(_ message: String) { + if isJsonOutputMode { + debugLogs.append("ERROR: \(message)") + } else { + fputs("ERROR: \(message)\n", stderr) + } + } + + func getDebugLogs() -> [String] { + return debugLogs + } + + func clearDebugLogs() { + debugLogs.removeAll() + } +} \ No newline at end of file diff --git a/swift-cli/Sources/peekaboo/Models.swift b/swift-cli/Sources/peekaboo/Models.swift new file mode 100644 index 0000000..aa5e008 --- /dev/null +++ b/swift-cli/Sources/peekaboo/Models.swift @@ -0,0 +1,135 @@ +import Foundation +import ArgumentParser + +// MARK: - Image Capture Models + +struct SavedFile: Codable { + let path: String + let item_label: String? + let window_title: String? + let window_id: UInt32? + let window_index: Int? + let mime_type: String +} + +struct ImageCaptureData: Codable { + let saved_files: [SavedFile] +} + +enum CaptureMode: String, CaseIterable, ExpressibleByArgument { + case screen + case window + case multi +} + +enum ImageFormat: String, CaseIterable, ExpressibleByArgument { + case png + case jpg +} + +enum CaptureFocus: String, CaseIterable, ExpressibleByArgument { + case background + case foreground +} + +// MARK: - Application & Window Models + +struct ApplicationInfo: Codable { + let app_name: String + let bundle_id: String + let pid: Int32 + let is_active: Bool + let window_count: Int +} + +struct ApplicationListData: Codable { + let applications: [ApplicationInfo] +} + +struct WindowInfo: Codable { + let window_title: String + let window_id: UInt32? + let window_index: Int? + let bounds: WindowBounds? + let is_on_screen: Bool? +} + +struct WindowBounds: Codable { + let x: Int + let y: Int + let width: Int + let height: Int +} + +struct TargetApplicationInfo: Codable { + let app_name: String + let bundle_id: String? + let pid: Int32 +} + +struct WindowListData: Codable { + let windows: [WindowInfo] + let target_application_info: TargetApplicationInfo +} + +// MARK: - Window Specifier + +enum WindowSpecifier { + case title(String) + case index(Int) +} + +// MARK: - Window Details Options + +enum WindowDetailOption: String, CaseIterable { + case off_screen + case bounds + case ids +} + +// MARK: - Window Management + +struct WindowData { + let windowId: UInt32 + let title: String + let bounds: CGRect + let isOnScreen: Bool + let windowIndex: Int +} + +// MARK: - Error Types + +enum CaptureError: Error, LocalizedError { + case noDisplaysAvailable + case capturePermissionDenied + case invalidDisplayID + case captureCreationFailed + case windowNotFound + case windowCaptureFailed + case fileWriteError(String) + case appNotFound(String) + case invalidWindowIndex(Int) + + var errorDescription: String? { + switch self { + case .noDisplaysAvailable: + return "No displays available for capture" + case .capturePermissionDenied: + return "Screen recording permission denied. Please grant permission in System Preferences > Security & Privacy > Privacy > Screen Recording" + case .invalidDisplayID: + return "Invalid display ID" + case .captureCreationFailed: + return "Failed to create screen capture" + case .windowNotFound: + return "Window not found" + case .windowCaptureFailed: + return "Failed to capture window" + case .fileWriteError(let path): + return "Failed to write file to: \(path)" + case .appNotFound(let identifier): + return "Application not found: \(identifier)" + case .invalidWindowIndex(let index): + return "Invalid window index: \(index)" + } + } +} \ No newline at end of file diff --git a/swift-cli/Sources/peekaboo/PermissionsChecker.swift b/swift-cli/Sources/peekaboo/PermissionsChecker.swift new file mode 100644 index 0000000..77278a9 --- /dev/null +++ b/swift-cli/Sources/peekaboo/PermissionsChecker.swift @@ -0,0 +1,47 @@ +import Foundation +import CoreGraphics +import AVFoundation + +class PermissionsChecker { + + static func checkScreenRecordingPermission() -> Bool { + // Check if we can capture screen content by trying to get display bounds + var displayCount: UInt32 = 0 + let result = CGGetActiveDisplayList(0, nil, &displayCount) + if result != .success || displayCount == 0 { + return false + } + + // Try to capture a small image to test permissions + guard let mainDisplayID = CGMainDisplayID() as CGDirectDisplayID? else { + return false + } + + // Test by trying to get display bounds - this requires screen recording permission + let bounds = CGDisplayBounds(mainDisplayID) + return bounds != CGRect.zero + } + + static func checkAccessibilityPermission() -> Bool { + // Check if we have accessibility permission + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false] + return AXIsProcessTrustedWithOptions(options as CFDictionary) + } + + static func requireScreenRecordingPermission() throws { + if !checkScreenRecordingPermission() { + throw CaptureError.capturePermissionDenied + } + } + + static func requireAccessibilityPermission() throws { + if !checkAccessibilityPermission() { + throw CaptureError.capturePermissionDenied + } + } +} + +enum PermissionError: Error { + case screenRecordingDenied + case accessibilityDenied +} \ No newline at end of file diff --git a/swift-cli/Sources/peekaboo/WindowManager.swift b/swift-cli/Sources/peekaboo/WindowManager.swift new file mode 100644 index 0000000..2c41a93 --- /dev/null +++ b/swift-cli/Sources/peekaboo/WindowManager.swift @@ -0,0 +1,113 @@ +import Foundation +import CoreGraphics +import AppKit + +class WindowManager { + + static func getWindowsForApp(pid: pid_t, includeOffScreen: Bool = false) throws -> [WindowData] { + Logger.shared.debug("Getting windows for PID: \(pid)") + + let options: CGWindowListOption = includeOffScreen + ? [.excludeDesktopElements] + : [.excludeDesktopElements, .optionOnScreenOnly] + + guard let windowList = CGWindowListCopyWindowInfo(options, kCGNullWindowID) as? [[String: Any]] else { + throw WindowError.windowListFailed + } + + var windows: [WindowData] = [] + var windowIndex = 0 + + for windowInfo in windowList { + guard let windowPID = windowInfo[kCGWindowOwnerPID as String] as? Int32, + windowPID == pid else { + continue + } + + guard let windowID = windowInfo[kCGWindowNumber as String] as? CGWindowID else { + continue + } + + let windowTitle = windowInfo[kCGWindowName as String] as? String ?? "Untitled" + + // Get window bounds + var bounds = CGRect.zero + if let boundsDict = windowInfo[kCGWindowBounds as String] as? [String: Any] { + let x = boundsDict["X"] as? Double ?? 0 + let y = boundsDict["Y"] as? Double ?? 0 + let width = boundsDict["Width"] as? Double ?? 0 + let height = boundsDict["Height"] as? Double ?? 0 + bounds = CGRect(x: x, y: y, width: width, height: height) + } + + // Determine if window is on screen + let isOnScreen = windowInfo[kCGWindowIsOnscreen as String] as? Bool ?? true + + let windowData = WindowData( + windowId: windowID, + title: windowTitle, + bounds: bounds, + isOnScreen: isOnScreen, + windowIndex: windowIndex + ) + + windows.append(windowData) + windowIndex += 1 + } + + // Sort by window layer (frontmost first) + windows.sort { (first: WindowData, second: WindowData) -> Bool in + // Windows with higher layer (closer to front) come first + return first.windowIndex < second.windowIndex + } + + Logger.shared.debug("Found \(windows.count) windows for PID \(pid)") + return windows + } + + static func getWindowsInfoForApp( + pid: pid_t, + includeOffScreen: Bool = false, + includeBounds: Bool = false, + includeIDs: Bool = false + ) throws -> [WindowInfo] { + + let windowDataArray = try getWindowsForApp(pid: pid, includeOffScreen: includeOffScreen) + + return windowDataArray.map { windowData in + WindowInfo( + window_title: windowData.title, + window_id: includeIDs ? windowData.windowId : nil, + window_index: windowData.windowIndex, + bounds: includeBounds ? WindowBounds( + x: Int(windowData.bounds.origin.x), + y: Int(windowData.bounds.origin.y), + width: Int(windowData.bounds.size.width), + height: Int(windowData.bounds.size.height) + ) : nil, + is_on_screen: includeOffScreen ? windowData.isOnScreen : nil + ) + } + } +} + +// Extension to add the getWindowsForApp function to ImageCommand +extension ImageCommand { + func getWindowsForApp(pid: pid_t) throws -> [WindowData] { + return try WindowManager.getWindowsForApp(pid: pid) + } +} + +enum WindowError: Error, LocalizedError { + case windowListFailed + case noWindowsFound + + var errorDescription: String? { + switch self { + case .windowListFailed: + return "Failed to get window list from system" + case .noWindowsFound: + return "No windows found for the specified application" + } + } +} \ No newline at end of file diff --git a/swift-cli/Sources/peekaboo/main.swift b/swift-cli/Sources/peekaboo/main.swift new file mode 100644 index 0000000..501f5f8 --- /dev/null +++ b/swift-cli/Sources/peekaboo/main.swift @@ -0,0 +1,15 @@ +import Foundation +import ArgumentParser + +struct PeekabooCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "peekaboo", + abstract: "A macOS utility for screen capture, application listing, and window management", + version: "1.1.1", + subcommands: [ImageCommand.self, ListCommand.self], + defaultSubcommand: ImageCommand.self + ) +} + +// Entry point +PeekabooCommand.main() \ No newline at end of file diff --git a/swift-cli/Tests/peekabooTests/ModelsTests.swift b/swift-cli/Tests/peekabooTests/ModelsTests.swift new file mode 100644 index 0000000..8eee911 --- /dev/null +++ b/swift-cli/Tests/peekabooTests/ModelsTests.swift @@ -0,0 +1,268 @@ +import XCTest +@testable import peekaboo + +final class ModelsTests: XCTestCase { + + func testCaptureMode() { + // Test CaptureMode enum values + XCTAssertEqual(CaptureMode.screen.rawValue, "screen") + XCTAssertEqual(CaptureMode.window.rawValue, "window") + XCTAssertEqual(CaptureMode.multi.rawValue, "multi") + + // Test CaptureMode from string + XCTAssertEqual(CaptureMode(rawValue: "screen"), .screen) + XCTAssertEqual(CaptureMode(rawValue: "window"), .window) + XCTAssertEqual(CaptureMode(rawValue: "multi"), .multi) + XCTAssertNil(CaptureMode(rawValue: "invalid")) + } + + func testImageFormat() { + // Test ImageFormat enum values + XCTAssertEqual(ImageFormat.png.rawValue, "png") + XCTAssertEqual(ImageFormat.jpg.rawValue, "jpg") + + // Test ImageFormat from string + XCTAssertEqual(ImageFormat(rawValue: "png"), .png) + XCTAssertEqual(ImageFormat(rawValue: "jpg"), .jpg) + XCTAssertNil(ImageFormat(rawValue: "invalid")) + } + + func testCaptureFocus() { + // Test CaptureFocus enum values + XCTAssertEqual(CaptureFocus.background.rawValue, "background") + XCTAssertEqual(CaptureFocus.foreground.rawValue, "foreground") + + // Test CaptureFocus from string + XCTAssertEqual(CaptureFocus(rawValue: "background"), .background) + XCTAssertEqual(CaptureFocus(rawValue: "foreground"), .foreground) + XCTAssertNil(CaptureFocus(rawValue: "invalid")) + } + + func testWindowDetailOption() { + // Test WindowDetailOption enum values + XCTAssertEqual(WindowDetailOption.off_screen.rawValue, "off_screen") + XCTAssertEqual(WindowDetailOption.bounds.rawValue, "bounds") + XCTAssertEqual(WindowDetailOption.ids.rawValue, "ids") + + // Test WindowDetailOption from string + XCTAssertEqual(WindowDetailOption(rawValue: "off_screen"), .off_screen) + XCTAssertEqual(WindowDetailOption(rawValue: "bounds"), .bounds) + XCTAssertEqual(WindowDetailOption(rawValue: "ids"), .ids) + XCTAssertNil(WindowDetailOption(rawValue: "invalid")) + } + + func testWindowBounds() { + let bounds = WindowBounds(x: 100, y: 200, width: 1200, height: 800) + + XCTAssertEqual(bounds.x, 100) + XCTAssertEqual(bounds.y, 200) + XCTAssertEqual(bounds.width, 1200) + XCTAssertEqual(bounds.height, 800) + } + + func testSavedFile() { + let savedFile = SavedFile( + path: "/tmp/test.png", + item_label: "Screen 1", + window_title: "Safari - Main Window", + window_id: 12345, + window_index: 0, + mime_type: "image/png" + ) + + XCTAssertEqual(savedFile.path, "/tmp/test.png") + XCTAssertEqual(savedFile.item_label, "Screen 1") + XCTAssertEqual(savedFile.window_title, "Safari - Main Window") + XCTAssertEqual(savedFile.window_id, 12345) + XCTAssertEqual(savedFile.window_index, 0) + XCTAssertEqual(savedFile.mime_type, "image/png") + } + + func testSavedFileWithNilValues() { + let savedFile = SavedFile( + path: "/tmp/screen.png", + item_label: nil, + window_title: nil, + window_id: nil, + window_index: nil, + mime_type: "image/png" + ) + + XCTAssertEqual(savedFile.path, "/tmp/screen.png") + XCTAssertNil(savedFile.item_label) + XCTAssertNil(savedFile.window_title) + XCTAssertNil(savedFile.window_id) + XCTAssertNil(savedFile.window_index) + XCTAssertEqual(savedFile.mime_type, "image/png") + } + + func testApplicationInfo() { + let appInfo = ApplicationInfo( + app_name: "Safari", + bundle_id: "com.apple.Safari", + pid: 1234, + is_active: true, + window_count: 2 + ) + + XCTAssertEqual(appInfo.app_name, "Safari") + XCTAssertEqual(appInfo.bundle_id, "com.apple.Safari") + XCTAssertEqual(appInfo.pid, 1234) + XCTAssertTrue(appInfo.is_active) + XCTAssertEqual(appInfo.window_count, 2) + } + + func testWindowInfo() { + let bounds = WindowBounds(x: 100, y: 100, width: 1200, height: 800) + let windowInfo = WindowInfo( + window_title: "Safari - Main Window", + window_id: 12345, + window_index: 0, + bounds: bounds, + is_on_screen: true + ) + + XCTAssertEqual(windowInfo.window_title, "Safari - Main Window") + XCTAssertEqual(windowInfo.window_id, 12345) + XCTAssertEqual(windowInfo.window_index, 0) + XCTAssertNotNil(windowInfo.bounds) + XCTAssertEqual(windowInfo.bounds?.x, 100) + XCTAssertEqual(windowInfo.bounds?.y, 100) + XCTAssertEqual(windowInfo.bounds?.width, 1200) + XCTAssertEqual(windowInfo.bounds?.height, 800) + XCTAssertTrue(windowInfo.is_on_screen!) + } + + func testTargetApplicationInfo() { + let targetApp = TargetApplicationInfo( + app_name: "Safari", + bundle_id: "com.apple.Safari", + pid: 1234 + ) + + XCTAssertEqual(targetApp.app_name, "Safari") + XCTAssertEqual(targetApp.bundle_id, "com.apple.Safari") + XCTAssertEqual(targetApp.pid, 1234) + } + + func testApplicationListData() { + let app1 = ApplicationInfo( + app_name: "Safari", + bundle_id: "com.apple.Safari", + pid: 1234, + is_active: true, + window_count: 2 + ) + + let app2 = ApplicationInfo( + app_name: "Terminal", + bundle_id: "com.apple.Terminal", + pid: 5678, + is_active: false, + window_count: 1 + ) + + let appListData = ApplicationListData(applications: [app1, app2]) + + XCTAssertEqual(appListData.applications.count, 2) + XCTAssertEqual(appListData.applications[0].app_name, "Safari") + XCTAssertEqual(appListData.applications[1].app_name, "Terminal") + } + + func testWindowListData() { + let bounds = WindowBounds(x: 100, y: 100, width: 1200, height: 800) + let window = WindowInfo( + window_title: "Safari - Main Window", + window_id: 12345, + window_index: 0, + bounds: bounds, + is_on_screen: true + ) + + let targetApp = TargetApplicationInfo( + app_name: "Safari", + bundle_id: "com.apple.Safari", + pid: 1234 + ) + + let windowListData = WindowListData( + windows: [window], + target_application_info: targetApp + ) + + XCTAssertEqual(windowListData.windows.count, 1) + XCTAssertEqual(windowListData.windows[0].window_title, "Safari - Main Window") + XCTAssertEqual(windowListData.target_application_info.app_name, "Safari") + XCTAssertEqual(windowListData.target_application_info.bundle_id, "com.apple.Safari") + XCTAssertEqual(windowListData.target_application_info.pid, 1234) + } + + func testImageCaptureData() { + let savedFile = SavedFile( + path: "/tmp/test.png", + item_label: "Screen 1", + window_title: nil, + window_id: nil, + window_index: nil, + mime_type: "image/png" + ) + + let imageData = ImageCaptureData(saved_files: [savedFile]) + + XCTAssertEqual(imageData.saved_files.count, 1) + XCTAssertEqual(imageData.saved_files[0].path, "/tmp/test.png") + XCTAssertEqual(imageData.saved_files[0].item_label, "Screen 1") + XCTAssertEqual(imageData.saved_files[0].mime_type, "image/png") + } + + func testCaptureErrorDescriptions() { + XCTAssertEqual(CaptureError.noDisplaysAvailable.errorDescription, "No displays available for capture") + XCTAssertTrue(CaptureError.capturePermissionDenied.errorDescription!.contains("Screen recording permission denied")) + XCTAssertEqual(CaptureError.invalidDisplayID.errorDescription, "Invalid display ID") + XCTAssertEqual(CaptureError.captureCreationFailed.errorDescription, "Failed to create screen capture") + XCTAssertEqual(CaptureError.windowNotFound.errorDescription, "Window not found") + XCTAssertEqual(CaptureError.windowCaptureFailed.errorDescription, "Failed to capture window") + XCTAssertEqual(CaptureError.fileWriteError("/tmp/test.png").errorDescription, "Failed to write file to: /tmp/test.png") + XCTAssertEqual(CaptureError.appNotFound("Safari").errorDescription, "Application not found: Safari") + XCTAssertEqual(CaptureError.invalidWindowIndex(5).errorDescription, "Invalid window index: 5") + } + + func testWindowData() { + let bounds = CGRect(x: 100, y: 200, width: 1200, height: 800) + let windowData = WindowData( + windowId: 12345, + title: "Safari - Main Window", + bounds: bounds, + isOnScreen: true, + windowIndex: 0 + ) + + XCTAssertEqual(windowData.windowId, 12345) + XCTAssertEqual(windowData.title, "Safari - Main Window") + XCTAssertEqual(windowData.bounds.origin.x, 100) + XCTAssertEqual(windowData.bounds.origin.y, 200) + XCTAssertEqual(windowData.bounds.size.width, 1200) + XCTAssertEqual(windowData.bounds.size.height, 800) + XCTAssertTrue(windowData.isOnScreen) + XCTAssertEqual(windowData.windowIndex, 0) + } + + func testWindowSpecifier() { + let titleSpecifier = WindowSpecifier.title("Main Window") + let indexSpecifier = WindowSpecifier.index(0) + + switch titleSpecifier { + case .title(let title): + XCTAssertEqual(title, "Main Window") + case .index(_): + XCTFail("Expected title specifier") + } + + switch indexSpecifier { + case .title(_): + XCTFail("Expected index specifier") + case .index(let index): + XCTAssertEqual(index, 0) + } + } +} \ No newline at end of file