Compare commits

..
13 Commits
Author SHA1 Message Date
TZGyn 195c6a9b1f Enable automatic language detection so CJK text is recognized
Vision defaulted to the first listed language and turned Chinese into
Latin garbage (你好世界 became 177WR). automaticallyDetectsLanguage fixes
it; the smoke test now covers Chinese so it cannot regress.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-22 10:54:54 +08:00
TZGyn ecac7885dd Keep the captured app focused during selection
The overlay used to activate Kapture, pushing the target app into
background mode so its chrome (title bar, toolbar, tabs) was captured
dimmed. Input no longer needs activation — the event tap handles it — so
the overlay is a non-activating panel shown without taking focus, and the
app keeps its active appearance in the capture.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-20 14:46:25 +08:00
TZGyn ef44ef7ac6 Let system dialogs take clicks during capture; hint Esc on the overlay
The event tap consumes every click while selecting, which made permission
prompts unclickable when they appeared mid-capture. Clicks now pass
through when a system dialog (SecurityAgent / System Settings) is
frontmost, and the overlay shows a visible Esc hint as an escape hatch.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-12 22:12:48 +08:00
TZGyn 715bb28c1b Fix supersample blur and click-cancellation on micro-drags
The 3x supersample resized the whole capture (blurring content); only the
corner mask is supersampled now, applied through clip(to😷) with the
image drawn once at native scale. The tap's drag branch also clears the
hovered window on sub-4px jitter, cancelling click-to-capture; the
threshold is applied there too.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 15:26:19 +08:00
TZGyn 07642ca408 Auto-detect window corner radius; anti-alias the mask
Measure the picked window's real corner radius from the capture itself
(the crop's corner pixels are the desktop background; app content starts
where the color departs), correct for arc anti-aliasing, and fall back to
10pt when ambiguous. The clip mask is supersampled 3x for smooth edges.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 15:20:21 +08:00
TZGyn 924a692f2b Round window-capture corners with a clip instead of masking()
The masking() call could silently fail against ScreenCaptureKit's pixel
format, leaving sharp corners. Clip to a rounded path and redraw instead,
verified headlessly for corner clipping and orientation.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 15:15:21 +08:00
TZGyn ae054d70b9 Treat any same-app window as popup risk for window capture
Owned popups composite their parent even when the windows do not
overlap, so the intersection test missed DBeaver-style floating dialogs.
Any other window from the same app now routes the pick to the screen
crop.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 15:09:09 +08:00
TZGyn 3078713f8f Use screen crop for owned popup windows
desktopIndependentWindow composites owned popups together with their
parent window and scales the group to the popup's size. Detect the case
at pick time — another window from the same app overlapping the picked
one — and capture the screen crop with a rounded mask instead, which
contains only the popup.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 15:05:34 +08:00
TZGyn 265571d7e2 Reject window captures that composite extra windows
ScreenCaptureKit's desktopIndependentWindow filter can include owned
child windows (e.g. an app dialog plus its parent), producing a capture
much larger than the picked window. Detect the overflow by comparing the
returned image size against the window's frame and fall back to the
screen crop with rounded mask. Hover changes are logged with the picked
window id and tracked count for diagnosis.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 14:55:40 +08:00
TZGyn 7633e81c52 Capture picked windows natively with real rounded corners
Window picks now capture via ScreenCaptureKit's desktopIndependentWindow
filter, so the image carries the app's actual rounded-corner alpha
instead of a fixed 10pt mask that let the desktop show through on apps
with larger radii. Falls back to crop+mask when the window is not in the
shareable content.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 14:10:03 +08:00
TZGyn 83b127cf44 Detect maximized windows on secondary displays
A leftover heuristic skipped any window matching its screen's dimensions,
which made window detection silently fail on secondary monitors where the
working window is typically maximized.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 14:03:52 +08:00
TZGyn 8332661504 Guard against event-tap teardown crashes; log result-window display
A tap-disabled callback force-unwrapped the tap port, which could crash
the app mid-capture (no result window, dead hotkey). Guard it and log
re-enables. Result window display is now logged with the image size.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 12:29:53 +08:00
TZGyn 832a18b06c Exclude the cursor from captures
💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 12:26:12 +08:00
5 changed files with 238 additions and 69 deletions
+28 -14
View File
@@ -119,33 +119,47 @@ enum LoginItem {
} }
} }
func runOCRSelfTest() { func renderTestImage(_ text: String, fontSize: CGFloat = 56) -> CGImage? {
let text = "Kapture OCR test 1234, hello world" let size = NSSize(width: 1900, height: 220)
let size = NSSize(width: 1600, height: 220)
guard let rep = NSBitmapImageRep( guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height), bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
) else { exit(1) } ) else { return nil }
NSGraphicsContext.saveGraphicsState() NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
NSColor.white.setFill() NSColor.white.setFill()
NSRect(origin: .zero, size: size).fill() NSRect(origin: .zero, size: size).fill()
let attrs: [NSAttributedString.Key: Any] = [ let attrs: [NSAttributedString.Key: Any] = [
.font: NSFont.boldSystemFont(ofSize: 56), .font: NSFont.systemFont(ofSize: fontSize),
.foregroundColor: NSColor.black, .foregroundColor: NSColor.black,
] ]
text.draw(at: NSPoint(x: 60, y: 75), withAttributes: attrs) text.draw(at: NSPoint(x: 60, y: 75), withAttributes: attrs)
NSGraphicsContext.restoreGraphicsState() NSGraphicsContext.restoreGraphicsState()
return rep.cgImage
guard let cgImage = rep.cgImage else { exit(1) }
OCRService.shared.recognize(cgImage) { recognized in
print("expected: \(text)")
print("got: \(recognized)")
let ok = recognized == text
print(ok ? "PASS" : "FAIL")
exit(ok ? 0 : 1)
} }
func runOCRSelfTest() {
let cases: [(expected: String, contains: String)] = [
("Kapture OCR test 1234, hello world", "Kapture OCR test 1234"),
("你好世界,这是一个测试", "你好世界"),
]
var index = 0
func runNext() {
guard index < cases.count else { exit(0) }
let current = cases[index]
index += 1
guard let image = renderTestImage(current.expected) else { exit(1) }
OCRService.shared.recognize(image) { recognized in
print("case: \(current.expected)")
print("got: \(recognized)")
let ok = recognized.contains(current.contains)
print(ok ? "PASS" : "FAIL")
if !ok { exit(1) }
runNext()
}
}
runNext()
RunLoop.main.run() RunLoop.main.run()
} }
+181 -51
View File
@@ -14,8 +14,9 @@ final class CaptureController: NSObject {
// Selection state (driven by the event tap; AppKit global coordinates) // Selection state (driven by the event tap; AppKit global coordinates)
private var marqueeStart: NSPoint? private var marqueeStart: NSPoint?
private(set) var marqueeRect: NSRect? private(set) var marqueeRect: NSRect?
private(set) var hoverFrame: NSRect? private(set) var hoverWindow: (id: CGWindowID, frame: NSRect, pid: Int)?
private var trackedWindows: [(id: CGWindowID, frame: NSRect)] = [] var hoverFrame: NSRect? { hoverWindow?.frame }
private var trackedWindows: [(id: CGWindowID, frame: NSRect, pid: Int)] = []
private var lastWindowScan = Date.distantPast private var lastWindowScan = Date.distantPast
private var eventTap: CFMachPort? private var eventTap: CFMachPort?
@@ -83,33 +84,43 @@ final class CaptureController: NSObject {
} }
isSelecting = true isSelecting = true
previousApp = NSWorkspace.shared.frontmostApplication previousApp = NSWorkspace.shared.frontmostApplication
// Activation is async on macOS 26; wait a tick so the app is active // With the event tap driving input we must NOT activate: stealing
// before the overlays appear (otherwise clicks pass through). // focus turns the target app into background mode (dimmed chrome),
// and that is what would end up in the screenshot. Activate only on
// the fallback path, where panel events are the only input.
let usesTap = AXIsProcessTrusted()
if !usesTap {
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
CaptureController.log("begin: activate issued, active=\(NSApp.isActive)") }
CaptureController.log("begin: usesTap=\(usesTap) active=\(NSApp.isActive)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self, self.isSelecting else { return } guard let self, self.isSelecting else { return }
CaptureController.log("begin: active=\(NSApp.isActive) — showing overlays")
self.overlays = NSScreen.screens.map { screen in self.overlays = NSScreen.screens.map { screen in
let panel = OverlayWindow(screen: screen, controller: self) let panel = OverlayWindow(screen: screen, controller: self)
(panel.contentView as? OverlayView)?.controller = self (panel.contentView as? OverlayView)?.controller = self
return panel return panel
} }
self.overlays.forEach { $0.makeKeyAndOrderFront(nil) } // orderFrontRegardless: show without taking key/focus.
self.overlays.forEach { $0.orderFrontRegardless() }
self.overlayIDs = self.overlays.map { $0.windowNumber } self.overlayIDs = self.overlays.map { $0.windowNumber }
CaptureController.log("begin: \(self.overlays.count) overlays, ids=\(self.overlayIDs), active=\(NSApp.isActive)") CaptureController.log("begin: \(self.overlays.count) overlays, ids=\(self.overlayIDs) — prior app stays focused")
self.installEventTap() self.installEventTap()
} }
} }
/// A window was picked from hover — capture it with rounded corners. /// A window was picked from hover — capture it directly (real rounded
private func finishWindowPick(_ rect: NSRect) { /// corners come from the window's own alpha).
private func finishWindowPick(_ windowID: CGWindowID, _ rect: NSRect, ownedRisk: Bool) {
let ids = overlayIDs let ids = overlayIDs
hideOverlays() hideOverlays()
isSelecting = false isSelecting = false
restorePreviousApp() restorePreviousApp()
DispatchQueue.main.async { [self] in DispatchQueue.main.async { [self] in
if ownedRisk {
capture(rect, excludingWindowIDs: ids, roundedCorners: true) capture(rect, excludingWindowIDs: ids, roundedCorners: true)
} else {
capture(rect, excludingWindowIDs: ids, windowID: windowID)
}
} }
} }
@@ -136,7 +147,7 @@ final class CaptureController: NSObject {
overlays.forEach { $0.orderOut(nil) } overlays.forEach { $0.orderOut(nil) }
overlays = [] overlays = []
marqueeRect = nil marqueeRect = nil
hoverFrame = nil hoverWindow = nil
marqueeStart = nil marqueeStart = nil
} }
@@ -217,11 +228,12 @@ final class CaptureController: NSObject {
) as? [[String: Any]] else { trackedWindows = []; return } ) as? [[String: Any]] else { trackedWindows = []; return }
let ownIDs = Set(overlayIDs) let ownIDs = Set(overlayIDs)
var found: [(id: CGWindowID, frame: NSRect)] = [] var found: [(id: CGWindowID, frame: NSRect, pid: Int)] = []
for info in list { for info in list {
guard let cid = info[kCGWindowNumber as String] as? CGWindowID, guard let cid = info[kCGWindowNumber as String] as? CGWindowID,
let bounds = info[kCGWindowBounds as String] as? [String: CGFloat], let bounds = info[kCGWindowBounds as String] as? [String: CGFloat],
let layer = info[kCGWindowLayer as String] as? Int, let layer = info[kCGWindowLayer as String] as? Int,
let pid = info[kCGWindowOwnerPID as String] as? Int,
layer == 0 layer == 0
else { continue } else { continue }
if ownIDs.contains(Int(cid)) { continue } if ownIDs.contains(Int(cid)) { continue }
@@ -233,12 +245,7 @@ final class CaptureController: NSObject {
) )
guard cgRect.width >= 80, cgRect.height >= 60 else { continue } guard cgRect.width >= 80, cgRect.height >= 60 else { continue }
let rect = Self.cgToAppKit(rect: cgRect) let rect = Self.cgToAppKit(rect: cgRect)
// Skip windows filling an entire screen (fullscreen apps) found.append((id: cid, frame: rect, pid: pid))
let coversWholeScreen = NSScreen.screens.contains { screen in
abs(screen.frame.width - rect.width) < 2 && abs(screen.frame.height - rect.height) < 2
}
guard !coversWholeScreen else { continue }
found.append((cid, rect))
} }
trackedWindows = found trackedWindows = found
} }
@@ -251,25 +258,38 @@ final class CaptureController: NSObject {
return NSRect(x: rect.origin.x, y: y, width: rect.width, height: rect.height) return NSRect(x: rect.origin.x, y: y, width: rect.width, height: rect.height)
} }
private func topmostWindowContaining(_ appKitPoint: NSPoint) -> NSRect? { private func topmostWindowContaining(_ appKitPoint: NSPoint) -> (id: CGWindowID, frame: NSRect, pid: Int)? {
for (_, frame) in trackedWindows { for entry in trackedWindows {
if frame.contains(appKitPoint) { if entry.frame.contains(appKitPoint) {
return frame return entry
} }
} }
return nil return nil
} }
/// System dialogs (permission prompts, System Settings) must always
/// receive their clicks even while we're selecting.
private func isSystemDialogFrontmost() -> Bool {
guard let front = NSWorkspace.shared.frontmostApplication else { return false }
let id = front.bundleIdentifier ?? ""
return id.hasPrefix("com.apple.security") || id.hasPrefix("com.apple.SystemSettings") || id == "com.apple.systempreferences"
}
/// Returns the event to pass through, or nil to consume it. /// Returns the event to pass through, or nil to consume it.
func handleTap(_ type: CGEventType, _ event: CGEvent) -> CGEvent? { func handleTap(_ type: CGEventType, _ event: CGEvent) -> CGEvent? {
guard isSelecting else { return event } guard isSelecting else { return event }
if isSystemDialogFrontmost() {
CaptureController.log("tap: system dialog frontmost — passing through \(type)")
return event
}
switch type { switch type {
case .tapDisabledByUserInput: case .tapDisabledByUserInput, .tapDisabledByTimeout:
CGEvent.tapEnable(tap: eventTap!, enable: true) // A timed-out tap must be re-enabled; guard against a torn-down tap.
return event if let tap = eventTap {
case .tapDisabledByTimeout: CGEvent.tapEnable(tap: tap, enable: true)
CGEvent.tapEnable(tap: eventTap!, enable: true) CaptureController.log("tap: re-enabled after \(type)")
}
return event return event
case .keyDown: case .keyDown:
if event.getIntegerValueField(.keyboardEventKeycode) == 53 { if event.getIntegerValueField(.keyboardEventKeycode) == 53 {
@@ -286,15 +306,23 @@ final class CaptureController: NSObject {
} }
let appKitPoint = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y)) let appKitPoint = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y))
if type == .mouseMoved { if type == .mouseMoved {
let frame = topmostWindowContaining(appKitPoint) let window = topmostWindowContaining(appKitPoint)
if frame != hoverFrame { if window?.frame != hoverFrame {
hoverFrame = frame hoverWindow = window
if let w = window {
CaptureController.log("hover: picked id=\(w.id) frame=\(w.frame) (tracked=\(trackedWindows.count))")
} else {
CaptureController.log("hover: none (tracked=\(trackedWindows.count))")
}
renderOverlays() renderOverlays()
} }
return event return event
} }
// Drag: grow the marquee // Drag: grow the marquee — but only after real movement, so a
// click's micro-jitter keeps the hovered window eligible.
guard let start = marqueeStart else { return nil } guard let start = marqueeStart else { return nil }
let distance = hypot(appKitPoint.x - start.x, appKitPoint.y - start.y)
guard distance >= 4 else { return nil }
let rect = NSRect( let rect = NSRect(
x: min(start.x, appKitPoint.x), x: min(start.x, appKitPoint.x),
y: min(start.y, appKitPoint.y), y: min(start.y, appKitPoint.y),
@@ -302,7 +330,7 @@ final class CaptureController: NSObject {
height: abs(appKitPoint.y - start.y) height: abs(appKitPoint.y - start.y)
) )
marqueeRect = rect marqueeRect = rect
hoverFrame = nil hoverWindow = nil
renderOverlays() renderOverlays()
return nil return nil
case .leftMouseDown: case .leftMouseDown:
@@ -317,9 +345,18 @@ final class CaptureController: NSObject {
if let rect = marqueeRect, dragDistance >= 4, rect.width >= 2, rect.height >= 2 { if let rect = marqueeRect, dragDistance >= 4, rect.width >= 2, rect.height >= 2 {
CaptureController.log("tap: marquee complete \(rect)") CaptureController.log("tap: marquee complete \(rect)")
finishSelection(rect) finishSelection(rect)
} else if let hover = hoverFrame { } else if let hover = hoverWindow {
CaptureController.log("tap: window pick \(hover)") // An overlapping window from the same app means this is an
finishWindowPick(hover) // owned popup — desktopIndependentWindow would composite the
// parent too, so capture the screen crop instead.
// Owned popups composite their parent even when the windows
// don't overlap, so ANY other same-app window on screen means
// crop (correct region) instead of the group composite.
let ownedRisk = trackedWindows.contains {
$0.id != hover.id && $0.pid == hover.pid
}
CaptureController.log("tap: window pick id=\(hover.id) pid=\(hover.pid) ownedRisk=\(ownedRisk) \(hover.frame)")
finishWindowPick(hover.id, hover.frame, ownedRisk: ownedRisk)
} else { } else {
CaptureController.log("tap: click with no hover/marquee — cancel") CaptureController.log("tap: click with no hover/marquee — cancel")
cancelSelection() cancelSelection()
@@ -332,8 +369,8 @@ final class CaptureController: NSObject {
// MARK: - Capture // MARK: - Capture
private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int], roundedCorners: Bool = false) { private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int], roundedCorners: Bool = false, windowID: CGWindowID? = nil) {
ScreenCapture.shared.capture(rect, excludingWindowIDs: ids, roundedCorners: roundedCorners) { [weak self] image in ScreenCapture.shared.capture(rect, excludingWindowIDs: ids, roundedCorners: roundedCorners, windowID: windowID) { [weak self] image in
DispatchQueue.main.async { DispatchQueue.main.async {
self?.isSelecting = false self?.isSelecting = false
self?.restorePreviousApp() self?.restorePreviousApp()
@@ -342,6 +379,7 @@ final class CaptureController: NSObject {
ResultWindow.shared.show(image: ResultWindow.failureImage("Capture failed. Check Screen Recording permission.")) ResultWindow.shared.show(image: ResultWindow.failureImage("Capture failed. Check Screen Recording permission."))
return return
} }
CaptureController.log("capture done: \(image.width)x\(image.height) — showing result window")
ResultWindow.shared.show(image: image) ResultWindow.shared.show(image: image)
} }
} }
@@ -354,6 +392,41 @@ final class ScreenCapture {
private init() {} private init() {}
/// Estimate the window's corner radius (in image pixels) by scanning the
/// top-left corner: the crop's corner pixels are the desktop background;
/// app content starts where the color departs from it.
static func detectCornerRadius(of image: CGImage) -> CGFloat? {
let w = image.width, h = image.height
guard w > 30, h > 30 else { return nil }
var px = [UInt8](repeating: 0, count: w * h * 4)
guard let c = CGContext(data: &px, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
c.draw(image, in: CGRect(x: 0, y: 0, width: w, height: h))
func pixel(x: Int, yTop: Int) -> (r: Int, g: Int, b: Int) {
let row = yTop * w * 4 + x * 4
return (Int(px[row]), Int(px[row + 1]), Int(px[row + 2]))
}
let bg = pixel(x: 0, yTop: 0)
func diffFromBg(_ p: (r: Int, g: Int, b: Int)) -> Int {
abs(p.r - bg.r) + abs(p.g - bg.g) + abs(p.b - bg.b)
}
var radii: [CGFloat] = []
for (limit, axis) in [(h / 3, 0), (w / 3, 1)] {
for i in 0..<limit {
let p = axis == 0 ? pixel(x: 0, yTop: i) : pixel(x: i, yTop: 0)
if diffFromBg(p) > 24 {
radii.append(CGFloat(i))
break
}
}
}
guard radii.count == 2 else { return nil }
let r = (radii[0] + radii[1]) / 2
return (r >= 3 && r <= 40) ? r : nil
}
@available(macOS 14.0, *) @available(macOS 14.0, *)
private func crop(_ image: CGImage, to cgRect: CGRect, display: SCDisplay, roundedCorners: Bool = false) -> CGImage? { private func crop(_ image: CGImage, to cgRect: CGRect, display: SCDisplay, roundedCorners: Bool = false) -> CGImage? {
// cgRect is in SCDisplay space (bottom-left origin, Y flipped vs AppKit). // cgRect is in SCDisplay space (bottom-left origin, Y flipped vs AppKit).
@@ -384,23 +457,49 @@ final class ScreenCapture {
// Apply a rounded-corner alpha mask for window captures, matching // Apply a rounded-corner alpha mask for window captures, matching
// macOS window chrome (≈10pt radius, scaled to image pixels). // macOS window chrome (≈10pt radius, scaled to image pixels).
if roundedCorners { if roundedCorners {
let radius = 10 * scaleX // The screen crop contains the app's real rounded corners against
let maskRect = CGRect(x: 0, y: 0, width: result.width, height: result.height) // the desktop — measure the radius from the corner pixels, then
guard let maskCtx = CGContext( // mask with it (supersampled for smooth edges).
let detected = Self.detectCornerRadius(of: result)
let radius = min(max((detected.map { $0 * 1.23 + 2.6 }) ?? (10 * scaleX), 4), 40)
CaptureController.log("corners: detected=\(detected.map { String(format: "%.1f", $0) } ?? "nil") using=\(Int(radius))px")
// Anti-aliased gray mask: supersample 3x, downsample, then draw
// the image through it ONCE at 1x (no content resampling blur).
let ss = 3
let W = result.width * ss, H = result.height * ss
guard let hiMask = CGContext(
data: nil, width: W, height: H,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceGray(),
bitmapInfo: CGImageAlphaInfo.none.rawValue
) else { return result }
hiMask.setFillColor(CGColor(gray: 1, alpha: 1))
hiMask.fill(CGRect(x: 0, y: 0, width: W, height: H))
hiMask.setFillColor(CGColor(gray: 0, alpha: 1))
let cornerPath = CGMutablePath()
cornerPath.addRect(CGRect(x: 0, y: 0, width: W, height: H))
cornerPath.addRoundedRect(in: CGRect(x: 0, y: 0, width: W, height: H), cornerWidth: radius * CGFloat(ss), cornerHeight: radius * CGFloat(ss), transform: .identity)
hiMask.addPath(cornerPath)
hiMask.fillPath(using: .evenOdd)
guard let hiMaskImg = hiMask.makeImage(),
let downMask = CGContext(
data: nil, width: result.width, height: result.height,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceGray(),
bitmapInfo: CGImageAlphaInfo.none.rawValue
) else { return result }
downMask.interpolationQuality = .high
downMask.draw(hiMaskImg, in: CGRect(x: 0, y: 0, width: result.width, height: result.height))
guard let mask1x = downMask.makeImage(),
let ctx = CGContext(
data: nil, width: result.width, height: result.height, data: nil, width: result.width, height: result.height,
bitsPerComponent: 8, bytesPerRow: 0, bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(), space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return result } ) else { return result }
maskCtx.setFillColor(CGColor(gray: 1, alpha: 1)) ctx.clip(to: CGRect(x: 0, y: 0, width: result.width, height: result.height), mask: mask1x)
maskCtx.fill(maskRect) ctx.draw(result, in: CGRect(x: 0, y: 0, width: result.width, height: result.height))
maskCtx.setBlendMode(.clear) if let masked = ctx.makeImage() {
maskCtx.setFillColor(CGColor(gray: 0, alpha: 1))
maskCtx.addPath(CGPath(roundedRect: maskRect, cornerWidth: radius, cornerHeight: radius, transform: nil))
maskCtx.fillPath()
maskCtx.setBlendMode(.normal)
if let mask = maskCtx.makeImage(),
let masked = result.masking(mask) {
result = masked result = masked
} }
} }
@@ -417,7 +516,7 @@ final class ScreenCapture {
return content.displays.first(where: { $0.displayID == cgID }) ?? content.displays.first return content.displays.first(where: { $0.displayID == cgID }) ?? content.displays.first
} }
func capture(_ rect: NSRect, excludingWindowIDs excludedIDs: [Int], roundedCorners: Bool = false, completion: @escaping (CGImage?) -> Void) { func capture(_ rect: NSRect, excludingWindowIDs excludedIDs: [Int], roundedCorners: Bool = false, windowID: CGWindowID? = nil, completion: @escaping (CGImage?) -> Void) {
if #available(macOS 14.0, *) { if #available(macOS 14.0, *) {
Task { Task {
guard let content = try? await SCShareableContent.current, guard let content = try? await SCShareableContent.current,
@@ -440,7 +539,7 @@ final class ScreenCapture {
let backingScale = screen.backingScaleFactor let backingScale = screen.backingScaleFactor
config.width = Int(CGFloat(display.width) * backingScale) config.width = Int(CGFloat(display.width) * backingScale)
config.height = Int(CGFloat(display.height) * backingScale) config.height = Int(CGFloat(display.height) * backingScale)
config.showsCursor = true config.showsCursor = false
// ScreenCaptureKit uses a different origin than AppKit for // ScreenCaptureKit uses a different origin than AppKit for
// screens stacked around the main display: per-display, // screens stacked around the main display: per-display,
@@ -452,6 +551,37 @@ final class ScreenCapture {
height: rect.height height: rect.height
) )
// Window pick: capture the window DIRECTLY — the image comes
// with the app's real rounded-corner alpha (no mask guessing).
if let windowID,
let scWindow = content.windows.first(where: { $0.windowID == windowID }) {
var windowConfig = SCStreamConfiguration()
windowConfig.width = Int(scWindow.frame.width * backingScale)
windowConfig.height = Int(scWindow.frame.height * backingScale)
windowConfig.showsCursor = false
if let windowImage = try? await SCScreenshotManager.captureImage(
contentFilter: SCContentFilter(desktopIndependentWindow: scWindow),
configuration: windowConfig
) {
let expectedW = CGFloat(scWindow.frame.width) * backingScale
let expectedH = CGFloat(scWindow.frame.height) * backingScale
let overflow = max(
CGFloat(windowImage.width) / max(expectedW, 1),
CGFloat(windowImage.height) / max(expectedH, 1)
)
if overflow > 1.35 {
// SCKit composited extra windows (owned popups etc.)
// — reject and use the screen crop instead.
CaptureController.log("capture: WINDOW id=\(windowID) overflow=\(overflow) (\(windowImage.width)x\(windowImage.height) vs \(Int(expectedW))x\(Int(expectedH))) — rejecting, using crop+mask")
} else {
CaptureController.log("capture: WINDOW id=\(windowID) image=\(windowImage.width)x\(windowImage.height) (native alpha corners)")
completion(windowImage)
return
}
}
CaptureController.log("capture: window filter failed for id=\(windowID) — falling back to crop+mask")
}
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
+5
View File
@@ -12,6 +12,11 @@ final class OCRService {
request.recognitionLevel = .accurate request.recognitionLevel = .accurate
request.usesLanguageCorrection = true request.usesLanguageCorrection = true
request.recognitionLanguages = ["en-US", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR"] request.recognitionLanguages = ["en-US", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR"]
// Without this, Vision defaults to the first listed language and
// renders CJK as Latin garbage (verified: 你好世界 -> "177WR").
if #available(macOS 13.0, *) {
request.automaticallyDetectsLanguage = true
}
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do { do {
+21 -1
View File
@@ -6,8 +6,11 @@ final class OverlayWindow: NSPanel {
init(screen: NSScreen, controller: CaptureController) { init(screen: NSScreen, controller: CaptureController) {
let content = OverlayView() let content = OverlayView()
super.init( super.init(
// Non-activating: the overlay must NOT steal focus, so the app
// being captured keeps its active appearance (undimmed chrome).
// Input comes from the CGEventTap, not panel events.
contentRect: screen.frame, contentRect: screen.frame,
styleMask: [.borderless], styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered, backing: .buffered,
defer: false defer: false
) )
@@ -57,6 +60,23 @@ final class OverlayView: NSView {
NSColor.black.withAlphaComponent(0.35).setFill() NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill() NSBezierPath(rect: bounds).fill()
// Esc hint — the only way out once clicks are consumed by the tap.
let hint = "Drag to select · hover + click a window to capture it · Esc to cancel"
let hintAttrs: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .medium),
.foregroundColor: NSColor.white.withAlphaComponent(0.85),
]
let hintSize = hint.size(withAttributes: hintAttrs)
let hintRect = NSRect(
x: (bounds.width - hintSize.width) / 2 - 10,
y: bounds.height - 46,
width: hintSize.width + 20,
height: hintSize.height + 12
)
NSColor.black.withAlphaComponent(0.65).setFill()
NSBezierPath(roundedRect: hintRect, xRadius: 8, yRadius: 8).fill()
hint.draw(at: NSPoint(x: hintRect.minX + 10, y: hintRect.minY + 6), withAttributes: hintAttrs)
guard let controller else { return } guard let controller else { return }
// Highlight the hovered window (rounded, like real window corners) // Highlight the hovered window (rounded, like real window corners)
+2 -2
View File
@@ -108,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.7.1</string> <string>1.9.1</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>71</string> <string>91</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>