Compare commits

..
4 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
6 changed files with 928 additions and 59 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"
),
]
)
+1
View File
@@ -42,6 +42,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
HotkeyManager.shared.register()
CLIService.shared.start()
}
func applicationWillTerminate(_ notification: Notification) {
+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())
}
}
+440 -56
View File
@@ -6,6 +6,12 @@ final class ResultWindow {
static let shared = ResultWindow()
private var windows: [NSWindow] = []
/// Kept alive: the hosting controller drives the SwiftUI view we attach
/// directly as the window's contentView. Using contentViewController
/// would install Auto Layout constraints that force the window to the
/// view's fitting size — it shrank whenever the layout branch changed
/// (e.g. toggling Actual Size).
private var hosts: [ObjectIdentifier: NSHostingController<ResultView>] = [:]
private var nextOrigin = NSPoint(x: 60, y: 60)
private var openCount = 0
@@ -13,15 +19,21 @@ final class ResultWindow {
func show(image: CGImage) {
let model = CaptureModel(image: image)
let w = NSWindow()
w.contentViewController = NSHostingController(rootView: ResultView(model: model) {
let host = NSHostingController(rootView: ResultView(model: model) {
w.close()
})
w.contentView = host.view
hosts[ObjectIdentifier(w)] = host
w.title = "Kapture — Screenshot" + (openCount > 0 ? " \(openCount + 1)" : "")
w.level = .floating
w.styleMask = [.titled, .closable, .resizable]
w.setContentSize(NSSize(width: 694, height: 520))
w.isReleasedWhenClosed = false
w.setFrameOrigin(nextOrigin)
// Activate so the FIRST click on any control (Actual Size, Copy, the
// mode picker…) reaches it. Without this, an inactive LSUIElement
// app's windows swallow the initial click just to activate.
NSApp.activate(ignoringOtherApps: true)
w.makeKeyAndOrderFront(nil)
windows.append(w)
@@ -40,6 +52,7 @@ final class ResultWindow {
Task { @MainActor in
guard let self else { return }
self.windows.removeAll { $0 === window }
self.hosts[ObjectIdentifier(window)] = nil
self.openCount = max(0, self.openCount - 1)
}
}
@@ -90,12 +103,28 @@ final class CaptureModel: ObservableObject {
@Published var statusMessage: String?
/// Drag selection rect in IMAGE point space (nil = none)
@Published var selection: CGRect?
/// Crop rect while the crop tool is active, in the DISPLAYED image's
/// pixel space (the original image once a crop exists).
@Published var cropRect: CGRect?
/// The uncropped original image — set on the first crop so crop mode can
/// show it and the crop can be adjusted back outward (like Photos).
@Published private(set) var originalImage: CGImage?
/// Origin of the current image within the original image (pixels).
@Published private(set) var cropOffset: CGPoint = .zero
/// Freehand drawing strokes in IMAGE point space
@Published var strokes: [Stroke] = []
/// Redo stack of removed strokes
@Published var redoStack: [Stroke] = []
/// Undo stack of pre-crop states (image + annotations + offset)
@Published private(set) var cropHistory: [(image: CGImage, strokes: [Stroke], redoStack: [Stroke], offset: CGPoint)] = []
/// Whether the pen is active (drag draws instead of selecting)
@Published var tool: Tool = .select
/// Actual-size view: 1 image pixel per screen pixel, scrollable.
/// Persisted so the choice carries over to new windows and launches.
@Published var isActualSize: Bool = UserDefaults.standard.bool(forKey: CaptureModel.actualSizeKey) {
didSet { UserDefaults.standard.set(isActualSize, forKey: CaptureModel.actualSizeKey) }
}
private static let actualSizeKey = "actualSizeView"
/// Current drawing color
@Published var drawColor: NSColor = .systemRed
/// Stroke thickness in points (image space)
@@ -105,6 +134,7 @@ final class CaptureModel: ObservableObject {
case select
case draw
case redact
case crop
}
struct Stroke {
@@ -126,6 +156,53 @@ final class CaptureModel: ObservableObject {
var hasSelection: Bool { selection != nil && selection!.width >= 2 && selection!.height >= 2 }
var canUndo: Bool { !strokes.isEmpty }
var canRedo: Bool { !redoStack.isEmpty }
var canUndoCrop: Bool { !cropHistory.isEmpty }
/// The image shown in crop mode: the original once a crop exists, so the
/// handles sit on the cropped region and can be dragged back outward.
var cropModeImage: CGImage { originalImage ?? image }
/// Enter crop mode: start from the current selection or the current
/// crop region, mapped into the displayed image's space.
func beginCropMode() {
if hasSelection {
// The selection is in current-image space; map it into the
// displayed (original) space.
cropRect = selection!.offsetBy(dx: cropOffset.x, dy: cropOffset.y)
selection = nil
} else {
cropRect = originalImage != nil
? CGRect(origin: cropOffset, size: CGSize(width: image.width, height: image.height))
: CGRect(origin: .zero, size: CGSize(width: image.width, height: image.height))
}
}
func cancelCrop() {
cropRect = nil
if tool == .crop { tool = .select }
}
/// Apply the pending crop rect and leave crop mode.
func applyCrop() {
guard let rect = cropRect else { return }
cropRect = nil
tool = .select
crop(to: rect)
}
/// Restore the image and annotations from before the most recent crop.
func undoCrop() {
guard let snapshot = cropHistory.popLast() else { return }
image = snapshot.image
strokes = snapshot.strokes
redoStack = snapshot.redoStack
cropOffset = snapshot.offset
selection = nil
if tool == .crop {
cropRect = CGRect(origin: cropOffset, size: CGSize(width: image.width, height: image.height))
}
statusMessage = "Crop undone."
}
func undoStroke() {
guard let last = strokes.popLast() else { return }
@@ -215,18 +292,29 @@ final class CaptureModel: ObservableObject {
return extractedText ?? "Select text in the screenshot, then click “Get Text”."
}
/// Crop the image to `rectInImage` (image pixel space). Strokes are
/// translated into the new coordinate space; strokes fully outside the
/// crop are dropped. Selection is cleared.
func crop(to rectInImage: CGRect) {
let bounds = CGRect(origin: .zero, size: CGSize(width: image.width, height: image.height))
let rect = rectInImage.integral.intersection(bounds)
guard rect.width >= 2, rect.height >= 2, rect != bounds,
let cropped = image.cropping(to: rect) else { return }
/// Crop to `rect` in the DISPLAYED image's pixel space (the original
/// image once a crop exists). Strokes are translated into the new
/// coordinate space; strokes fully outside the crop are dropped. The
/// previous state is pushed onto the crop undo stack.
func crop(to rectInDisplayedSpace: CGRect) {
let source = originalImage ?? image
let bounds = CGRect(origin: .zero, size: CGSize(width: source.width, height: source.height))
let rect = rectInDisplayedSpace.integral.intersection(bounds)
// No-op when the crop matches what's already visible.
let currentVisible = CGRect(origin: cropOffset, size: CGSize(width: image.width, height: image.height))
guard rect.width >= 2, rect.height >= 2, rect != currentVisible,
let cropped = source.cropping(to: rect) else { return }
cropHistory.append((image: image, strokes: strokes, redoStack: redoStack, offset: cropOffset))
image = cropped
if originalImage == nil { originalImage = source }
// Strokes live in current-image space: a point p maps into the
// original as p + old offset, then into the new crop as -rect.origin.
let dx = cropOffset.x - rect.minX
let dy = cropOffset.y - rect.minY
cropOffset = rect.origin
let newBounds = CGRect(origin: .zero, size: rect.size)
func translated(_ stroke: Stroke) -> Stroke? {
let points = stroke.points.map { CGPoint(x: $0.x - rect.minX, y: $0.y - rect.minY) }
let points = stroke.points.map { CGPoint(x: $0.x + dx, y: $0.y + dy) }
let xs = points.map { $0.x }
let ys = points.map { $0.y }
let strokeBounds = CGRect(
@@ -242,11 +330,6 @@ final class CaptureModel: ObservableObject {
statusMessage = "Cropped to \(Int(rect.width))×\(Int(rect.height))."
}
func cropToSelection() {
guard let sel = selection, hasSelection else { return }
crop(to: sel)
}
func runOCR(over selectionOnly: CGRect? = nil) {
guard !isOCRRunning else { return }
isOCRRunning = true
@@ -409,6 +492,7 @@ private struct CapCapsuleButton: View {
var primary = false
var disabled = false
var help: String
var shortcut: KeyboardShortcut? = nil
let action: () -> Void
var body: some View {
@@ -422,6 +506,7 @@ private struct CapCapsuleButton: View {
.contentShape(Capsule())
}
.buttonStyle(CapPressableStyle())
.keyboardShortcut(shortcut)
.disabled(disabled)
.opacity(disabled ? 0.5 : 1)
.help(help)
@@ -457,8 +542,61 @@ private struct CapIconButton: View {
}
}
// MARK: - Result window
/// Reports the hosting window's backing scale factor. Used by the
/// actual-size view so one image pixel maps to exactly one device pixel.
/// SwiftUI's `displayScale` environment is unreliable on macOS and does not
/// follow the window when it moves between displays of different densities.
private struct WindowScaleReader: NSViewRepresentable {
let onChange: (CGFloat) -> Void
func makeNSView(context: Context) -> ScaleProbeView {
let view = ScaleProbeView()
view.onChange = onChange
return view
}
func updateNSView(_ nsView: ScaleProbeView, context: Context) {
nsView.onChange = onChange
}
final class ScaleProbeView: NSView {
var onChange: ((CGFloat) -> Void)?
private var observers: [NSObjectProtocol] = []
private var lastScale: CGFloat = 0
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
observers.forEach(NotificationCenter.default.removeObserver)
observers = []
report()
guard let window else { return }
for name in [NSWindow.didChangeBackingPropertiesNotification,
NSWindow.didChangeScreenNotification] {
observers.append(NotificationCenter.default.addObserver(
forName: name, object: window, queue: .main
) { [weak self] _ in
self?.report()
})
}
}
private func report() {
let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2
guard scale != lastScale else { return }
lastScale = scale
// Defer: this runs during layout, and the callback writes @State.
DispatchQueue.main.async { [weak self] in
self?.onChange?(scale)
}
}
deinit {
observers.forEach(NotificationCenter.default.removeObserver)
}
}
}
// MARK: - Result window
struct ResultView: View {
@ObservedObject var model: CaptureModel
let onClose: () -> Void
@@ -469,6 +607,20 @@ struct ResultView: View {
HStack(spacing: 6) {
CapModePicker(mode: $model.mode)
CapIconButton(
icon: "1.magnifyingglass",
active: model.isActualSize,
help: "Actual size — 1 image pixel per screen pixel (⌘0)",
shortcut: KeyboardShortcut("0", modifiers: .command)
) { model.isActualSize.toggle() }
if model.canUndoCrop {
CapIconButton(
icon: "arrow.uturn.backward",
help: "Undo the last crop"
) { model.undoCrop() }
}
Spacer()
if model.hasSelection {
@@ -477,11 +629,6 @@ struct ResultView: View {
primary: true, disabled: model.isOCRRunning,
help: "Recognize text inside the selected region"
) { model.runOCRSelection() }
CapCapsuleButton(
title: "Crop", icon: "crop",
help: "Crop the image to the selected region"
) { model.cropToSelection() }
}
CapCapsuleButton(
@@ -497,6 +644,7 @@ struct ResultView: View {
) {
model.tool = model.tool == .draw ? .select : .draw
if model.tool != .select { model.selection = nil }
model.cropRect = nil
}
CapIconButton(
@@ -506,6 +654,20 @@ struct ResultView: View {
) {
model.tool = model.tool == .redact ? .select : .redact
if model.tool != .select { model.selection = nil }
model.cropRect = nil
}
CapIconButton(
icon: "crop",
active: model.tool == .crop,
help: "Crop the image"
) {
if model.tool == .crop {
model.cancelCrop()
} else {
model.tool = .crop
model.beginCropMode()
}
}
CapIconButton(icon: "doc.on.doc", help: "Copy image to clipboard") { model.copyImage() }
@@ -518,9 +680,12 @@ struct ResultView: View {
// Tool options (only when a draw tool is active)
if model.tool != .select {
HStack(spacing: 14) {
Label(model.tool == .redact ? "Redact" : "Draw", systemImage: model.tool == .redact ? "rectangle.fill" : "pencil.tip")
.font(.caption)
.foregroundStyle(.secondary)
Label(
model.tool == .redact ? "Redact" : model.tool == .crop ? "Crop" : "Draw",
systemImage: model.tool == .redact ? "rectangle.fill" : model.tool == .crop ? "crop" : "pencil.tip"
)
.font(.caption)
.foregroundStyle(.secondary)
if model.tool == .draw {
HStack(spacing: 6) {
@@ -541,30 +706,49 @@ struct ResultView: View {
.labelsHidden()
.frame(width: 28)
.help("Drawing color")
} else {
} else if model.tool == .redact {
Label("Drag to cover sensitive text", systemImage: "eye.slash")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Label("Drag the corners or edges to adjust the crop", systemImage: "hand.draw")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
CapIconButton(
icon: "arrow.uturn.backward", disabled: !model.canUndo,
help: "Undo last stroke (⌘Z)",
shortcut: KeyboardShortcut("z", modifiers: .command)
) { model.undoStroke() }
if model.tool == .crop {
CapCapsuleButton(
title: "Cancel", icon: "xmark",
help: "Cancel cropping (Esc)",
shortcut: .cancelAction
) { model.cancelCrop() }
CapIconButton(
icon: "arrow.uturn.forward", disabled: !model.canRedo,
help: "Redo stroke (⌘⇧Z)",
shortcut: KeyboardShortcut("z", modifiers: [.command, .shift])
) { model.redoStroke() }
CapCapsuleButton(
title: "Crop Image", icon: "checkmark",
primary: true,
help: "Apply the crop (Return)",
shortcut: .defaultAction
) { model.applyCrop() }
} else {
CapIconButton(
icon: "arrow.uturn.backward", disabled: !model.canUndo,
help: "Undo last stroke (⌘Z)",
shortcut: KeyboardShortcut("z", modifiers: .command)
) { model.undoStroke() }
CapIconButton(
icon: "eraser", disabled: model.strokes.isEmpty,
help: "Remove all drawn annotations"
) { model.clearAnnotations() }
CapIconButton(
icon: "arrow.uturn.forward", disabled: !model.canRedo,
help: "Redo stroke (⌘⇧Z)",
shortcut: KeyboardShortcut("z", modifiers: [.command, .shift])
) { model.redoStroke() }
CapIconButton(
icon: "eraser", disabled: model.strokes.isEmpty,
help: "Remove all drawn annotations"
) { model.clearAnnotations() }
}
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
@@ -596,7 +780,11 @@ struct ResultView: View {
.controlSize(.small)
}
Text(model.statusMessage ?? (model.mode == .screenshot
? (model.hasSelection ? "Region selected — OCR it or Crop." : "Drag to select a region, or Get All Text.")
? (model.tool == .crop
? "Adjust the crop — Return to apply, Esc to cancel."
: (model.hasSelection
? "Region selected — OCR it, or adjust it with Crop."
: (model.isActualSize ? "Actual size — 1:1 pixels, scroll to pan." : "Drag to select a region, or Get All Text.")))
: "Selectable text"))
.font(.caption)
.foregroundStyle(.secondary)
@@ -622,8 +810,20 @@ struct ResultView: View {
struct ScreenshotPane: View {
@ObservedObject var model: CaptureModel
/// In crop mode after a crop: the original image is displayed so the
/// crop handles sit on the cropped region and can be dragged back out.
private var displayedImage: CGImage {
model.tool == .crop ? model.cropModeImage : model.image
}
/// Strokes are stored in current-image space; when the original is
/// displayed in crop mode they shift by the cumulative crop offset.
private var strokeDisplayOffset: CGPoint {
model.tool == .crop ? model.cropOffset : .zero
}
private var imageSize: CGSize {
CGSize(width: model.image.width, height: model.image.height)
CGSize(width: displayedImage.width, height: displayedImage.height)
}
private func geometry(for geo: GeometryProxy) -> (scale: CGFloat, drawSize: CGSize, origin: CGPoint) {
@@ -649,14 +849,145 @@ struct ScreenshotPane: View {
}
@State private var currentStroke: [CGPoint] = []
@State private var cropDrag: CropDrag?
@State private var cropStartRect = CGRect.zero
/// Which part of the crop rect a drag is manipulating.
private enum CropDrag {
case move, n, s, e, w, ne, nw, se, sw
}
/// Hit-test the drag start against the crop handles (corners, edge
/// midpoints), then the interior (move). Nil = outside the crop rect.
private func cropHandle(at viewPoint: CGPoint, cropView: CGRect) -> CropDrag? {
let points: [(CropDrag, CGPoint)] = [
(.nw, CGPoint(x: cropView.minX, y: cropView.minY)),
(.n, CGPoint(x: cropView.midX, y: cropView.minY)),
(.ne, CGPoint(x: cropView.maxX, y: cropView.minY)),
(.e, CGPoint(x: cropView.maxX, y: cropView.midY)),
(.se, CGPoint(x: cropView.maxX, y: cropView.maxY)),
(.s, CGPoint(x: cropView.midX, y: cropView.maxY)),
(.sw, CGPoint(x: cropView.minX, y: cropView.maxY)),
(.w, CGPoint(x: cropView.minX, y: cropView.midY)),
]
for (drag, point) in points where hypot(point.x - viewPoint.x, point.y - viewPoint.y) <= 16 {
return drag
}
return cropView.contains(viewPoint) ? .move : nil
}
/// Compute the crop rect for a drag, clamped to the image and with a
/// minimum size. The dragged edge/corner follows the pointer; the
/// opposite side stays put.
private static func resizedCropRect(from start: CGRect, drag: CropDrag, delta: CGSize, bounds: CGRect) -> CGRect {
let minSize: CGFloat = 10
switch drag {
case .move:
var rect = start.offsetBy(dx: delta.width, dy: delta.height)
rect.origin.x = min(max(rect.origin.x, bounds.minX), bounds.maxX - rect.width)
rect.origin.y = min(max(rect.origin.y, bounds.minY), bounds.maxY - rect.height)
return rect
case .n, .s, .e, .w:
var minX = start.minX, maxX = start.maxX
var minY = start.minY, maxY = start.maxY
if drag == .w { minX = min(max(start.minX + delta.width, bounds.minX), start.maxX - minSize) }
if drag == .e { maxX = max(min(start.maxX + delta.width, bounds.maxX), start.minX + minSize) }
if drag == .n { minY = min(max(start.minY + delta.height, bounds.minY), start.maxY - minSize) }
if drag == .s { maxY = max(min(start.maxY + delta.height, bounds.maxY), start.minY + minSize) }
return CGRect(x: minX, y: minY, width: maxX - minX, height: maxY - minY)
case .nw, .ne, .sw, .se:
// Corner drag: the opposite corner is the anchor; normalize so
// the anchor stays fixed whichever way the pointer goes.
let anchor = CGPoint(
x: (drag == .nw || drag == .sw) ? start.maxX : start.minX,
y: (drag == .nw || drag == .ne) ? start.maxY : start.minY
)
let moved = CGPoint(
x: min(max(((drag == .nw || drag == .sw) ? start.minX : start.maxX) + delta.width, bounds.minX), bounds.maxX),
y: min(max(((drag == .nw || drag == .ne) ? start.minY : start.maxY) + delta.height, bounds.minY), bounds.maxY)
)
let rect = CGRect(
x: min(anchor.x, moved.x), y: min(anchor.y, moved.y),
width: abs(moved.x - anchor.x), height: abs(moved.y - anchor.y)
)
return (rect.width >= minSize && rect.height >= minSize) ? rect : start
}
}
private func updateCropDrag(_ value: DragGesture.Value, scale: CGFloat, origin: CGPoint) {
guard let current = model.cropRect else { return }
let bounds = CGRect(origin: .zero, size: imageSize)
if cropDrag == nil {
let cropView = CGRect(
x: origin.x + current.minX * scale, y: origin.y + current.minY * scale,
width: current.width * scale, height: current.height * scale
)
if let handle = cropHandle(at: value.startLocation, cropView: cropView) {
cropDrag = handle
cropStartRect = current
} else {
// Started outside the crop rect: draw a fresh one anchored
// where the drag began (handled by the .se anchor math).
let anchor = toImagePoint(value.startLocation, scale: scale, origin: origin)
guard bounds.contains(anchor) else { return }
cropDrag = .se
cropStartRect = CGRect(origin: anchor, size: .zero)
}
}
guard let drag = cropDrag else { return }
let delta = CGSize(width: value.translation.width / scale, height: value.translation.height / scale)
let newRect = Self.resizedCropRect(from: cropStartRect, drag: drag, delta: delta, bounds: bounds)
guard newRect.width >= 1, newRect.height >= 1 else { return }
model.cropRect = newRect
}
@State private var windowScale: CGFloat = 2
/// Image pixels to window points. Actual size is 1 image pixel per
/// device pixel, i.e. 1/windowScale points.
private var actualSizeScale: CGFloat {
1 / max(windowScale, 0.01)
}
var body: some View {
GeometryReader { geo in
let (scale, drawSize, origin) = geometry(for: geo)
let cardRect = CGRect(origin: origin, size: drawSize)
let cardClip = RoundedRectangle(cornerRadius: 10).path(in: cardRect)
if model.isActualSize {
// Actual size: 1 image pixel per screen pixel (view scale =
// 1/windowScale). Scrollable when larger than the viewport;
// centred when smaller.
let scale = actualSizeScale
let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
let origin = CGPoint(
x: max(0, (geo.size.width - drawSize.width) / 2),
y: max(0, (geo.size.height - drawSize.height) / 2)
)
ScrollView([.horizontal, .vertical]) {
imageContent(scale: scale, drawSize: drawSize, origin: origin)
.frame(
width: max(drawSize.width, geo.size.width),
height: max(drawSize.height, geo.size.height)
)
}
} else {
let (scale, drawSize, origin) = geometry(for: geo)
imageContent(scale: scale, drawSize: drawSize, origin: origin)
.frame(width: geo.size.width, height: geo.size.height)
}
}
.background(WindowScaleReader { scale in
windowScale = scale
})
}
ZStack {
/// The image card with annotations, selection and crop overlays, plus
/// the drag gesture. Parameterized by scale/origin so it works both
/// fit-to-window and at actual size inside the ScrollView.
private func imageContent(scale: CGFloat, drawSize: CGSize, origin: CGPoint) -> some View {
let cardRect = CGRect(origin: origin, size: drawSize)
let cardClip = RoundedRectangle(cornerRadius: 10).path(in: cardRect)
let strokeOffset = strokeDisplayOffset
return ZStack {
// Card behind the image
RoundedRectangle(cornerRadius: 10)
.fill(CapStyle.card)
@@ -664,9 +995,9 @@ struct ScreenshotPane: View {
.frame(width: drawSize.width, height: drawSize.height)
.position(x: cardRect.midX, y: cardRect.midY)
Image(nsImage: NSImage(cgImage: model.image, size: .zero))
Image(nsImage: NSImage(cgImage: displayedImage, size: .zero))
.resizable()
.interpolation(imageSize.width >= geo.size.width ? .high : .none)
.interpolation(scale < 1 ? .high : .none)
.aspectRatio(contentMode: .fit)
.frame(width: drawSize.width, height: drawSize.height)
.clipShape(RoundedRectangle(cornerRadius: 10))
@@ -680,17 +1011,17 @@ struct ScreenshotPane: View {
let xs = stroke.points.map { $0.x }
let ys = stroke.points.map { $0.y }
let bar = CGRect(
x: origin.x + (xs.min()! - 2) * scale,
y: origin.y + (ys.min()! - 2) * scale,
x: origin.x + (xs.min()! + strokeOffset.x - 2) * scale,
y: origin.y + (ys.min()! + strokeOffset.y - 2) * scale,
width: ((xs.max()! - xs.min()!) + 4) * scale,
height: (max(stroke.width, ys.max()! - ys.min()!) + 4) * scale
)
context.fill(Path(bar), with: .color(.black))
} else {
var path = Path()
path.move(to: CGPoint(x: origin.x + stroke.points[0].x * scale, y: origin.y + stroke.points[0].y * scale))
path.move(to: CGPoint(x: origin.x + (stroke.points[0].x + strokeOffset.x) * scale, y: origin.y + (stroke.points[0].y + strokeOffset.y) * scale))
for point in stroke.points.dropFirst() {
path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale))
path.addLine(to: CGPoint(x: origin.x + (point.x + strokeOffset.x) * scale, y: origin.y + (point.y + strokeOffset.y) * scale))
}
// stroke.width is in image pixels; scale to view points
context.stroke(path, with: .color(Color(nsColor: stroke.color)), lineWidth: stroke.width * scale)
@@ -705,9 +1036,9 @@ struct ScreenshotPane: View {
Canvas { context, _ in
var path = Path()
if currentStroke.count > 1 {
path.move(to: CGPoint(x: origin.x + currentStroke[0].x * scale, y: origin.y + currentStroke[0].y * scale))
path.move(to: CGPoint(x: origin.x + (currentStroke[0].x + strokeOffset.x) * scale, y: origin.y + (currentStroke[0].y + strokeOffset.y) * scale))
for point in currentStroke.dropFirst() {
path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale))
path.addLine(to: CGPoint(x: origin.x + (point.x + strokeOffset.x) * scale, y: origin.y + (point.y + strokeOffset.y) * scale))
}
}
context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: model.strokeWidth)
@@ -723,6 +1054,56 @@ struct ScreenshotPane: View {
.position(x: cardRect.midX, y: cardRect.midY)
.allowsHitTesting(false)
if model.tool == .crop, let crop = model.cropRect {
let cropView = CGRect(
x: origin.x + crop.minX * scale,
y: origin.y + crop.minY * scale,
width: crop.width * scale,
height: crop.height * scale
)
Canvas { context, size in
// Dim everything outside the crop rect.
var dim = Path()
dim.addRect(CGRect(origin: .zero, size: size))
dim.addRect(cropView)
context.fill(dim, with: .color(.black.opacity(0.45)), style: FillStyle(eoFill: true))
// Rule-of-thirds grid.
var grid = Path()
for i in 1..<3 {
let x = cropView.minX + cropView.width * CGFloat(i) / 3
grid.move(to: CGPoint(x: x, y: cropView.minY))
grid.addLine(to: CGPoint(x: x, y: cropView.maxY))
let y = cropView.minY + cropView.height * CGFloat(i) / 3
grid.move(to: CGPoint(x: cropView.minX, y: y))
grid.addLine(to: CGPoint(x: cropView.maxX, y: y))
}
context.stroke(grid, with: .color(.white.opacity(0.35)), lineWidth: 1)
// Border.
context.stroke(Path(cropView), with: .color(.white), lineWidth: 1.5)
// Handles: white dots at corners and edge midpoints.
let handles: [CGPoint] = [
CGPoint(x: cropView.minX, y: cropView.minY),
CGPoint(x: cropView.midX, y: cropView.minY),
CGPoint(x: cropView.maxX, y: cropView.minY),
CGPoint(x: cropView.maxX, y: cropView.midY),
CGPoint(x: cropView.maxX, y: cropView.maxY),
CGPoint(x: cropView.midX, y: cropView.maxY),
CGPoint(x: cropView.minX, y: cropView.maxY),
CGPoint(x: cropView.minX, y: cropView.midY),
]
for point in handles {
let dot = Path(ellipseIn: CGRect(x: point.x - 4, y: point.y - 4, width: 8, height: 8))
context.fill(dot, with: .color(.white))
context.stroke(dot, with: .color(.black.opacity(0.4)), lineWidth: 1)
}
}
.clipShape(cardClip)
.allowsHitTesting(false)
}
if model.tool == .select, let sel = model.selection {
let viewRect = CGRect(
x: origin.x + sel.minX * scale,
@@ -742,7 +1123,9 @@ struct ScreenshotPane: View {
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
if model.tool != .select {
if model.tool == .crop {
updateCropDrag(value, scale: scale, origin: origin)
} else if model.tool != .select {
let p = toImagePoint(value.location, scale: scale, origin: origin)
let imgRect = CGRect(origin: .zero, size: imageSize)
if imgRect.contains(p) {
@@ -761,7 +1144,9 @@ struct ScreenshotPane: View {
}
}
.onEnded { _ in
if model.tool != .select {
if model.tool == .crop {
cropDrag = nil
} else if model.tool != .select {
if currentStroke.count > 1 {
model.addStroke(currentStroke, widthPx: model.strokeWidth / max(scale, 0.01))
}
@@ -773,6 +1158,5 @@ struct ScreenshotPane: View {
}
}
)
}
}
}
+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.11.0</string>
<string>1.12.0</string>
<key>CFBundleVersion</key>
<string>110</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