import AppKit import Foundation // kapture — command-line client for the Kapture menu-bar app. // // It does not capture by itself: requests go to the running app, which // already holds the Screen Recording permission and owns all window / // display capture logic. Requests and responses are JSON files in // ~/Library/Application Support/Kapture/cli, signalled by a distributed // notification carrying the request id. let helpText = """ kapture — screenshot capture for agents and scripts USAGE kapture capture [options] Capture a window or the main display to a PNG kapture list [--json] List on-screen windows (id, app, title, size) kapture --help Show this help CAPTURE OPTIONS --app App whose frontmost window to capture. Matches localized names and bundle ids, case-insensitive substring. Default: the frontmost app's window. --window-id Capture a specific window id from `kapture list`. --screen Capture the main display instead of a window. --out Output PNG path (default: a new file in $TMPDIR). --json Print {"ok","path","width","height"} instead of just the path. BEHAVIOR Prints the absolute PNG path on stdout (exit 0). Errors go to stderr. Requires the Kapture menu-bar app (Homebrew cask); it is launched automatically if it is not running. EXAMPLES kapture capture --app Xcode --out /tmp/xcode.png kapture capture --app Safari --json kapture list kapture capture --window-id 1234 --out shot.png kapture capture --screen """ // MARK: - Bridge enum Bridge { static let notificationName = "dev.tzgyn.kapture.cli.request" static let appBundleID = "dev.tzgyn.kapture" static var directory: URL { FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("Kapture/cli", isDirectory: true) } static func appIsRunning() -> Bool { NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == appBundleID || $0.localizedName == "Kapture" } } static func launchApp() { // Prefer the bundle this CLI lives inside, so a dev CLI launches its // own app build rather than a stale installed copy with the same id. let executable = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath() let appBundle = executable .deletingLastPathComponent() // Contents/MacOS .deletingLastPathComponent() // Contents .deletingLastPathComponent() // Kapture.app let task = Process() task.executableURL = URL(fileURLWithPath: "/usr/bin/open") if appBundle.pathExtension == "app" { task.arguments = [appBundle.path] } else { task.arguments = ["-b", appBundleID] } task.standardOutput = FileHandle.nullDevice task.standardError = FileHandle.nullDevice try? task.run() task.waitUntilExit() } /// Send a request to the app and wait for its response. Posts the /// notification repeatedly so a still-starting app can pick it up. static func send(_ request: [String: Any], timeout: TimeInterval = 12) -> [String: Any]? { try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let id = UUID().uuidString let requestURL = directory.appendingPathComponent("\(id).request.json") let responseURL = directory.appendingPathComponent("\(id).response.json") defer { try? FileManager.default.removeItem(at: requestURL) try? FileManager.default.removeItem(at: responseURL) } guard let data = try? JSONSerialization.data(withJSONObject: request), (try? data.write(to: requestURL, options: .atomic)) != nil else { return nil } if !appIsRunning() { launchApp() } let deadline = Date().addingTimeInterval(timeout) var lastPost = Date.distantPast while Date() < deadline { if Date().timeIntervalSince(lastPost) > 0.3 { lastPost = Date() DistributedNotificationCenter.default().postNotificationName( .init(notificationName), object: nil, userInfo: ["id": id], deliverImmediately: true ) } if let responseData = try? Data(contentsOf: responseURL), let object = (try? JSONSerialization.jsonObject(with: responseData)) as? [String: Any] { return object } usleep(50_000) } return nil } } // MARK: - Output helpers func fail(_ message: String, code: Int32) -> Never { FileHandle.standardError.write(("kapture: " + message + "\n").data(using: .utf8)!) exit(code) } func usageError(_ message: String) -> Never { FileHandle.standardError.write(("kapture: " + message + "\n\n" + helpText + "\n").data(using: .utf8)!) exit(2) } func printJSON(_ object: [String: Any]) { guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) else { return } print(String(data: data, encoding: .utf8) ?? "{}") } func timestamp() -> String { let formatter = DateFormatter() formatter.dateFormat = "yyyyMMdd-HHmmss" return formatter.string(from: Date()) } // MARK: - Commands func runCapture(_ args: [String]) { var app: String? var windowID: Int? var screen = false var out: String? var json = false var index = 0 while index < args.count { switch args[index] { case "--app": index += 1 guard index < args.count else { usageError("--app needs a value") } app = args[index] case "--window-id": index += 1 guard index < args.count, let id = Int(args[index]) else { usageError("--window-id needs a number") } windowID = id case "--screen": screen = true case "--out": index += 1 guard index < args.count else { usageError("--out needs a path") } out = args[index] case "--json": json = true default: usageError("unknown option \"\(args[index])\"") } index += 1 } let outURL = URL(fileURLWithPath: (out.map { ($0 as NSString).expandingTildeInPath } ?? FileManager.default.temporaryDirectory.appendingPathComponent("kapture-\(timestamp()).png").path)) var request: [String: Any] = ["command": "capture", "screen": screen, "out": outURL.path] if let app { request["app"] = app } if let windowID { request["windowID"] = windowID } guard let response = Bridge.send(request) else { fail("the Kapture app did not respond. Is it installed and running?", code: 3) } guard (response["ok"] as? Bool) == true else { fail(response["error"] as? String ?? "capture failed", code: 1) } if json { printJSON([ "ok": true, "path": response["path"] as? String ?? outURL.path, "width": response["width"] as? Int ?? 0, "height": response["height"] as? Int ?? 0, ]) } else { print(response["path"] as? String ?? outURL.path) } } func runList(_ args: [String]) { var json = false for arg in args { switch arg { case "--json": json = true default: usageError("unknown option \"\(arg)\"") } } guard let response = Bridge.send(["command": "list"]) else { fail("the Kapture app did not respond. Is it installed and running?", code: 3) } guard (response["ok"] as? Bool) == true else { fail(response["error"] as? String ?? "could not list windows", code: 1) } let windows = response["windows"] as? [[String: Any]] ?? [] if json { printJSON(["ok": true, "windows": windows]) return } func pad(_ text: String, _ width: Int) -> String { text.count >= width ? String(text.prefix(width - 1)) + "…" : text.padding(toLength: width, withPad: " ", startingAt: 0) } print("\(pad("ID", 8)) \(pad("APP", 20)) \(pad("TITLE", 50)) SIZE") for window in windows { let id = window["id"] as? Int ?? 0 let app = window["app"] as? String ?? "" let title = window["title"] as? String ?? "" let width = window["width"] as? Int ?? 0 let height = window["height"] as? Int ?? 0 print("\(pad(String(id), 8)) \(pad(app, 20)) \(pad(title, 50)) \(width)x\(height)") } } // MARK: - Main var arguments = Array(CommandLine.arguments.dropFirst()) guard let command = arguments.first else { print(helpText) exit(2) } arguments.removeFirst() switch command { case "capture": runCapture(arguments) case "list": runList(arguments) case "--help", "-h", "help": print(helpText) case "--version", "-v": print("kapture (bundled with the Kapture app)") default: usageError("unknown command \"\(command)\"") }