Compare commits

...
9 Commits
Author SHA1 Message Date
TZGyn 6f8f436987 Add a kapture CLI so agents can capture windows and screens
kapture capture --app <name> (or --window-id / --screen) writes a PNG and
prints its path; kapture list enumerates on-screen windows. The CLI is a
thin client: requests go to the running menu-bar app over distributed
notifications plus JSON files in Application Support, so captures use the
app's Screen Recording grant and its window/display logic — no second
permission. The app launches automatically when needed, and the bundled
CLI opens its own app bundle rather than a stale installed copy.

Built as `kapture-cli` (Kapture/kapture collide on the case-insensitive
filesystem), signed nested in the app bundle, and installed on PATH as
`kapture` by the Homebrew cask's binary stanza.

Generated with OpenCode

Assisted-by: Kimi:k3
2026-10-27 23:38:43 +08:00
TZGyn cac85402a3 Remember the Actual Size view choice
Persist the magnifier state in UserDefaults so every new result window
opens in the same mode and the choice survives relaunches.

Generated with OpenCode

Assisted-by: Kimi:k3
2026-10-26 21:14:51 +08:00
TZGyn d4b0970ed2 Fix Actual Size: activation, window sizing, and real backing scale
Three bugs made the toggle appear dead: the result window never
activated the LSUIElement app, so the first click was consumed; the
hosting controller's Auto Layout fitting size shrank the window when
the layout branch changed; and SwiftUI's displayScale environment does
not reliably track the window's display on macOS. The window now
activates before ordering front, the hosting view is attached directly
as the contentView (no fitting-size constraints), and the actual-size
scale comes from the window's backingScaleFactor (updated when it moves
between displays).

Generated with OpenCode

Assisted-by: Kimi:k3
2026-10-26 20:57:12 +08:00
TZGyn ce89d34654 Make crop a Photos-style tool with undo and actual-size view
Crop is now a mode like Draw/Redact: a draggable rect with corner and
edge handles, rule-of-thirds grid, dimmed surroundings, Cancel (Esc) and
Crop Image (Return). Re-entering crop after a crop shows the full
original with the handles on the current crop region, so it can be
dragged back outward; every crop is undoable from the toolbar.

The result window also gains an Actual Size view (Cmd+0): one image
pixel per screen pixel, scrollable, so retina captures stay crisp
instead of being resampled down to the window size.

Generated with OpenCode

Assisted-by: Kimi:k3
2026-10-26 20:45:48 +08:00
TZGyn 6a2afef1d2 Add screen recording, crop, and a Cap-style result window
Recording: Ctrl+Shift+Cmd+R (or the menu) opens the same selection
flow — a marquee records that region, clicking a window records it
(following moves). Video is H.264 .mov at 60fps via SCStream ->
AVAssetWriter with the cursor included, auto-saved to ~/Movies and
revealed in Finder on stop. A red border and a floating timer/stop bar
show during recording, both excluded from the video. Audio is wired
behind a flag for later.

The result window gains a Crop button (crop to the selection;
annotations translate and stay editable) and is restyled after Cap:
sliding-pill Screenshot/Text switcher, capsule buttons in Cap blue,
circular icon buttons, and the content on a rounded card — light and
dark.

Generated with OpenCode

Assisted-by: Kimi:k3
2026-10-24 21:48:59 +08:00
TZGyn 04b84e188a Retry OCR on upscaled images when nothing is read
Tiny UI text (around 7pt) reads as nothing at native size — verified
locally. When a pass returns fewer than three characters, OCR retries at
2x and 3x and keeps the fullest result. The smoke test covers a tiny-text
case so the fallback stays working.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-30 11:44:26 +08:00
TZGyn 2e971d5f8d Darken the screen in frozen capture mode
Frozen mode showed the still image at full brightness until a selection
existed, unlike live mode's dark veil. It now draws the same 35% veil over
everything and re-draws the selected area from the snapshot, keeping the
selection bright. The hint text matches the mode.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-25 17:43:59 +08:00
TZGyn e4e20c51d3 Add frozen-screen capture on Ctrl+Shift+Cmd+S
A second hotkey snapshots every display the moment it is pressed and
shows that still image as the overlay, so the pointer and hover states
(tooltips, open menus) are captured. Selection is marquee-only — no
window detection — and the result is cropped from the snapshot rather
than recaptured, keeping the frame exactly as it was.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-25 17:40:12 +08:00
TZGyn 26700c59f4 Make exported stroke thickness match the preview
Strokes were sized in view points but exported in image pixels, so on
retina captures the saved/copied annotation looked several times thinner.
Stroke widths are now converted into image pixels when drawn and scaled
back for the preview, keeping preview and export identical.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-23 17:28:38 +08:00
12 changed files with 1887 additions and 209 deletions
+13 -1
View File
@@ -4,10 +4,22 @@ import PackageDescription
let package = Package(
name: "Kapture",
platforms: [.macOS(.v13)],
products: [
.executable(name: "Kapture", targets: ["Kapture"]),
// Command-line client for agents and scripts; talks to the running
// menu-bar app over distributed notifications (no extra permissions).
// Named kapture-cli because "Kapture" and "kapture" collide on the
// case-insensitive filesystem; the cask installs it as `kapture`.
.executable(name: "kapture-cli", targets: ["KaptureCLI"]),
],
targets: [
.executableTarget(
name: "Kapture",
path: "Sources/Kapture"
)
),
.executableTarget(
name: "KaptureCLI",
path: "Sources/KaptureCLI"
),
]
)
+40 -8
View File
@@ -33,16 +33,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
HotkeyManager.shared.onTrigger = { CaptureController.shared.begin() }
HotkeyManager.shared.onFrozenTrigger = { CaptureController.shared.begin(frozen: true) }
HotkeyManager.shared.onRecordTrigger = {
if RecordingController.shared.state.isRecording {
RecordingController.shared.stop()
} else {
CaptureController.shared.begin(recording: true)
}
}
HotkeyManager.shared.register()
CLIService.shared.start()
}
func applicationWillTerminate(_ notification: Notification) {
RecordingController.shared.finalizeForTermination()
}
}
struct MenuView: View {
@State private var granted = PermissionManager.isGranted
@ObservedObject private var recordingState = RecordingController.shared.state
var body: some View {
Button("Capture & OCR") { CaptureController.shared.begin() }
.keyboardShortcut("s", modifiers: [.command, .shift])
.disabled(recordingState.isRecording)
Button("Capture Frozen Screen") { CaptureController.shared.begin(frozen: true) }
.keyboardShortcut("s", modifiers: [.command, .shift, .control])
.disabled(recordingState.isRecording)
if recordingState.isRecording {
Button("Stop Recording") { RecordingController.shared.stop() }
.keyboardShortcut("r", modifiers: [.command, .shift, .control])
} else {
Button("Record Screen…") { CaptureController.shared.begin(recording: true) }
.keyboardShortcut("r", modifiers: [.command, .shift, .control])
}
Button("Extract from Image File…") { ResultWindow.shared.showOpenPanel() }
@@ -119,8 +146,8 @@ enum LoginItem {
}
}
func renderTestImage(_ text: String, fontSize: CGFloat = 56) -> CGImage? {
let size = NSSize(width: 1900, height: 220)
func renderTestImage(_ text: String, fontSize: CGFloat = 56, width: CGFloat = 1900, height: CGFloat = 220) -> CGImage? {
let size = NSSize(width: width, height: height)
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
@@ -134,15 +161,20 @@ func renderTestImage(_ text: String, fontSize: CGFloat = 56) -> CGImage? {
.font: NSFont.systemFont(ofSize: fontSize),
.foregroundColor: NSColor.black,
]
text.draw(at: NSPoint(x: 60, y: 75), withAttributes: attrs)
// Centre the text vertically so tiny canvases still contain it.
let textHeight = ("Ag" as NSString).size(withAttributes: attrs).height
let y = max(4, (size.height - textHeight) / 2)
text.draw(at: NSPoint(x: 20, y: y), withAttributes: attrs)
NSGraphicsContext.restoreGraphicsState()
return rep.cgImage
}
func runOCRSelfTest() {
let cases: [(expected: String, contains: String)] = [
("Kapture OCR test 1234, hello world", "Kapture OCR test 1234"),
("你好世界,这是一个测试", "你好世界"),
let cases: [(expected: String, contains: String, fontSize: CGFloat, width: CGFloat, height: CGFloat)] = [
("Kapture OCR test 1234, hello world", "Kapture OCR test 1234", 56, 1900, 220),
("你好世界,这是一个测试", "你好世界", 56, 1900, 220),
// Tiny text: needs the upscale retry to read anything at all.
("会话导出并发冲突报错 export fail", "导出", 7, 320, 40),
]
var index = 0
@@ -150,9 +182,9 @@ func runOCRSelfTest() {
guard index < cases.count else { exit(0) }
let current = cases[index]
index += 1
guard let image = renderTestImage(current.expected) else { exit(1) }
guard let image = renderTestImage(current.expected, fontSize: current.fontSize, width: current.width, height: current.height) else { exit(1) }
OCRService.shared.recognize(image) { recognized in
print("case: \(current.expected)")
print("case: \(current.expected) @\(Int(current.fontSize))pt")
print("got: \(recognized)")
let ok = recognized.contains(current.contains)
print(ok ? "PASS" : "FAIL")
+211
View File
@@ -0,0 +1,211 @@
import AppKit
import ImageIO
import UniformTypeIdentifiers
/// Serves `kapture` CLI requests from the running menu-bar app. The CLI
/// writes a JSON request into `~/Library/Application Support/Kapture/cli`
/// and posts a distributed notification carrying its id; this service
/// captures and writes the JSON response alongside it. Captures use the
/// app's own Screen Recording grant, so the CLI needs no permissions.
final class CLIService {
static let shared = CLIService()
static let notificationName = "dev.tzgyn.kapture.cli.request"
static let requestKey = "id"
private var observer: NSObjectProtocol?
private var directory: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Kapture/cli", isDirectory: true)
}
private func requestURL(_ id: String) -> URL {
directory.appendingPathComponent("\(id).request.json")
}
private func responseURL(_ id: String) -> URL {
directory.appendingPathComponent("\(id).response.json")
}
func start() {
guard observer == nil else { return }
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
observer = DistributedNotificationCenter.default().addObserver(
forName: .init(Self.notificationName), object: nil, queue: .main
) { [weak self] note in
guard let id = note.userInfo?[Self.requestKey] as? String else { return }
self?.handle(id: id)
}
CaptureController.log("cli: listening")
}
// MARK: - Requests
private func handle(id: String) {
let url = requestURL(id)
defer { try? FileManager.default.removeItem(at: url) }
guard let data = try? Data(contentsOf: url),
let request = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
respond(id: id, ["ok": false, "error": "Malformed request"])
return
}
switch request["command"] as? String {
case "list":
respond(id: id, [
"ok": true,
"windows": windows().map { window in
[
"id": Int(window.id),
"app": window.app,
"title": window.title,
"width": Int(window.bounds.width),
"height": Int(window.bounds.height),
] as [String: Any]
},
])
case "capture":
handleCapture(id: id, request: request)
default:
respond(id: id, ["ok": false, "error": "Unknown command"])
}
}
private func handleCapture(id: String, request: [String: Any]) {
guard CGPreflightScreenCaptureAccess() else {
respond(id: id, ["ok": false, "error": "Kapture does not have Screen Recording permission"])
return
}
let outPath = request["out"] as? String
if request["screen"] as? Bool == true {
guard let screen = NSScreen.screens.first else {
respond(id: id, ["ok": false, "error": "No screen available"])
return
}
capture(rect: screen.frame, windowID: nil, out: outPath, id: id)
return
}
if let number = request["windowID"] as? NSNumber,
let window = windows().first(where: { Int($0.id) == number.intValue }) {
capture(rect: window.bounds, windowID: window.id, out: outPath, id: id)
return
}
guard let window = bestWindow(for: request["app"] as? String) else {
let error = (request["app"] as? String).map { "No on-screen window found for app \"\($0)\"" }
?? "No window found"
respond(id: id, ["ok": false, "error": error])
return
}
capture(rect: window.bounds, windowID: window.id, out: outPath, id: id)
}
private func capture(rect: NSRect, windowID: CGWindowID?, out: String?, id: String) {
ScreenCapture.shared.capture(rect, excludingWindowIDs: [], windowID: windowID) { [weak self] image in
DispatchQueue.main.async {
guard let self else { return }
guard let image else {
self.respond(id: id, ["ok": false, "error": "Capture failed (Screen Recording permission?)"])
return
}
let url = out.map { URL(fileURLWithPath: $0) }
?? FileManager.default.temporaryDirectory.appendingPathComponent("kapture-\(Self.timestamp()).png")
guard let dest = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else {
self.respond(id: id, ["ok": false, "error": "Could not create \(url.path)"])
return
}
CGImageDestinationAddImage(dest, image, nil)
guard CGImageDestinationFinalize(dest) else {
self.respond(id: id, ["ok": false, "error": "Could not write \(url.path)"])
return
}
CaptureController.log("cli: captured \(image.width)x\(image.height) -> \(url.path)")
self.respond(id: id, ["ok": true, "path": url.path, "width": image.width, "height": image.height])
}
}
}
private func respond(id: String, _ body: [String: Any]) {
guard let data = try? JSONSerialization.data(withJSONObject: body) else { return }
try? data.write(to: responseURL(id), options: .atomic)
}
// MARK: - Windows
private struct WindowInfo {
let id: CGWindowID
let pid: Int
let app: String
let title: String
let bounds: NSRect
}
/// On-screen layer-0 windows, front to back, own panels excluded.
private func windows() -> [WindowInfo] {
guard let list = CGWindowListCopyWindowInfo(
[.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID
) as? [[String: Any]] else { return [] }
let ownPid = Int(ProcessInfo.processInfo.processIdentifier)
return list.compactMap { info in
guard let id = info[kCGWindowNumber as String] as? CGWindowID,
let layer = info[kCGWindowLayer as String] as? Int, layer == 0,
let pid = info[kCGWindowOwnerPID as String] as? Int, pid != ownPid,
let bounds = info[kCGWindowBounds as String] as? [String: CGFloat],
(bounds["Width"] ?? 0) >= 80, (bounds["Height"] ?? 0) >= 60 else { return nil }
let cgRect = NSRect(
x: bounds["X"] ?? 0, y: bounds["Y"] ?? 0,
width: bounds["Width"] ?? 0, height: bounds["Height"] ?? 0
)
return WindowInfo(
id: id,
pid: pid,
app: info[kCGWindowOwnerName as String] as? String ?? "",
title: info[kCGWindowName as String] as? String ?? "",
bounds: Self.cgToAppKit(cgRect)
)
}
}
private func bestWindow(for query: String?) -> WindowInfo? {
let candidates = windows()
guard let query, !query.isEmpty else {
if let front = NSWorkspace.shared.frontmostApplication,
let window = candidates.first(where: { $0.pid == front.processIdentifier }) {
return window
}
return candidates.first
}
let needle = query.lowercased()
if let window = candidates.first(where: { $0.app.lowercased().contains(needle) }) {
return window
}
for app in NSWorkspace.shared.runningApplications {
let name = (app.localizedName ?? "").lowercased()
let bundle = (app.bundleIdentifier ?? "").lowercased()
if name.contains(needle) || bundle.contains(needle),
let window = candidates.first(where: { $0.pid == app.processIdentifier }) {
return window
}
}
return nil
}
private static func cgToAppKit(_ rect: NSRect) -> NSRect {
guard let main = NSScreen.screens.first else { return rect }
return NSRect(
x: rect.origin.x,
y: main.frame.height - rect.origin.y - rect.height,
width: rect.width,
height: rect.height
)
}
private static func timestamp() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmmss"
return formatter.string(from: Date())
}
}
+148 -3
View File
@@ -76,13 +76,25 @@ final class CaptureController: NSObject {
// MARK: - Session lifecycle
func begin() {
/// Frozen mode: the screen is snapshotted at hotkey press, so the pointer
/// and any hover state (tooltips, open menus) end up in the capture.
private(set) var isFrozenMode = false
/// Recording mode: the selection flow ends in a recording, not a still.
private(set) var isRecordingMode = false
private var frozenSnapshots: [ScreenCapture.DisplaySnapshot] = []
func begin(frozen: Bool = false, recording: Bool = false) {
guard !isSelecting else { return }
// No new captures while a recording is running (the selection
// overlays would end up in the video).
guard !RecordingController.shared.state.isRecording else { return }
guard PermissionManager.isGranted else {
PermissionManager.request()
return
}
isSelecting = true
isFrozenMode = frozen && !recording
isRecordingMode = recording
previousApp = NSWorkspace.shared.frontmostApplication
// With the event tap driving input we must NOT activate: stealing
// focus turns the target app into background mode (dimmed chrome),
@@ -92,12 +104,31 @@ final class CaptureController: NSObject {
if !usesTap {
NSApp.activate(ignoringOtherApps: true)
}
CaptureController.log("begin: usesTap=\(usesTap) active=\(NSApp.isActive)")
CaptureController.log("begin: frozen=\(isFrozenMode) recording=\(recording) usesTap=\(usesTap) active=\(NSApp.isActive)")
if frozen {
ScreenCapture.shared.snapshotAllDisplays(includeCursor: true) { [weak self] snapshots in
DispatchQueue.main.async {
guard let self, self.isSelecting else { return }
self.frozenSnapshots = snapshots
CaptureController.log("frozen: captured \(snapshots.count) displays")
self.showOverlays()
}
}
} else {
showOverlays()
}
}
private func showOverlays() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self, self.isSelecting else { return }
self.overlays = NSScreen.screens.map { screen in
let panel = OverlayWindow(screen: screen, controller: self)
(panel.contentView as? OverlayView)?.controller = self
if let view = panel.contentView as? OverlayView {
view.controller = self
view.frozenImage = self.frozenSnapshots.first(where: { $0.frame == screen.frame })?.image
}
return panel
}
// orderFrontRegardless: show without taking key/focus.
@@ -112,9 +143,20 @@ final class CaptureController: NSObject {
/// corners come from the window's own alpha).
private func finishWindowPick(_ windowID: CGWindowID, _ rect: NSRect, ownedRisk: Bool) {
let ids = overlayIDs
let recording = isRecordingMode
hideOverlays()
isSelecting = false
restorePreviousApp()
if recording {
// Owned-popup risk: record the screen region instead of the
// window filter, which could composite the parent into the video.
RecordingController.shared.begin(
region: rect,
windowID: ownedRisk ? nil : windowID,
excludingWindowIDs: ids
)
return
}
DispatchQueue.main.async { [self] in
if ownedRisk {
capture(rect, excludingWindowIDs: ids, roundedCorners: true)
@@ -126,11 +168,38 @@ final class CaptureController: NSObject {
private func finishSelection(_ rect: NSRect) {
let ids = overlayIDs
let frozen = frozenSnapshots
let wasFrozen = isFrozenMode
let recording = isRecordingMode
hideOverlays()
guard rect.width >= 2, rect.height >= 2 else {
cancelSelection()
return
}
if wasFrozen {
let midpoint = NSPoint(x: rect.midX, y: rect.midY)
let snapshot = frozen.first(where: { $0.frame.contains(midpoint) }) ?? frozen.first
isSelecting = false
restorePreviousApp()
DispatchQueue.main.async { [self] in
if let snapshot, let image = ScreenCapture.cropFrozen(snapshot, to: rect) {
CaptureController.log("frozen: cropped \(Int(rect.width))x\(Int(rect.height)) -> \(image.width)x\(image.height)")
ResultWindow.shared.show(image: image)
} else {
ResultWindow.shared.show(image: ResultWindow.failureImage("Could not crop the frozen screen."))
}
}
return
}
if recording {
isSelecting = false
restorePreviousApp()
RecordingController.shared.begin(region: rect, windowID: nil, excludingWindowIDs: ids)
return
}
DispatchQueue.main.async { [self] in
capture(rect, excludingWindowIDs: ids)
}
@@ -149,6 +218,9 @@ final class CaptureController: NSObject {
marqueeRect = nil
hoverWindow = nil
marqueeStart = nil
frozenSnapshots = []
isFrozenMode = false
isRecordingMode = false
}
private func restorePreviousApp() {
@@ -306,6 +378,8 @@ final class CaptureController: NSObject {
}
let appKitPoint = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y))
if type == .mouseMoved {
// Frozen mode is marquee-only: no window hover detection.
if isFrozenMode { return event }
let window = topmostWindowContaining(appKitPoint)
if window?.frame != hoverFrame {
hoverWindow = window
@@ -345,6 +419,9 @@ final class CaptureController: NSObject {
if let rect = marqueeRect, dragDistance >= 4, rect.width >= 2, rect.height >= 2 {
CaptureController.log("tap: marquee complete \(rect)")
finishSelection(rect)
} else if isFrozenMode {
CaptureController.log("tap: frozen click without drag — cancel")
cancelSelection()
} else if let hover = hoverWindow {
// An overlapping window from the same app means this is an
// owned popup — desktopIndependentWindow would composite the
@@ -506,6 +583,74 @@ final class ScreenCapture {
return result
}
/// Screen frame (AppKit global points) + full-resolution image per display.
struct DisplaySnapshot {
let frame: NSRect
let image: CGImage
}
/// Snapshot every display right now. Used by frozen mode: the images are
/// taken before the overlay appears, so the pointer and hover states
/// (tooltips, menus) are baked in.
func snapshotAllDisplays(includeCursor: Bool, completion: @escaping ([DisplaySnapshot]) -> Void) {
if #available(macOS 14.0, *) {
Task {
guard let content = try? await SCShareableContent.current else {
completion([])
return
}
var out: [DisplaySnapshot] = []
for display in content.displays {
guard let screen = NSScreen.screens.first(where: {
(($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0) == display.displayID
}) else { continue }
let config = SCStreamConfiguration()
let scale = screen.backingScaleFactor
config.width = Int(CGFloat(display.width) * scale)
config.height = Int(CGFloat(display.height) * scale)
config.showsCursor = includeCursor
let filter = SCContentFilter(display: display, excludingWindows: [])
if let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) {
out.append(DisplaySnapshot(frame: screen.frame, image: image))
}
}
completion(out)
}
} else {
var out: [DisplaySnapshot] = []
for screen in NSScreen.screens {
let cgRect = CGRect(
x: screen.frame.minX,
y: (NSScreen.screens.first(where: { $0.frame.origin == .zero })?.frame.height ?? screen.frame.height) - screen.frame.maxY,
width: screen.frame.width,
height: screen.frame.height
)
if let image = CGWindowListCreateImage(cgRect, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution, .boundsIgnoreFraming]) {
out.append(DisplaySnapshot(frame: screen.frame, image: image))
}
}
completion(out)
}
}
/// Crop a selection out of a frozen snapshot (top-left pixel space).
static func cropFrozen(_ snapshot: DisplaySnapshot, to rect: NSRect) -> CGImage? {
let scaleX = CGFloat(snapshot.image.width) / snapshot.frame.width
let scaleY = CGFloat(snapshot.image.height) / snapshot.frame.height
guard scaleX > 0, scaleY > 0 else { return nil }
let localX = rect.minX - snapshot.frame.minX
let localTopY = snapshot.frame.maxY - rect.maxY
let px = CGRect(
x: localX * scaleX,
y: localTopY * scaleY,
width: rect.width * scaleX,
height: rect.height * scaleY
)
let safe = px.intersection(CGRect(x: 0, y: 0, width: CGFloat(snapshot.image.width), height: CGFloat(snapshot.image.height))).integral
guard safe.width >= 1, safe.height >= 1 else { return nil }
return snapshot.image.cropping(to: safe)
}
@available(macOS 14.0, *)
private func displayContaining(_ rect: NSRect, in content: SCShareableContent) -> SCDisplay? {
let mid = NSPoint(x: rect.midX, y: rect.midY)
+37 -12
View File
@@ -4,11 +4,16 @@ import AppKit
final class HotkeyManager {
static let shared = HotkeyManager()
/// ⌘⇧S — live capture (ScreenCaptureKit at capture time).
var onTrigger: (() -> Void)?
/// ⌃⇧⌘S — frozen capture (snapshot at hotkey press).
var onFrozenTrigger: (() -> Void)?
/// ⌃⇧⌘R — screen recording (region or window); toggles stop.
var onRecordTrigger: (() -> Void)?
private var hotKeyRef: EventHotKeyRef?
private var hotKeyRefs: [EventHotKeyRef?] = []
private var eventHandlerRef: EventHandlerRef?
private var hotKeyID = EventHotKeyID(signature: 0x4B505452, id: 1)
private let signature: OSType = 0x4B505452
private init() {}
@@ -17,18 +22,38 @@ final class HotkeyManager {
eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed)
)
InstallEventHandler(GetApplicationEventTarget(), { _, _, _ in
HotkeyManager.shared.onTrigger?()
InstallEventHandler(GetApplicationEventTarget(), { _, event, _ in
var hotKeyID = EventHotKeyID()
let status = GetEventParameter(
event,
EventParamName(kEventParamDirectObject),
EventParamType(typeEventHotKeyID),
nil,
MemoryLayout<EventHotKeyID>.size,
nil,
&hotKeyID
)
guard status == noErr else { return noErr }
DispatchQueue.main.async {
switch hotKeyID.id {
case 1: HotkeyManager.shared.onTrigger?()
case 2: HotkeyManager.shared.onFrozenTrigger?()
case 3: HotkeyManager.shared.onRecordTrigger?()
default: break
}
}
return noErr
}, 1, &eventType, nil, &eventHandlerRef)
RegisterEventHotKey(
UInt32(kVK_ANSI_S),
UInt32(cmdKey | shiftKey),
hotKeyID,
GetApplicationEventTarget(),
0,
&hotKeyRef
)
registerHotkey(id: 1, keyCode: UInt32(kVK_ANSI_S), modifiers: UInt32(cmdKey | shiftKey))
registerHotkey(id: 2, keyCode: UInt32(kVK_ANSI_S), modifiers: UInt32(cmdKey | shiftKey | controlKey))
registerHotkey(id: 3, keyCode: UInt32(kVK_ANSI_R), modifiers: UInt32(cmdKey | shiftKey | controlKey))
}
private func registerHotkey(id: UInt32, keyCode: UInt32, modifiers: UInt32) {
var ref: EventHotKeyRef?
let hotKeyID = EventHotKeyID(signature: signature, id: id)
RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &ref)
hotKeyRefs.append(ref)
}
}
+58 -26
View File
@@ -8,34 +8,66 @@ final class OCRService {
func recognize(_ cgImage: CGImage, completion: @escaping (String) -> Void) {
queue.async {
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
request.recognitionLanguages = ["en-US", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR"]
// Without this, Vision defaults to the first listed language and
// renders CJK as Latin garbage (verified: 你好世界 -> "177WR").
if #available(macOS 13.0, *) {
request.automaticallyDetectsLanguage = true
}
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
completion("")
let text = Self.perform(cgImage)
// Tiny text (small UI labels) often reads as nothing at native
// size; retry upscaled and keep whichever pass read more.
guard text.count < 3 else {
completion(text)
return
}
let observations = request.results ?? []
let lines = observations
.sorted { a, b in
let ay = a.boundingBox.midY
let by = b.boundingBox.midY
if abs(ay - by) > 0.02 { return ay > by }
return a.boundingBox.minX < b.boundingBox.minX
}
.compactMap { $0.topCandidates(1).first?.string }
completion(lines.joined(separator: "\n"))
var best = text
for factor in [CGFloat(2), CGFloat(3)] {
guard let upscaled = Self.upscaled(cgImage, factor: factor) else { continue }
let candidate = Self.perform(upscaled)
if candidate.count > best.count { best = candidate }
if best.count >= 3 { break }
}
completion(best)
}
}
private static func perform(_ cgImage: CGImage) -> String {
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
request.recognitionLanguages = ["en-US", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR"]
// Without this, Vision defaults to the first listed language and
// renders CJK as Latin garbage (verified: 你好世界 -> "177WR").
if #available(macOS 13.0, *) {
request.automaticallyDetectsLanguage = true
}
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
return ""
}
let observations = request.results ?? []
let lines = observations
.sorted { a, b in
let ay = a.boundingBox.midY
let by = b.boundingBox.midY
if abs(ay - by) > 0.02 { return ay > by }
return a.boundingBox.minX < b.boundingBox.minX
}
.compactMap { $0.topCandidates(1).first?.string }
return lines.joined(separator: "\n")
}
private static func upscaled(_ cgImage: CGImage, factor: CGFloat) -> CGImage? {
let width = Int(CGFloat(cgImage.width) * factor)
let height = Int(CGFloat(cgImage.height) * factor)
guard width > 0, height > 0, width <= 20000, height <= 20000 else { return nil }
guard let ctx = CGContext(
data: nil, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
ctx.interpolationQuality = .high
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)))
return ctx.makeImage()
}
}
+68 -3
View File
@@ -30,6 +30,9 @@ final class OverlayWindow: NSPanel {
final class OverlayView: NSView {
weak var controller: CaptureController?
/// In frozen mode: the still image to display for this screen (already
/// full resolution). Nil = live mode (dim the real screen).
var frozenImage: CGImage?
override var acceptsFirstResponder: Bool { true }
@@ -56,12 +59,52 @@ final class OverlayView: NSView {
)
}
/// Draw the frozen snapshot filling the screen, then replace its centre
/// hole with the live... no: the image IS the content, drawn opaque.
private func drawFrozen(_ image: CGImage) {
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
ctx.saveGState()
ctx.interpolationQuality = .high
// Image is in pixels, view in points — draw into the view bounds.
ctx.draw(image, in: bounds)
ctx.restoreGState()
}
private func drawSizeLabel(for sel: NSRect, in vf: NSRect) {
let label = "\(Int(sel.width)) × \(Int(sel.height))"
let attrs: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium),
.foregroundColor: NSColor.white,
]
let labelSize = label.size(withAttributes: attrs)
let labelRect = NSRect(
x: vf.maxX - labelSize.width - 8,
y: vf.minY - labelSize.height - 10,
width: labelSize.width + 8,
height: labelSize.height + 6
)
NSColor.black.withAlphaComponent(0.6).setFill()
NSBezierPath(roundedRect: labelRect, xRadius: 4, yRadius: 4).fill()
label.draw(at: NSPoint(x: labelRect.minX + 4, y: labelRect.minY + 3), withAttributes: attrs)
}
override func draw(_ dirtyRect: NSRect) {
NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill()
if let frozenImage {
drawFrozen(frozenImage)
} else {
NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill()
}
// Esc hint — the only way out once clicks are consumed by the tap.
let hint = "Drag to select · hover + click a window to capture it · Esc to cancel"
let hint: String
if controller?.isRecordingMode == true {
hint = "Drag to select · hover + click a window to record · Esc to cancel"
} else if frozenImage != nil {
hint = "Frozen screen · drag to select · Esc to cancel"
} else {
hint = "Drag to select · hover + click a window to capture it · Esc to cancel"
}
let hintAttrs: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .medium),
.foregroundColor: NSColor.white.withAlphaComponent(0.85),
@@ -79,6 +122,28 @@ final class OverlayView: NSView {
guard let controller else { return }
// Frozen mode: same look as live mode — a dark veil over everything,
// with the selection showing the frozen pixels undimmed.
if let frozenImage {
NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill()
if let sel = controller.marqueeRect, sel.width > 0, sel.height > 0 {
let vf = viewRect(sel)
NSGraphicsContext.current?.saveGraphicsState()
NSBezierPath(rect: vf).addClip()
drawFrozen(frozenImage)
NSGraphicsContext.current?.restoreGraphicsState()
let border = NSBezierPath(rect: vf)
border.lineWidth = 1.5
NSColor.controlAccentColor.setStroke()
border.stroke()
drawSizeLabel(for: sel, in: vf)
}
return
}
// Highlight the hovered window (rounded, like real window corners)
if let frame = controller.hoverFrame {
let vf = viewRect(frame)
+181
View File
@@ -0,0 +1,181 @@
import AppKit
import SwiftUI
/// Shared recording state, observed by the menu bar menu and control bar.
final class RecordingState: ObservableObject {
@Published var isRecording = false
@Published var elapsed: TimeInterval = 0
}
/// Owns a recording session: the red border around the recorded region and
/// the floating timer/stop bar. Both chrome windows are excluded from the
/// video. On stop, the file is auto-saved to ~/Movies and revealed in Finder.
final class RecordingController {
static let shared = RecordingController()
let state = RecordingState()
private var recorder: ScreenRecorder?
private var chromeWindows: [NSWindow] = []
private var timer: Timer?
/// `region` is in AppKit global points; `windowID` records that window
/// instead of the region. Called by CaptureController after the
/// selection overlays are hidden.
func begin(region: NSRect, windowID: CGWindowID?, excludingWindowIDs: [Int]) {
guard !state.isRecording else { return }
showChrome(region: region)
let excluded = excludingWindowIDs + chromeWindows.map { $0.windowNumber }
state.isRecording = true
let recorder = ScreenRecorder()
self.recorder = recorder
// Give the window server a beat so the chrome shows up in the
// shareable-content snapshot and gets excluded from the video.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
guard let self, self.recorder === recorder else { return }
recorder.start(region: region, windowID: windowID, excludingWindowIDs: excluded) { [weak self] ok in
DispatchQueue.main.async {
guard let self else { return }
if ok {
self.startTimer()
} else {
self.finish(url: nil)
ResultWindow.shared.show(image: ResultWindow.failureImage("Could not start the recording. Check Screen Recording permission."))
}
}
}
}
}
func stop() {
guard state.isRecording else { return }
state.isRecording = false
timer?.invalidate()
timer = nil
recorder?.stop { [weak self] url in
DispatchQueue.main.async { self?.finish(url: url) }
}
}
/// Best-effort finalize on quit so the .mov isn't left truncated.
/// Blocks briefly — only call from applicationWillTerminate.
func finalizeForTermination() {
guard let recorder else { return }
let semaphore = DispatchSemaphore(value: 0)
recorder.stop { _ in semaphore.signal() }
_ = semaphore.wait(timeout: .now() + 3)
}
private func finish(url: URL?) {
state.isRecording = false
hideChrome()
recorder = nil
state.elapsed = 0
if let url {
CaptureController.log("rec: saved \(url.path)")
NSWorkspace.shared.activateFileViewerSelecting([url])
}
}
private func startTimer() {
let started = Date()
state.elapsed = 0
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
self?.state.elapsed = Date().timeIntervalSince(started)
}
}
// MARK: - Chrome
private func showChrome(region: NSRect) {
// Red border around the recorded region.
let border = NSPanel(
contentRect: region,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
border.level = .screenSaver
border.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
border.backgroundColor = .clear
border.isOpaque = false
border.hasShadow = false
border.ignoresMouseEvents = true
border.contentView = RecordingBorderView()
border.orderFrontRegardless()
// Floating timer/stop bar at the bottom centre of the region.
let bar = NSPanel(
contentRect: .zero,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
bar.level = .screenSaver
bar.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
bar.backgroundColor = .clear
bar.isOpaque = false
bar.hasShadow = false
let hosting = NSHostingView(rootView: RecordingBarView(state: state) { [weak self] in
self?.stop()
})
let size = hosting.fittingSize
bar.setContentSize(size)
bar.setFrameOrigin(NSPoint(x: region.midX - size.width / 2, y: region.minY + 14))
bar.contentView = hosting
bar.orderFrontRegardless()
chromeWindows = [border, bar]
}
private func hideChrome() {
chromeWindows.forEach { $0.orderOut(nil) }
chromeWindows = []
}
}
/// Draws the red rounded border around the recorded region.
private final class RecordingBorderView: NSView {
override func draw(_ dirtyRect: NSRect) {
let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 2, dy: 2), xRadius: 8, yRadius: 8)
NSColor.systemRed.setStroke()
path.lineWidth = 3
path.stroke()
}
}
/// The floating capsule bar: red dot, elapsed time, stop button.
struct RecordingBarView: View {
@ObservedObject var state: RecordingState
let onStop: () -> Void
private var timeString: String {
let total = Int(state.elapsed)
return String(format: "%02d:%02d", total / 60, total % 60)
}
var body: some View {
HStack(spacing: 10) {
Circle()
.fill(Color.red)
.frame(width: 8, height: 8)
Text(timeString)
.font(.system(size: 12, weight: .medium).monospacedDigit())
.foregroundStyle(.white)
Button(action: onStop) {
Image(systemName: "stop.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.white)
.frame(width: 22, height: 22)
.background(Circle().fill(Color.red))
.contentShape(Circle())
}
.buttonStyle(.plain)
.help("Stop recording (⌃⇧⌘R)")
}
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(Color.black.opacity(0.78)))
}
}
File diff suppressed because it is too large Load Diff
+203
View File
@@ -0,0 +1,203 @@
import AppKit
import AVFoundation
import CoreMedia
import ScreenCaptureKit
/// Records a screen region (or a single window) to an H.264 .mov via
/// SCStream -> AVAssetWriter. Works on macOS 13+ (no SCScreenshotManager).
///
/// Video-only for now: the audio path is wired behind `includeAudio`
/// (off) — enabling sound later means setting the flag to true, which
/// turns on `capturesAudio`, adds an AAC track, and starts appending
/// audio sample buffers. That path is untested until enabled.
final class ScreenRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
/// Future: also record system audio into the .mov.
var includeAudio = false
private let queue = DispatchQueue(label: "dev.tzgyn.kapture.rec")
private var stream: SCStream?
private var writer: AVAssetWriter?
private var videoInput: AVAssetWriterInput?
private var audioInput: AVAssetWriterInput?
private var sessionStarted = false
private var outputURL: URL?
/// `region` is in AppKit global points. When `windowID` is given the
/// stream records that window directly (and follows it if it moves);
/// otherwise it records `region` via a display filter + sourceRect.
func start(region: NSRect, windowID: CGWindowID?, excludingWindowIDs: [Int], completion: @escaping (Bool) -> Void) {
Task {
do {
try await prepare(region: region, windowID: windowID, excludingWindowIDs: excludingWindowIDs)
completion(true)
} catch {
CaptureController.log("rec: start failed: \(error.localizedDescription)")
completion(false)
}
}
}
private func prepare(region: NSRect, windowID: CGWindowID?, excludingWindowIDs: [Int]) async throws {
let content = try await SCShareableContent.current
let excludedSet = Set(excludingWindowIDs.map { CGWindowID($0) })
let excluded = content.windows.filter { excludedSet.contains($0.windowID) }
let config = SCStreamConfiguration()
let filter: SCContentFilter
let pixelWidth: Int
let pixelHeight: Int
let regionCentre = NSPoint(x: region.midX, y: region.midY)
let screen = NSScreen.screens.first(where: { $0.frame.contains(regionCentre) }) ?? NSScreen.screens.first
let scale = screen?.backingScaleFactor ?? 2
if let windowID, let scWindow = content.windows.first(where: { $0.windowID == windowID }) {
filter = SCContentFilter(desktopIndependentWindow: scWindow)
pixelWidth = max(2, Int(scWindow.frame.width * scale) / 2 * 2)
pixelHeight = max(2, Int(scWindow.frame.height * scale) / 2 * 2)
CaptureController.log("rec: window id=\(windowID) \(pixelWidth)x\(pixelHeight)")
} else {
guard let screen else {
throw NSError(domain: "dev.tzgyn.kapture", code: 1, userInfo: [NSLocalizedDescriptionKey: "No screen for region"])
}
let cgID = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
guard let display = content.displays.first(where: { $0.displayID == cgID }) ?? content.displays.first else {
throw NSError(domain: "dev.tzgyn.kapture", code: 2, userInfo: [NSLocalizedDescriptionKey: "No display for region"])
}
filter = SCContentFilter(display: display, excludingWindows: excluded)
// sourceRect is display-local POINTS with a TOP-LEFT origin
// (same mapping cropFrozen uses for the frozen snapshots).
let local = CGRect(
x: region.minX - screen.frame.minX,
y: screen.frame.maxY - region.maxY,
width: region.width,
height: region.height
).intersection(CGRect(x: 0, y: 0, width: display.frame.width, height: display.frame.height))
guard local.width >= 2, local.height >= 2 else {
throw NSError(domain: "dev.tzgyn.kapture", code: 3, userInfo: [NSLocalizedDescriptionKey: "Region outside display"])
}
config.sourceRect = local
pixelWidth = max(2, Int(local.width * scale) / 2 * 2)
pixelHeight = max(2, Int(local.height * scale) / 2 * 2)
CaptureController.log("rec: region \(region) sourceRect=\(local) \(pixelWidth)x\(pixelHeight)")
}
config.width = pixelWidth
config.height = pixelHeight
config.minimumFrameInterval = CMTime(value: 1, timescale: 60)
config.pixelFormat = kCVPixelFormatType_32BGRA
// Recordings SHOULD include the pointer (unlike stills).
config.showsCursor = true
config.queueDepth = 6
config.capturesAudio = includeAudio
let movies = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory())
let url = movies.appendingPathComponent("Kapture-\(Self.timestamp()).mov")
let writer = try AVAssetWriter(url: url, fileType: .mov)
let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: pixelWidth,
AVVideoHeightKey: pixelHeight,
])
videoInput.expectsMediaDataInRealTime = true
writer.add(videoInput)
var audioInput: AVAssetWriterInput?
if includeAudio {
let input = AVAssetWriterInput(mediaType: .audio, outputSettings: [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 48000,
AVNumberOfChannelsKey: 2,
])
input.expectsMediaDataInRealTime = true
writer.add(input)
audioInput = input
}
guard writer.startWriting() else {
throw writer.error ?? NSError(domain: "dev.tzgyn.kapture", code: 4, userInfo: [NSLocalizedDescriptionKey: "AVAssetWriter failed to start"])
}
let stream = SCStream(filter: filter, configuration: config, delegate: self)
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue)
if includeAudio {
try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: queue)
}
try await stream.startCapture()
self.stream = stream
self.writer = writer
self.videoInput = videoInput
self.audioInput = audioInput
self.outputURL = url
CaptureController.log("rec: started -> \(url.lastPathComponent)")
}
/// Stops the stream and finalizes the file. Completion gets the URL on
/// success (called on an internal queue — hop to main for UI).
func stop(completion: @escaping (URL?) -> Void) {
Task {
try? await stream?.stopCapture()
stream = nil
queue.async {
guard let writer = self.writer else {
completion(nil)
return
}
self.videoInput?.markAsFinished()
self.audioInput?.markAsFinished()
let url = self.outputURL
self.writer = nil
self.videoInput = nil
self.audioInput = nil
self.sessionStarted = false
writer.finishWriting {
CaptureController.log("rec: finished status=\(writer.status.rawValue)")
completion(writer.status == .completed ? url : nil)
}
}
}
}
// MARK: SCStreamOutput (called on `queue`)
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
guard CMSampleBufferDataIsReady(sampleBuffer), let writer, let videoInput else { return }
switch type {
case .screen:
// Skip incomplete frames (macOS 13.1+ exposes a frame status).
if #available(macOS 13.1, *) {
guard let attachments = (CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [NSDictionary])?.first,
let dict = attachments as? [String: Any],
let statusRaw = dict[SCStreamFrameInfo.status.rawValue] as? Int,
SCFrameStatus(rawValue: statusRaw) == .complete else { return }
}
guard videoInput.isReadyForMoreMediaData else { return }
if !sessionStarted {
sessionStarted = true
writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(sampleBuffer))
}
videoInput.append(sampleBuffer)
case .audio:
guard includeAudio, sessionStarted, let audioInput, audioInput.isReadyForMoreMediaData else { return }
audioInput.append(sampleBuffer)
default:
break
}
}
// MARK: SCStreamDelegate
func stream(_ stream: SCStream, didStopWithError error: Error) {
CaptureController.log("rec: stream stopped with error: \(error.localizedDescription)")
}
private static func timestamp() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmmss"
return formatter.string(from: Date())
}
}
+254
View File
@@ -0,0 +1,254 @@
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 <name|bundle-id> App whose frontmost window to capture. Matches
localized names and bundle ids, case-insensitive
substring. Default: the frontmost app's window.
--window-id <id> Capture a specific window id from `kapture list`.
--screen Capture the main display instead of a window.
--out <path> 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)\"")
}
+9 -2
View File
@@ -89,6 +89,9 @@ fi
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS"
cp "$BIN" "$APP/Contents/MacOS/Kapture"
# CLI client for agents/scripts; installed on PATH as `kapture` by the
# Homebrew cask (binary target: kapture).
cp ".build/release/kapture-cli" "$APP/Contents/MacOS/kapture-cli"
cat > "$APP/Contents/Info.plist" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
@@ -108,9 +111,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
<key>CFBundleIconFile</key>
<string>Icon</string>
<key>CFBundleShortVersionString</key>
<string>1.9.1</string>
<string>1.12.0</string>
<key>CFBundleVersion</key>
<string>91</string>
<string>120</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
@@ -124,9 +127,13 @@ PLIST
chmod +x scripts/make-icon.sh
./scripts/make-icon.sh
# Nested executable: sign it explicitly so the bundle seal and notarization
# see a Developer ID signature rather than SwiftPM's ad-hoc one.
if [ -n "$SIGN_KEYCHAIN" ]; then
codesign --force --options runtime --sign "$CERT" --keychain "$SIGN_KEYCHAIN" "$APP/Contents/MacOS/kapture-cli"
codesign --force --options runtime --sign "$CERT" --keychain "$SIGN_KEYCHAIN" "$APP"
else
codesign --force --sign "$CERT" "$APP/Contents/MacOS/kapture-cli"
codesign --force --sign "$CERT" "$APP"
fi