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"