mirror of
https://github.com/samsonjs/Peekaboo.git
synced 2026-03-31 10:25:47 +00:00
Add swift CLI
This commit is contained in:
parent
b3327b7cbe
commit
2619f2a916
11 changed files with 1495 additions and 0 deletions
30
swift-cli/Package.swift
Normal file
30
swift-cli/Package.swift
Normal 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"]
|
||||
),
|
||||
]
|
||||
)
|
||||
164
swift-cli/Sources/peekaboo/ApplicationFinder.swift
Normal file
164
swift-cli/Sources/peekaboo/ApplicationFinder.swift
Normal 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])
|
||||
}
|
||||
300
swift-cli/Sources/peekaboo/ImageCommand.swift
Normal file
300
swift-cli/Sources/peekaboo/ImageCommand.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
190
swift-cli/Sources/peekaboo/JSONOutput.swift
Normal file
190
swift-cli/Sources/peekaboo/JSONOutput.swift
Normal 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))
|
||||
}
|
||||
179
swift-cli/Sources/peekaboo/ListCommand.swift
Normal file
179
swift-cli/Sources/peekaboo/ListCommand.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
54
swift-cli/Sources/peekaboo/Logger.swift
Normal file
54
swift-cli/Sources/peekaboo/Logger.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
135
swift-cli/Sources/peekaboo/Models.swift
Normal file
135
swift-cli/Sources/peekaboo/Models.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
47
swift-cli/Sources/peekaboo/PermissionsChecker.swift
Normal file
47
swift-cli/Sources/peekaboo/PermissionsChecker.swift
Normal 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
|
||||
}
|
||||
113
swift-cli/Sources/peekaboo/WindowManager.swift
Normal file
113
swift-cli/Sources/peekaboo/WindowManager.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
swift-cli/Sources/peekaboo/main.swift
Normal file
15
swift-cli/Sources/peekaboo/main.swift
Normal 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()
|
||||
268
swift-cli/Tests/peekabooTests/ModelsTests.swift
Normal file
268
swift-cli/Tests/peekabooTests/ModelsTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue