Compare commits

...
3 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
5 changed files with 79 additions and 23 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()
} }
+23 -6
View File
@@ -84,21 +84,26 @@ 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()
} }
} }
@@ -262,9 +267,21 @@ final class CaptureController: NSObject {
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, .tapDisabledByTimeout: case .tapDisabledByUserInput, .tapDisabledByTimeout:
+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.8.6</string> <string>1.9.1</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>86</string> <string>91</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>