Compare commits

...
17 Commits
Author SHA1 Message Date
TZGyn 1961d0dc9c Move draw controls into the draw toolbar
The color picker, undo/redo, and clear eraser now live in the dedicated
draw top bar instead of the main toolbar, keeping the primary actions
(Copy/Save/Get Text) uncluttered. Keyboard shortcuts (⌘Z/⌘⇧Z) remain.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:55:00 +08:00
TZGyn 41cb06dcb5 Add draw toolbar with thickness, undo/redo, and color picker
When drawing is enabled a dedicated top bar shows a thickness slider,
color picker, and undo/redo buttons (⌘Z / ⌘⇧Z). Strokes render and export
with the chosen width and color.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:39:06 +08:00
TZGyn 52e36b5c5a Add drawing color picker to the result window
A compact ColorPicker appears next to the Draw button when drawing is
enabled; strokes render and export in the chosen color (defaults to
system red).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:33:32 +08:00
TZGyn a74a86d001 Fix vertical offset in ScreenCaptureKit crops
The captured CGImage has a top-left origin, while the crop rect was
computed in SCDisplay space (bottom-left). Flip Y within the display's
bounds when converting to pixel coordinates so the crop lands exactly
where the user selected.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:27:41 +08:00
TZGyn f50057d951 Fix screenshot offset after pixel-exact crop change
The new CGImage.cropping path expects a top-left pixel rect; the previous
CGContext redraw had implicitly flipped Y. Keep the existing SCDisplay-space
conversion so the crop matches the selected region.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:23:37 +08:00
TZGyn 16b876807d Add freehand drawing and fix residual capture pixelation
The screenshot window now supports toggling a pen to draw annotations
that are flattened into copied and saved images. Captures are also
cropped with a pixel-exact CGImage.cropping instead of a CGContext
redraw, removing residual blur on retina displays.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:21:22 +08:00
TZGyn 6a2c8df180 Modernize the result window UI
Restyles the window with a cleaner layout: icon-based toolbar buttons
(Copy/Save/Get All Text, highlighted OCR Selection action), a bottom status
bar with progress indicator and contextual hints, monospaced text tab, and
a divider separating chrome from content.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-13 09:40:51 +08:00
TZGyn c9447d2a8b Make Kapture screenshot-first with marquee text selection
The result window now works like a screenshot tool: drag on the image to
select a region, then click Get Selection Text to OCR only that area
(full-image OCR stays as Get All Text). Selection is rendered in image
coordinates so it stays accurate at any window size.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 21:06:09 +08:00
TZGyn 3fdc2fca95 Fix vertical offset when capturing the secondary display
The first ScreenCaptureKit Y-flip used the wrong reference edges, shifting
cropped selections on displays below the main screen. Convert each selection
per-display as scY = appKitY - NSScreen.maxY + SCDisplay.maxY, which
matches both the main and secondary displays.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:51:18 +08:00
TZGyn 590be2356f Fix captures on displays below the main screen
ScreenCaptureKit uses a flipped Y axis relative to AppKit NSScreen frames,
so selections on a display below the main screen produced out-of-bounds
crop rects and "Capture failed". Convert the selection into SCDisplay space
using the matching NSScreen's top edge before cropping.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:47:48 +08:00
TZGyn af32a36a91 Add Copy Diagnostics menu item for multi-display debugging
Prints each NSScreen's frame/backing scale/CG display ID and each
SCDisplay's frame/point size, and copies it to the clipboard, so
secondary-display capture failures can be debugged from the menu bar.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:44:56 +08:00
TZGyn 1327b61e2b Request capture pixels from the selection screen's backing scale
SCDisplay.width is in points, not pixels: the previous fix sent point
counts to SCStreamConfiguration, producing blurry main-screen captures and
failed secondary-screen captures. Multiply by the backing scale factor of
the screen containing the selection (defaulting to 1x, never forcing 2x).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:37:53 +08:00
TZGyn c9a807e8a0 Fix secondary display captures after retina scaling change
The 1.1.2 retina fix overrode the stream width from the display's pixel
dimensions, which made captures on secondary monitors come out at the
wrong scale or offset. Restore requesting the display's native pixel
resolution and keep the crop scale derived from the actual returned image,
plus guard against out-of-bounds crop rects.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:34:18 +08:00
TZGyn 7bc93c2014 Anti-alias downscaled screenshots in the result window
Full-res retina captures are downscaled to fit the window; nearest-neighbor
made them look chunky. Use high-quality interpolation when shrinking, keep
pixel-crisp rendering only when upscaling small selections.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:25:23 +08:00
TZGyn 374af50acc Capture at full retina resolution
The ScreenCaptureKit stream was sized in points at 1x scale, so retina
displays produced half-resolution captures that appeared pixelated.
Request backingScale x pixels and derive the crop scale from the actual
captured image dimensions.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:14:39 +08:00
TZGyn 1a5771ec27 Render screenshot pixels crisply in the result window
The result image was upscaled with interpolated smoothing, making small
captures look low-res. Disable interpolation so pixels stay 1:1 sharp.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 16:07:42 +08:00
TZGyn 234a4226c5 Add notarization to release pipeline; sign with hardened runtime
release.sh now submits the zip to Apple's notary via notarytool, staples
the ticket, and repackages, so users no longer hit Gatekeeper warnings.
build-app.sh signs Developer ID builds with the hardened runtime and
unlocks the login keychain to avoid transient signing errors. The private
API key (.p8) is gitignored.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 11:31:14 +08:00
6 changed files with 545 additions and 78 deletions
+3
View File
@@ -5,6 +5,9 @@
# App bundle (build artifact, regenerate with ./build-app.sh) # App bundle (build artifact, regenerate with ./build-app.sh)
Kapture.app/ Kapture.app/
# Secrets
*.p8
# macOS # macOS
.DS_Store .DS_Store
+7
View File
@@ -48,6 +48,13 @@ struct MenuView: View {
Divider() Divider()
Button("Copy Diagnostics") {
let diag = CaptureController.shared.diagnostics()
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(diag, forType: .string)
}
Button(LoginItem.isEnabled ? "Disable Launch at Login" : "Launch at Login") { Button(LoginItem.isEnabled ? "Disable Launch at Login" : "Launch at Login") {
if !LoginItem.toggle() { if !LoginItem.toggle() {
let alert = NSAlert() let alert = NSAlert()
+75 -23
View File
@@ -10,6 +10,38 @@ final class CaptureController: NSObject {
private var previousApp: NSRunningApplication? private var previousApp: NSRunningApplication?
private var overlayIDs: [Int] = [] private var overlayIDs: [Int] = []
/// Diagnostic snapshot for "Copy Diagnostics" menu item.
func diagnostics() -> String {
var lines: [String] = []
lines.append("Kapture diagnostics")
for (i, s) in NSScreen.screens.enumerated() {
let scale = s.backingScaleFactor
let cgID = (s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
lines.append("screen[\(i)]: frame=\(s.frame) scale=\(scale) backingSize=\(s.frame.width*scale)x\(s.frame.height*scale) cgID=\(cgID)")
}
if #available(macOS 14.0, *) {
Task {
if let content = try? await SCShareableContent.current {
for (i, d) in content.displays.enumerated() {
lines.append("SCDisplay[\(i)]: frame=\(d.frame) width=\(d.width)x\(d.height) id=\(d.displayID)")
}
} else {
lines.append("SCShareableContent: unavailable (no Screen Recording permission?)")
}
let text = lines.joined(separator: "\n")
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(text, forType: .string)
}
} else {
let text = lines.joined(separator: "\n")
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(text, forType: .string)
}
return lines.joined(separator: "\n")
}
func begin() { func begin() {
guard !isSelecting else { return } guard !isSelecting else { return }
guard PermissionManager.isGranted else { guard PermissionManager.isGranted else {
@@ -85,30 +117,31 @@ final class ScreenCapture {
private init() {} private init() {}
@available(macOS 14.0, *) @available(macOS 14.0, *)
private func crop(_ image: CGImage, to rect: NSRect, display: SCDisplay) -> CGImage? { private func crop(_ image: CGImage, to cgRect: CGRect, display: SCDisplay) -> CGImage? {
let scaleX = CGFloat(display.width) / display.frame.width // cgRect is in SCDisplay space (bottom-left origin, Y flipped vs AppKit).
let scaleY = CGFloat(display.height) / display.frame.height // The captured image has a TOP-LEFT origin. So within the display,
let cgRect = CGRect( // we must flip Y: pixelY = displayHeight - (cgY - displayMinY) - cgHeight.
x: rect.minX, let scaleX = CGFloat(image.width) / display.frame.width
y: rect.minY, let scaleY = CGFloat(image.height) / display.frame.height
width: rect.width, guard scaleX > 0, scaleY > 0 else { return nil }
height: rect.height let localMinX = cgRect.minX - display.frame.minX
) let localMaxY = cgRect.maxY - display.frame.minY
let flippedY = display.frame.height - localMaxY
let pxRect = CGRect( let pxRect = CGRect(
x: (cgRect.minX - display.frame.minX) * scaleX, x: localMinX * scaleX,
y: (cgRect.minY - display.frame.minY) * scaleY, y: flippedY * scaleY,
width: cgRect.width * scaleX, width: cgRect.width * scaleX,
height: cgRect.height * scaleY height: cgRect.height * scaleY
) )
guard let cropCtx = CGContext( // Guard against negative offsets when the selection reaches beyond the
data: nil, width: Int(pxRect.width), height: Int(pxRect.height), // display's frame (e.g. secondary monitor coordinate quirks).
bitsPerComponent: 8, bytesPerRow: 0, let safeRect = pxRect.intersection(CGRect(x: 0, y: 0, width: CGFloat(image.width), height: CGFloat(image.height)))
space: CGColorSpaceCreateDeviceRGB(), guard safeRect.width >= 1, safeRect.height >= 1 else { return nil }
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue // Use a pixel-exact subimage crop instead of redrawing through a
) else { return nil } // CGContext, which resamples and loses quality on retina captures.
cropCtx.interpolationQuality = .high let rounded = safeRect.integral
cropCtx.draw(image, in: CGRect(x: -pxRect.minX, y: -pxRect.minY, width: CGFloat(image.width), height: CGFloat(image.height))) guard rounded.width >= 1, rounded.height >= 1 else { return nil }
return cropCtx.makeImage() return image.cropping(to: rounded)
} }
@available(macOS 14.0, *) @available(macOS 14.0, *)
@@ -133,15 +166,34 @@ final class ScreenCapture {
let excluded = content.windows.filter { excludedSet.contains($0.windowID) } let excluded = content.windows.filter { excludedSet.contains($0.windowID) }
let filter = SCContentFilter(display: display, excludingWindows: excluded) let filter = SCContentFilter(display: display, excludingWindows: excluded)
let config = SCStreamConfiguration() let config = SCStreamConfiguration()
config.width = Int(display.width) // SCDisplay.width/height are in POINTS; SCStreamConfiguration
config.height = Int(display.height) // width/height are output PIXELS. Multiply by the backing scale
// of the screen containing the selection so retina displays are
// captured at full resolution and 1x displays stay 1x.
guard let screen = NSScreen.screens.first(where: { $0.frame.contains(NSPoint(x: rect.midX, y: rect.midY)) }) else {
completion(nil)
return
}
let backingScale = screen.backingScaleFactor
config.width = Int(CGFloat(display.width) * backingScale)
config.height = Int(CGFloat(display.height) * backingScale)
config.showsCursor = true config.showsCursor = true
// ScreenCaptureKit uses a different origin than AppKit for
// screens stacked around the main display: per-display,
// SCDisplay y = appKitY - NSScreen.maxY + SCDisplay.maxY.
let scRect = CGRect(
x: rect.minX,
y: rect.minY - screen.frame.maxY + display.frame.maxY,
width: rect.width,
height: rect.height
)
guard let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) else { guard let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) else {
completion(nil) completion(nil)
return return
} }
completion(self.crop(image, to: rect, display: display)) completion(self.crop(image, to: scRect, display: display))
} }
} else { } else {
guard let primary = NSScreen.screens.first(where: { $0.frame.origin == .zero }) ?? NSScreen.screens.first else { guard let primary = NSScreen.screens.first(where: { $0.frame.origin == .zero }) ?? NSScreen.screens.first else {
+382 -48
View File
@@ -88,6 +88,18 @@ final class CaptureModel: ObservableObject {
@Published var extractedText: String? @Published var extractedText: String?
@Published var isOCRRunning = false @Published var isOCRRunning = false
@Published var statusMessage: String? @Published var statusMessage: String?
/// Drag selection rect in IMAGE point space (nil = none)
@Published var selection: CGRect?
/// Freehand drawing strokes in IMAGE point space
@Published var strokes: [[CGPoint]] = []
/// Redo stack of removed strokes
@Published var redoStack: [[CGPoint]] = []
/// Whether the pen is active (drag draws instead of selecting)
@Published var isDrawing = false
/// Current drawing color
@Published var drawColor: NSColor = .systemRed
/// Stroke thickness in points (image space)
@Published var strokeWidth: CGFloat = 3.0
enum CaptureMode { enum CaptureMode {
case screenshot case screenshot
@@ -98,24 +110,97 @@ final class CaptureModel: ObservableObject {
self.image = image self.image = image
} }
var hasSelection: Bool { selection != nil && selection!.width >= 2 && selection!.height >= 2 }
var canUndo: Bool { !strokes.isEmpty }
var canRedo: Bool { !redoStack.isEmpty }
func undoStroke() {
guard let last = strokes.popLast() else { return }
redoStack.append(last)
}
func redoStroke() {
guard let last = redoStack.popLast() else { return }
strokes.append(last)
}
func clearAnnotations() {
strokes = []
redoStack = []
}
/// Image with drawn strokes flattened in, for copy/save/export.
var annotatedImage: CGImage {
guard !strokes.isEmpty else { return image }
let size = NSSize(width: image.width, height: image.height)
guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
) else { return image }
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
let ctx = NSGraphicsContext.current!.cgContext
// CGImage draws with a flipped Y in NSGraphicsContext
ctx.draw(image, in: NSRect(origin: .zero, size: size))
drawColor.setStroke()
ctx.setLineWidth(strokeWidth)
ctx.setLineCap(.round)
ctx.setLineJoin(.round)
for stroke in strokes {
guard stroke.count > 1 else { continue }
ctx.beginPath()
ctx.move(to: stroke[0])
for point in stroke.dropFirst() {
ctx.addLine(to: point)
}
ctx.strokePath()
}
NSGraphicsContext.restoreGraphicsState()
return rep.cgImage ?? image
}
func addStroke(_ points: [CGPoint]) {
guard points.count > 1 else { return }
strokes.append(points)
redoStack = []
}
var displayText: String { var displayText: String {
if isOCRRunning { if isOCRRunning {
return "Recognizing text…" return "Recognizing text…"
} }
return extractedText ?? "Text appears here — click “Get Text”." return extractedText ?? "Select text in the screenshot, then click “Get Text”."
} }
func runOCR() { func cropImage(to rectInImage: CGRect) -> CGImage? {
return image.cropping(to: rectInImage)
}
func runOCR(over selectionOnly: CGRect? = nil) {
guard !isOCRRunning else { return } guard !isOCRRunning else { return }
isOCRRunning = true isOCRRunning = true
statusMessage = nil statusMessage = nil
OCRService.shared.recognize(image) { [weak self] text in
let targetImage: CGImage
if let sel = selectionOnly {
guard let cropped = image.cropping(to: sel) else {
self.isOCRRunning = false
self.statusMessage = "Selection too small."
return
}
targetImage = cropped
} else {
targetImage = image
}
OCRService.shared.recognize(targetImage) { [weak self] text in
Task { @MainActor in Task { @MainActor in
guard let self else { return } guard let self else { return }
self.isOCRRunning = false self.isOCRRunning = false
self.mode = .text self.mode = .text
if text.isEmpty { if text.isEmpty {
self.extractedText = "No text found in the screenshot." self.extractedText = "No text found in the selection."
} else { } else {
self.extractedText = text self.extractedText = text
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
@@ -127,6 +212,11 @@ final class CaptureModel: ObservableObject {
} }
} }
func runOCRSelection() {
guard let sel = selection, hasSelection else { return }
runOCR(over: sel)
}
func copyText() { func copyText() {
guard let text = extractedText else { return } guard let text = extractedText else { return }
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
@@ -138,7 +228,7 @@ final class CaptureModel: ObservableObject {
func copyImage() { func copyImage() {
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
pasteboard.clearContents() pasteboard.clearContents()
pasteboard.writeObjects([NSImage(cgImage: image, size: .zero)]) pasteboard.writeObjects([NSImage(cgImage: annotatedImage, size: .zero)])
statusMessage = "Image copied to clipboard." statusMessage = "Image copied to clipboard."
} }
@@ -151,7 +241,7 @@ final class CaptureModel: ObservableObject {
statusMessage = "Could not create PNG file." statusMessage = "Could not create PNG file."
return return
} }
CGImageDestinationAddImage(dest, image, nil) CGImageDestinationAddImage(dest, annotatedImage, nil)
if CGImageDestinationFinalize(dest) { if CGImageDestinationFinalize(dest) {
statusMessage = "Saved PNG." statusMessage = "Saved PNG."
} else { } else {
@@ -171,68 +261,312 @@ struct ResultView: View {
let onClose: () -> Void let onClose: () -> Void
var body: some View { var body: some View {
VStack(spacing: 10) { VStack(spacing: 0) {
HStack { // Toolbar
HStack(spacing: 10) {
Picker("", selection: $model.mode) { Picker("", selection: $model.mode) {
Text("Screenshot").tag(CaptureModel.CaptureMode.screenshot) Text("Screenshot").tag(CaptureModel.CaptureMode.screenshot)
Text("Text").tag(CaptureModel.CaptureMode.text) Text("Text").tag(CaptureModel.CaptureMode.text)
if model.isOCRRunning {
Text("…").tag(CaptureModel.CaptureMode.text)
}
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.fixedSize() .fixedSize()
Spacer()
Button("Copy Image") { model.copyImage() }
Button("Save PNG…") { model.savePNG() }
Button("Get Text") { model.runOCR() }
.disabled(model.isOCRRunning || model.extractedText != nil)
Button("Close") { onClose() }
}
.padding(.horizontal, 12)
.padding(.top, 10)
Spacer()
if model.hasSelection {
Button {
model.runOCRSelection()
} label: {
Label("OCR Selection", systemImage: "text.magnifyingglass")
}
.buttonStyle(.borderedProminent)
.disabled(model.isOCRRunning)
.help("Recognize text inside the selected region")
}
Button {
model.runOCR()
} label: {
Label("Get All Text", systemImage: "text.viewfinder")
}
.buttonStyle(.bordered)
.disabled(model.isOCRRunning)
.help("Recognize all text in the screenshot")
Divider().frame(height: 18)
Button {
model.isDrawing.toggle()
if model.isDrawing { model.selection = nil }
} label: {
Label("Draw", systemImage: model.isDrawing ? "pencil.tip.crop.circle.fill" : "pencil.tip.crop.circle")
}
.buttonStyle(.bordered)
.foregroundStyle(model.isDrawing ? .primary : .secondary)
.help("Toggle drawing on the screenshot")
Button {
model.copyImage()
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
.buttonStyle(.bordered)
.help("Copy image to clipboard")
Button {
model.savePNG()
} label: {
Label("Save", systemImage: "square.and.arrow.down")
}
.buttonStyle(.bordered)
.help("Save screenshot as PNG");
Button {
onClose()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Close")
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
Divider()
// Draw toolbar (only when drawing)
if model.isDrawing {
HStack(spacing: 14) {
Label("Draw", systemImage: "pencil.tip")
.font(.caption)
.foregroundStyle(.secondary)
Divider().frame(height: 18)
HStack(spacing: 6) {
Image(systemName: "circle.fill").font(.system(size: 4))
Image(systemName: "circle.fill").font(.system(size: 7))
Image(systemName: "circle.fill").font(.system(size: 10))
}
.foregroundStyle(.secondary)
Slider(value: $model.strokeWidth, in: 1...12, step: 0.5)
.frame(width: 120)
.help("Stroke thickness")
Divider().frame(height: 18)
ColorPicker("", selection: Binding(
get: { Color(nsColor: model.drawColor) },
set: { model.drawColor = NSColor($0) }
), supportsOpacity: false)
.labelsHidden()
.frame(width: 28)
.help("Drawing color")
Spacer()
Button {
model.undoStroke()
} label: {
Label("Undo", systemImage: "arrow.uturn.backward")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!model.canUndo)
.keyboardShortcut("z", modifiers: .command)
.help("Undo last stroke (⌘Z)")
Button {
model.redoStroke()
} label: {
Label("Redo", systemImage: "arrow.uturn.forward")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!model.canRedo)
.keyboardShortcut("z", modifiers: [.command, .shift])
.help("Redo stroke (⌘⇧Z)")
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.bar)
Divider()
}
// Content
if model.mode == .screenshot { if model.mode == .screenshot {
Image(nsImage: NSImage(cgImage: model.image, size: .zero)) ScreenshotPane(model: model)
.resizable() .padding(12)
.aspectRatio(contentMode: .fit)
.border(Color.secondary.opacity(0.4))
.padding(.horizontal, 12)
} else { } else {
ZStack(alignment: .bottomTrailing) { ZStack {
ScrollView { ScrollView {
Text(model.displayText) Text(model.displayText)
.font(.system(.body)) .font(.system(.body, design: .monospaced))
.textSelection(.enabled) .textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(8) .padding(12)
} }
.overlay( }
RoundedRectangle(cornerRadius: 6) .frame(maxWidth: .infinity, maxHeight: .infinity)
.stroke(Color.secondary.opacity(0.4)) }
)
HStack(spacing: 8) { // Status bar
if let msg = model.statusMessage { HStack(spacing: 8) {
Text(msg) if model.isOCRRunning {
.font(.caption) ProgressView()
.foregroundColor(.secondary) .controlSize(.small)
} }
Button("Copy Text") { model.copyText() } Text(model.statusMessage ?? (model.mode == .screenshot
? (model.hasSelection ? "Region selected — click OCR Selection." : "Drag to select a region, or Get All Text.")
: "Selectable text"))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
if model.mode == .text, model.extractedText != nil {
Button {
model.copyText()
} label: {
Label("Copy Text", systemImage: "doc.on.doc")
} }
.padding(8) .buttonStyle(.bordered)
.controlSize(.small)
} }
} }
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.bar)
}
.frame(minWidth: 520, minHeight: 420)
}
}
if model.statusMessage != nil && model.mode == .screenshot { struct ScreenshotPane: View {
Text(model.statusMessage!) @ObservedObject var model: CaptureModel
.font(.caption)
.foregroundColor(.secondary) private var imageSize: CGSize {
.frame(maxWidth: .infinity, alignment: .leading) CGSize(width: model.image.width, height: model.image.height)
.padding(.horizontal, 12) }
private func geometry(for geo: GeometryProxy) -> (scale: CGFloat, drawSize: CGSize, origin: CGPoint) {
let size = imageSize
let scale = min(geo.size.width / size.width, geo.size.height / size.height)
let drawSize = CGSize(width: size.width * scale, height: size.height * scale)
let origin = CGPoint(
x: (geo.size.width - drawSize.width) / 2,
y: (geo.size.height - drawSize.height) / 2
)
return (scale, drawSize, origin)
}
private func toImagePoint(_ viewPoint: CGPoint, scale: CGFloat, origin: CGPoint) -> CGPoint {
CGPoint(
x: (viewPoint.x - origin.x) / scale,
y: (viewPoint.y - origin.y) / scale
)
}
private func clampSelection(_ rect: CGRect) -> CGRect {
rect.intersection(CGRect(origin: .zero, size: imageSize))
}
@State private var currentStroke: [CGPoint] = []
var body: some View {
GeometryReader { geo in
let (scale, drawSize, origin) = geometry(for: geo)
ZStack {
Image(nsImage: NSImage(cgImage: model.image, size: .zero))
.resizable()
.interpolation(imageSize.width >= geo.size.width ? .high : .none)
.aspectRatio(contentMode: .fit)
.frame(width: drawSize.width, height: drawSize.height)
.position(x: origin.x + drawSize.width / 2, y: origin.y + drawSize.height / 2)
// Completed strokes
Canvas { context, _ in
var path = Path()
for stroke in model.strokes {
guard stroke.count > 1 else { continue }
path.move(to: CGPoint(x: origin.x + stroke[0].x * scale, y: origin.y + stroke[0].y * scale))
for point in stroke.dropFirst() {
path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale))
}
}
context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: model.strokeWidth)
}
.allowsHitTesting(false)
// In-progress stroke
if !currentStroke.isEmpty {
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))
for point in currentStroke.dropFirst() {
path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale))
}
}
context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: model.strokeWidth)
}
.allowsHitTesting(false)
}
if !model.isDrawing, let sel = model.selection {
let viewRect = CGRect(
x: origin.x + sel.minX * scale,
y: origin.y + sel.minY * scale,
width: sel.width * scale,
height: sel.height * scale
)
Rectangle()
.fill(Color.accentColor.opacity(0.15))
.border(Color.accentColor, width: 1.5)
.frame(width: viewRect.width, height: viewRect.height)
.position(x: viewRect.midX, y: viewRect.midY)
.allowsHitTesting(false)
}
} }
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
if model.isDrawing {
let p = toImagePoint(value.location, scale: scale, origin: origin)
let imgRect = CGRect(origin: .zero, size: imageSize)
if imgRect.contains(p) {
currentStroke.append(p)
}
} else {
let start = toImagePoint(value.startLocation, scale: scale, origin: origin)
let current = toImagePoint(value.location, scale: scale, origin: origin)
let rect = CGRect(
x: min(start.x, current.x),
y: min(start.y, current.y),
width: abs(current.x - start.x),
height: abs(current.y - start.y)
)
model.selection = clampSelection(rect)
}
}
.onEnded { _ in
if model.isDrawing {
if currentStroke.count > 1 {
model.addStroke(currentStroke)
}
currentStroke = []
} else {
if let s = model.selection, (s.width < 2 || s.height < 2) {
model.selection = nil
}
}
}
)
} }
.padding(.bottom, 10)
.frame(minWidth: 480, minHeight: 380)
} }
} }
+7 -5
View File
@@ -23,8 +23,10 @@ SIGN_KEYCHAIN=""
if [ -n "$DEV_IDENT" ]; then if [ -n "$DEV_IDENT" ]; then
CERT="$DEV_IDENT" CERT="$DEV_IDENT"
echo "Using Developer ID identity: $CERT" echo "Using Developer ID identity: $CERT"
# Sign from the login keychain where the Dev ID lives. # Ensure the login keychain holding the Dev ID is unlocked & accessible.
SIGN_KEYCHAIN="" security unlock-keychain "$HOME/Library/Keychains/login.keychain-db" 2>/dev/null || true
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "" "$HOME/Library/Keychains/login.keychain-db" >/dev/null 2>&1 || true
SIGN_KEYCHAIN="$HOME/Library/Keychains/login.keychain-db"
else else
CERT="kapture-dev" CERT="kapture-dev"
echo "Using self-signed identity 'kapture-dev'" echo "Using self-signed identity 'kapture-dev'"
@@ -106,9 +108,9 @@ cat > "$APP/Contents/Info.plist" <<'PLIST'
<key>CFBundleIconFile</key> <key>CFBundleIconFile</key>
<string>Icon</string> <string>Icon</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.1.0</string> <string>1.4.1</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>10</string> <string>41</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>
@@ -123,7 +125,7 @@ chmod +x scripts/make-icon.sh
./scripts/make-icon.sh ./scripts/make-icon.sh
if [ -n "$SIGN_KEYCHAIN" ]; then if [ -n "$SIGN_KEYCHAIN" ]; then
codesign --force --sign "$CERT" --keychain "$SIGN_KEYCHAIN" "$APP" codesign --force --options runtime --sign "$CERT" --keychain "$SIGN_KEYCHAIN" "$APP"
else else
codesign --force --sign "$CERT" "$APP" codesign --force --sign "$CERT" "$APP"
fi fi
+71 -2
View File
@@ -8,14 +8,83 @@ if [ "$#" -ne 1 ]; then
fi fi
VERSION="$1" VERSION="$1"
# Notarization credentials. Configure once, e.g. in ~/.zshrc:
# export KAPTURE_NOTARY_ASC_PROVIDER="<TeamID>"
# export KAPTURE_NOTARY_API_KEY_PATH="$HOME/.config/kapture/AuthKey_XXXX.p8"
# export KAPTURE_NOTARY_API_KEY_ID="<KeyID>"
# export KAPTURE_NOTARY_API_ISSUER="<IssuerID>"
# If these are unset (or KAPTURE_SKIP_NOTARY=1), release.sh builds, zips, and stops
# there — the zip is signed with the Developer ID identity but NOT notarized.
./build-app.sh ./build-app.sh
mkdir -p dist mkdir -p dist
rm -f dist/Kapture.zip rm -f dist/Kapture.zip
ditto -c -k --keepParent Kapture.app dist/Kapture.zip ditto -c -k --keepParent Kapture.app dist/Kapture.zip
shasum -a 256 dist/Kapture.zip
if [ -n "${KAPTURE_SKIP_NOTARY:-}" ]; then
echo
echo "Skipping notarization (KAPTURE_SKIP_NOTARY set)."
shasum -a 256 dist/Kapture.zip
echo
echo "Tag and release with:"
echo " git tag v$VERSION && git push origin v$VERSION"
echo "Attach dist/Kapture.zip to the GitHub release manually."
exit 0
fi
REQUIRED_VARS="KAPTURE_NOTARY_ASC_PROVIDER KAPTURE_NOTARY_API_KEY_PATH KAPTURE_NOTARY_API_KEY_ID KAPTURE_NOTARY_API_ISSUER"
MISSING=""
for v in $REQUIRED_VARS; do
if [ -z "${!v:-}" ]; then
MISSING="$MISSING $v"
fi
done
if [ -n "$MISSING" ]; then
echo "Notarization skipped — missing env var(s):$MISSING (see header comments)." >&2
shasum -a 256 dist/Kapture.zip
echo
echo "The zip above is signed but NOT notarized; Gatekeeper will warn users until it is."
echo "Set the KAPTURE_NOTARY_* variables and re-run ./release.sh $VERSION."
exit 0
fi
if ! command -v xcrun >/dev/null 2>&1 || ! xcrun --find notarytool >/dev/null 2>&1; then
echo "Notarization skipped — xcrun notarytool not found (requires Xcode 13+)." >&2
shasum -a 256 dist/Kapture.zip
exit 0
fi
echo "Submitting to Apple for notarization (first run can take minutes)..."
xcrun notarytool submit dist/Kapture.zip \
--key "$KAPTURE_NOTARY_API_KEY_PATH" \
--key-id "$KAPTURE_NOTARY_API_KEY_ID" \
--issuer "$KAPTURE_NOTARY_API_ISSUER" \
--wait \
--output-format json > notary-result.json
STATUS=$(jq -r '.status' notary-result.json 2>/dev/null || echo "unknown")
if [ "$STATUS" != "Accepted" ]; then
echo "Notarization failed with status: $STATUS" >&2
if [ -f notary-result.json ]; then
cat notary-result.json >&2
rm -f notary-result.json
fi
exit 1
fi
rm -f notary-result.json
echo "Notarization accepted — stapling ticket onto the app..."
xcrun stapler staple Kapture.app
xcrun stapler validate Kapture.app
rm -f dist/Kapture.zip
ditto -c -k --keepParent Kapture.app dist/Kapture.zip
echo
echo "Done — notarized."
shasum -a 256 dist/Kapture.zip
echo echo
echo "Tag and release with:" echo "Tag and release with:"
echo " git tag v$VERSION && git push origin v$VERSION" echo " git tag v$VERSION && git push origin v$VERSION"
echo "Attach dist/Kapture.zip to the GitHub release manually." echo "Attach dist/Kapture.zip to the GitHub release manually; the Homebrew cask is ready."