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.onRecordTrigger = { if RecordingController.shared.state.isRecording { RecordingController.shared.stop() } else { CaptureController.shared.begin(recording: true) } } HotkeyManager.shared.register() } func applicationWillTerminate(_ notification: Notification) { RecordingController.shared.finalizeForTermination() } } struct MenuView: View { @State private var granted = PermissionManager.isGranted @ObservedObject private var recordingState = RecordingController.shared.state var body: some View { Button("Capture & OCR") { CaptureController.shared.begin() } .keyboardShortcut("s", modifiers: [.command, .shift]) .disabled(recordingState.isRecording) Button("Capture Frozen Screen") { CaptureController.shared.begin(frozen: true) } .keyboardShortcut("s", modifiers: [.command, .shift, .control]) .disabled(recordingState.isRecording) if recordingState.isRecording { Button("Stop Recording") { RecordingController.shared.stop() } .keyboardShortcut("r", modifiers: [.command, .shift, .control]) } else { Button("Record Screen…") { CaptureController.shared.begin(recording: true) } .keyboardShortcut("r", 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() }