Recording: Ctrl+Shift+Cmd+R (or the menu) opens the same selection flow — a marquee records that region, clicking a window records it (following moves). Video is H.264 .mov at 60fps via SCStream -> AVAssetWriter with the cursor included, auto-saved to ~/Movies and revealed in Finder on stop. A red border and a floating timer/stop bar show during recording, both excluded from the video. Audio is wired behind a flag for later. The result window gains a Crop button (crop to the selection; annotations translate and stay editable) and is restyled after Cap: sliding-pill Screenshot/Text switcher, capsule buttons in Cap blue, circular icon buttons, and the content on a rounded card — light and dark. Generated with OpenCode Assisted-by: Kimi:k3
197 lines
7.2 KiB
Swift
197 lines
7.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.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()
|
|
}
|