mirror of
https://github.com/samsonjs/Peekaboo.git
synced 2026-04-27 15:07:41 +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