diff --git a/Sources/Kapture/CaptureController.swift b/Sources/Kapture/CaptureController.swift
index 3980b71..0dd2182 100644
--- a/Sources/Kapture/CaptureController.swift
+++ b/Sources/Kapture/CaptureController.swift
@@ -1,5 +1,6 @@
import AppKit
import CoreGraphics
+import ScreenCaptureKit
final class CaptureController: NSObject {
static let shared = CaptureController()
@@ -7,6 +8,7 @@ final class CaptureController: NSObject {
private var overlays: [NSWindow] = []
private(set) var isSelecting = false
private var previousApp: NSRunningApplication?
+ private var overlayIDs: [Int] = []
func begin() {
guard !isSelecting else { return }
@@ -29,16 +31,18 @@ final class CaptureController: NSObject {
)
}
overlays.forEach { $0.makeKeyAndOrderFront(nil) }
+ overlayIDs = overlays.map { $0.windowNumber }
}
private func finishSelection(_ rect: NSRect) {
+ let ids = overlayIDs
hideOverlays()
guard rect.width >= 2, rect.height >= 2 else {
cancelSelection()
return
}
DispatchQueue.main.async { [self] in
- capture(rect)
+ capture(rect, excludingWindowIDs: ids)
}
}
@@ -58,40 +62,99 @@ final class CaptureController: NSObject {
previousApp = nil
}
- private func capture(_ rect: NSRect) {
- guard let image = ScreenCapture.capture(rect) else {
- isSelecting = false
- restorePreviousApp()
- Task { @MainActor in
- ResultWindow.shared.show(image: ResultWindow.failureImage("Capture failed. Check Screen Recording permission."))
- }
- return
- }
- DispatchQueue.main.async { [weak self] in
- self?.isSelecting = false
- self?.restorePreviousApp()
- Task { @MainActor in
- ResultWindow.shared.show(image: image)
+ private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int]) {
+ ScreenCapture.shared.capture(rect, excludingWindowIDs: ids) { [weak self] image in
+ DispatchQueue.main.async {
+ self?.isSelecting = false
+ self?.restorePreviousApp()
+ Task { @MainActor in
+ guard let image else {
+ ResultWindow.shared.show(image: ResultWindow.failureImage("Capture failed. Check Screen Recording permission."))
+ return
+ }
+ ResultWindow.shared.show(image: image)
+ }
}
}
}
}
-enum ScreenCapture {
- static func capture(_ rect: NSRect) -> CGImage? {
- guard let primary = NSScreen.screens.first(where: { $0.frame.origin == .zero })
- ?? NSScreen.screens.first else { return nil }
+final class ScreenCapture {
+ static let shared = ScreenCapture()
+
+ private init() {}
+
+ @available(macOS 14.0, *)
+ private func crop(_ image: CGImage, to rect: NSRect, display: SCDisplay) -> CGImage? {
+ let scaleX = CGFloat(display.width) / display.frame.width
+ let scaleY = CGFloat(display.height) / display.frame.height
let cgRect = CGRect(
x: rect.minX,
- y: primary.frame.height - rect.maxY,
+ y: rect.minY,
width: rect.width,
height: rect.height
)
- return CGWindowListCreateImage(
- cgRect,
- .optionOnScreenOnly,
- kCGNullWindowID,
- [.bestResolution, .boundsIgnoreFraming]
+ let pxRect = CGRect(
+ x: (cgRect.minX - display.frame.minX) * scaleX,
+ y: (cgRect.minY - display.frame.minY) * scaleY,
+ width: cgRect.width * scaleX,
+ height: cgRect.height * scaleY
)
+ guard let cropCtx = CGContext(
+ data: nil, width: Int(pxRect.width), height: Int(pxRect.height),
+ bitsPerComponent: 8, bytesPerRow: 0,
+ space: CGColorSpaceCreateDeviceRGB(),
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else { return nil }
+ cropCtx.interpolationQuality = .high
+ cropCtx.draw(image, in: CGRect(x: -pxRect.minX, y: -pxRect.minY, width: CGFloat(image.width), height: CGFloat(image.height)))
+ return cropCtx.makeImage()
+ }
+
+ @available(macOS 14.0, *)
+ private func displayContaining(_ rect: NSRect, in content: SCShareableContent) -> SCDisplay? {
+ let mid = NSPoint(x: rect.midX, y: rect.midY)
+ guard let screen = NSScreen.screens.first(where: { $0.frame.contains(mid) }) ?? NSScreen.screens.first else {
+ return nil
+ }
+ let cgID = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
+ return content.displays.first(where: { $0.displayID == cgID }) ?? content.displays.first
+ }
+
+ func capture(_ rect: NSRect, excludingWindowIDs excludedIDs: [Int], completion: @escaping (CGImage?) -> Void) {
+ if #available(macOS 14.0, *) {
+ Task {
+ guard let content = try? await SCShareableContent.current,
+ let display = displayContaining(rect, in: content) else {
+ completion(nil)
+ return
+ }
+ let excludedSet = Set(excludedIDs.map { CGWindowID($0) })
+ let excluded = content.windows.filter { excludedSet.contains($0.windowID) }
+ let filter = SCContentFilter(display: display, excludingWindows: excluded)
+ let config = SCStreamConfiguration()
+ config.width = Int(display.width)
+ config.height = Int(display.height)
+ config.showsCursor = true
+
+ guard let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) else {
+ completion(nil)
+ return
+ }
+ completion(self.crop(image, to: rect, display: display))
+ }
+ } else {
+ guard let primary = NSScreen.screens.first(where: { $0.frame.origin == .zero }) ?? NSScreen.screens.first else {
+ completion(nil)
+ return
+ }
+ let cgRect = CGRect(
+ x: rect.minX,
+ y: primary.frame.height - rect.maxY,
+ width: rect.width,
+ height: rect.height
+ )
+ completion(CGWindowListCreateImage(cgRect, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution, .boundsIgnoreFraming]))
+ }
}
}
diff --git a/Sources/Kapture/ResultWindow.swift b/Sources/Kapture/ResultWindow.swift
index c19ccda..e866ae0 100644
--- a/Sources/Kapture/ResultWindow.swift
+++ b/Sources/Kapture/ResultWindow.swift
@@ -158,7 +158,7 @@ struct ResultView: View {
Spacer()
Button("Save PNG…") { model.savePNG() }
Button("Get Text") { model.runOCR() }
- .disabled(model.isOCRRunning || model.extractedText != nil || model.mode == .text)
+ .disabled(model.isOCRRunning || model.extractedText != nil)
Button("Close") { onClose() }
}
.padding(.horizontal, 12)
diff --git a/build-app.sh b/build-app.sh
index 00665ec..e84f7d4 100755
--- a/build-app.sh
+++ b/build-app.sh
@@ -55,6 +55,8 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
Kapture
CFBundlePackageType
APPL
+ CFBundleIconFile
+ Icon
CFBundleShortVersionString
1.0
CFBundleVersion
@@ -71,4 +73,9 @@ PLIST
codesign --force --sign "$CERT" "$APP"
+chmod +x scripts/make-icon.sh
+./scripts/make-icon.sh
+
+codesign --force --sign "$CERT" "$APP"
+
echo "Built $APP (signed with stable identity '$CERT') — drag to /Applications or run: open $APP"
diff --git a/scripts/gen-icon.swift b/scripts/gen-icon.swift
new file mode 100644
index 0000000..b2b0de3
--- /dev/null
+++ b/scripts/gen-icon.swift
@@ -0,0 +1,50 @@
+import AppKit
+
+let size = NSSize(width: 1024, height: 1024)
+guard let rep = NSBitmapImageRep(
+ bitmapDataPlanes: nil, pixelsWide: 1024, pixelsHigh: 1024,
+ bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
+ colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
+) else { exit(1) }
+
+NSGraphicsContext.saveGraphicsState()
+NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
+NSGraphicsContext.current?.imageInterpolation = .high
+
+let gradient = NSGradient(colors: [
+ NSColor(calibratedRed: 0.20, green: 0.42, blue: 0.95, alpha: 1),
+ NSColor(calibratedRed: 0.10, green: 0.20, blue: 0.60, alpha: 1),
+])!
+gradient.draw(in: NSRect(origin: .zero, size: size), angle: -45)
+
+let canvas = NSRect(origin: .zero, size: size)
+
+NSColor.white.withAlphaComponent(0.92).setStroke()
+let border = NSBezierPath(roundedRect: canvas.insetBy(dx: 64, dy: 64), xRadius: 64, yRadius: 64)
+border.lineWidth = 28
+border.stroke()
+
+let glyphColor = NSColor.white
+let attrs: [NSAttributedString.Key: Any] = [
+ .font: NSFont.systemFont(ofSize: 520, weight: .semibold),
+ .foregroundColor: glyphColor,
+]
+let glyph = "Aa"
+let glyphSize = glyph.size(withAttributes: attrs)
+glyph.draw(
+ at: NSPoint(
+ x: (1024 - glyphSize.width) / 2,
+ y: (1024 - glyphSize.height) / 2 - 40
+ ),
+ withAttributes: attrs
+)
+
+NSGraphicsContext.restoreGraphicsState()
+
+guard let data = rep.representation(using: .png, properties: [:]) else { exit(1) }
+if CommandLine.arguments.count < 2 { exit(1) }
+do {
+ try data.write(to: URL(fileURLWithPath: CommandLine.arguments[1]))
+} catch {
+ exit(1)
+}
diff --git a/scripts/make-icon.sh b/scripts/make-icon.sh
new file mode 100755
index 0000000..307b515
--- /dev/null
+++ b/scripts/make-icon.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+set -euo pipefail
+
+cd "$(dirname "$0")/.."
+
+TMP=$(mktemp -d)
+trap 'rm -rf "$TMP"' EXIT
+
+swift scripts/gen-icon.swift "$TMP/icon-1024.png"
+
+mkdir -p "$TMP/Icon.iconset"
+for s in 16 32 128 256 512; do
+ d=$((s * 2))
+ sips -z "$s" "$s" "$TMP/icon-1024.png" --out "$TMP/Icon.iconset/icon_${s}x${s}.png" >/dev/null
+ sips -z "$d" "$d" "$TMP/icon-1024.png" --out "$TMP/Icon.iconset/icon_${s}x${s}@2x.png" >/dev/null
+done
+
+iconutil -c icns "$TMP/Icon.iconset" -o "$TMP/Icon.icns"
+mkdir -p Kapture.app/Contents/Resources
+cp "$TMP/Icon.icns" Kapture.app/Contents/Resources/Icon.icns
+echo "Generated Kapture.app/Contents/Resources/Icon.icns"