Add swift CLI

This commit is contained in:
Peter Steinberger 2025-05-23 05:40:27 +02:00
parent b3327b7cbe
commit 2619f2a916
11 changed files with 1495 additions and 0 deletions

30
swift-cli/Package.swift Normal file
View file

@ -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"]
),
]
)

View file

@ -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<pid_t> = []
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])
}

View file

@ -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<CGDirectDisplayID>(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)
}
}

View file

@ -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<T: Codable>(data: T, messages: [String]? = nil) {
let response = CodableJSONResponse(success: true, data: data, messages: messages, debug_logs: Logger.shared.getDebugLogs())
outputJSONCodable(response)
}
func outputJSONCodable<T: Codable>(_ 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<T: Codable>: 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))
}

View file

@ -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<WindowDetailOption> {
guard let detailsString = includeDetails else {
return []
}
let components = detailsString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
var options: Set<WindowDetailOption> = []
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()
}
}
}

View file

@ -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()
}
}

View file

@ -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)"
}
}
}

View file

@ -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
}

View file

@ -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"
}
}
}

View file

@ -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()

View file

@ -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)
}
}
}