Tiny UI text (around 7pt) reads as nothing at native size — verified
locally. When a pass returns fewer than three characters, OCR retries at
2x and 3x and keeps the fullest result. The smoke test covers a tiny-text
case so the fallback stays working.
💘 Generated with Crush
Assisted-by: Crush:deepseek-v4-flash-0731
175 lines
6.2 KiB
Swift
175 lines
6.2 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
import CoreGraphics
|
|
import ServiceManagement
|
|
|
|
@main
|
|
struct KaptureApp: App {
|
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
|
|
|
var body: some Scene {
|
|
MenuBarExtra("Kapture", systemImage: "text.viewfinder") {
|
|
MenuView()
|
|
}
|
|
.menuBarExtraStyle(.menu)
|
|
}
|
|
}
|
|
|
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
if CommandLine.arguments.contains("--test-ocr") {
|
|
runOCRSelfTest()
|
|
return
|
|
}
|
|
// Single instance: stale copies keep their own hotkey + overlays alive
|
|
// after app updates and eat clicks meant for the new instance.
|
|
let myPid = ProcessInfo.processInfo.processIdentifier
|
|
let others = NSRunningApplication.runningApplications(withBundleIdentifier: "dev.tzgyn.kapture")
|
|
.filter { $0.processIdentifier != myPid }
|
|
if !others.isEmpty {
|
|
CaptureController.log("launch: other Kapture instances pids=\(others.map { String($0.processIdentifier) }.joined(separator: ",")) — terminating them")
|
|
for other in others {
|
|
other.forceTerminate()
|
|
}
|
|
}
|
|
HotkeyManager.shared.onTrigger = { CaptureController.shared.begin() }
|
|
HotkeyManager.shared.onFrozenTrigger = { CaptureController.shared.begin(frozen: true) }
|
|
HotkeyManager.shared.register()
|
|
}
|
|
}
|
|
|
|
struct MenuView: View {
|
|
@State private var granted = PermissionManager.isGranted
|
|
|
|
var body: some View {
|
|
Button("Capture & OCR") { CaptureController.shared.begin() }
|
|
.keyboardShortcut("s", modifiers: [.command, .shift])
|
|
|
|
Button("Capture Frozen Screen") { CaptureController.shared.begin(frozen: true) }
|
|
.keyboardShortcut("s", modifiers: [.command, .shift, .control])
|
|
|
|
Button("Extract from Image File…") { ResultWindow.shared.showOpenPanel() }
|
|
|
|
Divider()
|
|
|
|
if granted {
|
|
Text("Screen Recording: Granted")
|
|
} else {
|
|
Button("Grant Screen Recording Permission…") {
|
|
PermissionManager.request()
|
|
granted = PermissionManager.isGranted
|
|
}
|
|
}
|
|
|
|
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") {
|
|
if !LoginItem.toggle() {
|
|
let alert = NSAlert()
|
|
alert.messageText = "Could not update Launch at Login"
|
|
alert.informativeText = "Launch at Login requires a signed app bundle in /Applications. It is not available when running via `swift run`."
|
|
alert.alertStyle = .warning
|
|
alert.addButton(withTitle: "OK")
|
|
alert.runModal()
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Quit Kapture") { NSApp.terminate(nil) }
|
|
.keyboardShortcut("q")
|
|
}
|
|
}
|
|
|
|
enum PermissionManager {
|
|
static var isGranted: Bool { CGPreflightScreenCaptureAccess() }
|
|
|
|
static func request() {
|
|
CGRequestScreenCaptureAccess()
|
|
}
|
|
|
|
static var isAccessibilityGranted: Bool { AXIsProcessTrusted() }
|
|
|
|
static func requestAccessibility() {
|
|
let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
|
_ = AXIsProcessTrustedWithOptions(opts)
|
|
}
|
|
}
|
|
|
|
enum LoginItem {
|
|
static var isEnabled: Bool {
|
|
SMAppService.mainApp.status == .enabled
|
|
}
|
|
|
|
static func toggle() -> Bool {
|
|
let service = SMAppService.mainApp
|
|
do {
|
|
if service.status == .enabled {
|
|
try service.unregister()
|
|
} else {
|
|
try service.register()
|
|
}
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
func renderTestImage(_ text: String, fontSize: CGFloat = 56, width: CGFloat = 1900, height: CGFloat = 220) -> CGImage? {
|
|
let size = NSSize(width: width, height: 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 nil }
|
|
NSGraphicsContext.saveGraphicsState()
|
|
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
|
|
NSColor.white.setFill()
|
|
NSRect(origin: .zero, size: size).fill()
|
|
let attrs: [NSAttributedString.Key: Any] = [
|
|
.font: NSFont.systemFont(ofSize: fontSize),
|
|
.foregroundColor: NSColor.black,
|
|
]
|
|
// Centre the text vertically so tiny canvases still contain it.
|
|
let textHeight = ("Ag" as NSString).size(withAttributes: attrs).height
|
|
let y = max(4, (size.height - textHeight) / 2)
|
|
text.draw(at: NSPoint(x: 20, y: y), withAttributes: attrs)
|
|
NSGraphicsContext.restoreGraphicsState()
|
|
return rep.cgImage
|
|
}
|
|
|
|
func runOCRSelfTest() {
|
|
let cases: [(expected: String, contains: String, fontSize: CGFloat, width: CGFloat, height: CGFloat)] = [
|
|
("Kapture OCR test 1234, hello world", "Kapture OCR test 1234", 56, 1900, 220),
|
|
("你好世界,这是一个测试", "你好世界", 56, 1900, 220),
|
|
// Tiny text: needs the upscale retry to read anything at all.
|
|
("会话导出并发冲突报错 export fail", "导出", 7, 320, 40),
|
|
]
|
|
var index = 0
|
|
|
|
func runNext() {
|
|
guard index < cases.count else { exit(0) }
|
|
let current = cases[index]
|
|
index += 1
|
|
guard let image = renderTestImage(current.expected, fontSize: current.fontSize, width: current.width, height: current.height) else { exit(1) }
|
|
OCRService.shared.recognize(image) { recognized in
|
|
print("case: \(current.expected) @\(Int(current.fontSize))pt")
|
|
print("got: \(recognized)")
|
|
let ok = recognized.contains(current.contains)
|
|
print(ok ? "PASS" : "FAIL")
|
|
if !ok { exit(1) }
|
|
runNext()
|
|
}
|
|
}
|
|
runNext()
|
|
RunLoop.main.run()
|
|
}
|