Compare commits

...
23 Commits
Author SHA1 Message Date
TZGyn cac85402a3 Remember the Actual Size view choice
Persist the magnifier state in UserDefaults so every new result window
opens in the same mode and the choice survives relaunches.

Generated with OpenCode

Assisted-by: Kimi:k3
2026-10-26 21:14:51 +08:00
TZGyn d4b0970ed2 Fix Actual Size: activation, window sizing, and real backing scale
Three bugs made the toggle appear dead: the result window never
activated the LSUIElement app, so the first click was consumed; the
hosting controller's Auto Layout fitting size shrank the window when
the layout branch changed; and SwiftUI's displayScale environment does
not reliably track the window's display on macOS. The window now
activates before ordering front, the hosting view is attached directly
as the contentView (no fitting-size constraints), and the actual-size
scale comes from the window's backingScaleFactor (updated when it moves
between displays).

Generated with OpenCode

Assisted-by: Kimi:k3
2026-10-26 20:57:12 +08:00
TZGyn ce89d34654 Make crop a Photos-style tool with undo and actual-size view
Crop is now a mode like Draw/Redact: a draggable rect with corner and
edge handles, rule-of-thirds grid, dimmed surroundings, Cancel (Esc) and
Crop Image (Return). Re-entering crop after a crop shows the full
original with the handles on the current crop region, so it can be
dragged back outward; every crop is undoable from the toolbar.

The result window also gains an Actual Size view (Cmd+0): one image
pixel per screen pixel, scrollable, so retina captures stay crisp
instead of being resampled down to the window size.

Generated with OpenCode

Assisted-by: Kimi:k3
2026-10-26 20:45:48 +08:00
TZGyn 6a2afef1d2 Add screen recording, crop, and a Cap-style result window
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
2026-10-24 21:48:59 +08:00
TZGyn 04b84e188a Retry OCR on upscaled images when nothing is read
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
2026-09-30 11:44:26 +08:00
TZGyn 2e971d5f8d Darken the screen in frozen capture mode
Frozen mode showed the still image at full brightness until a selection
existed, unlike live mode's dark veil. It now draws the same 35% veil over
everything and re-draws the selected area from the snapshot, keeping the
selection bright. The hint text matches the mode.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-25 17:43:59 +08:00
TZGyn e4e20c51d3 Add frozen-screen capture on Ctrl+Shift+Cmd+S
A second hotkey snapshots every display the moment it is pressed and
shows that still image as the overlay, so the pointer and hover states
(tooltips, open menus) are captured. Selection is marquee-only — no
window detection — and the result is cropped from the snapshot rather
than recaptured, keeping the frame exactly as it was.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-25 17:40:12 +08:00
TZGyn 26700c59f4 Make exported stroke thickness match the preview
Strokes were sized in view points but exported in image pixels, so on
retina captures the saved/copied annotation looked several times thinner.
Stroke widths are now converted into image pixels when drawn and scaled
back for the preview, keeping preview and export identical.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-23 17:28:38 +08:00
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
TZGyn 6e5796e16d Check Accessibility before starting a capture; log capture geometry
The first capture used to start the overlay and the system permission
prompt at once, making both uninteractive. begin() now requires
Accessibility up front, shows an alert with a direct link to the setting,
and only starts capturing once granted. Captures log their display,
backing scale, and rect mapping for diagnosing multi-display offsets.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 12:24:47 +08:00
TZGyn 7e9cc5b269 Drive capture selection through a CGEventTap
Panel event delivery is unreliable on macOS 26 (clicks pass through even
with the app active and the panel key). The selection flow is now driven
by a CGEventTap that sees and consumes mouse events system-wide: hover
highlights windows, clicks capture them (rounded), drags marquee-select,
Esc cancels. Requires Accessibility permission, prompted on first use;
falls back to panel events when unavailable.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 12:08:57 +08:00
9 changed files with 1856 additions and 417 deletions
+66 -14
View File
@@ -33,16 +33,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
} }
HotkeyManager.shared.onTrigger = { CaptureController.shared.begin() } 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() HotkeyManager.shared.register()
} }
func applicationWillTerminate(_ notification: Notification) {
RecordingController.shared.finalizeForTermination()
}
} }
struct MenuView: View { struct MenuView: View {
@State private var granted = PermissionManager.isGranted @State private var granted = PermissionManager.isGranted
@ObservedObject private var recordingState = RecordingController.shared.state
var body: some View { var body: some View {
Button("Capture & OCR") { CaptureController.shared.begin() } Button("Capture & OCR") { CaptureController.shared.begin() }
.keyboardShortcut("s", modifiers: [.command, .shift]) .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() } Button("Extract from Image File…") { ResultWindow.shared.showOpenPanel() }
@@ -90,6 +116,13 @@ enum PermissionManager {
static func request() { static func request() {
CGRequestScreenCaptureAccess() CGRequestScreenCaptureAccess()
} }
static var isAccessibilityGranted: Bool { AXIsProcessTrusted() }
static func requestAccessibility() {
let opts = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(opts)
}
} }
enum LoginItem { enum LoginItem {
@@ -112,33 +145,52 @@ enum LoginItem {
} }
} }
func runOCRSelfTest() { func renderTestImage(_ text: String, fontSize: CGFloat = 56, width: CGFloat = 1900, height: CGFloat = 220) -> CGImage? {
let text = "Kapture OCR test 1234, hello world" let size = NSSize(width: width, height: height)
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) // 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() NSGraphicsContext.restoreGraphicsState()
return rep.cgImage
}
guard let cgImage = rep.cgImage else { exit(1) } func runOCRSelfTest() {
OCRService.shared.recognize(cgImage) { recognized in let cases: [(expected: String, contains: String, fontSize: CGFloat, width: CGFloat, height: CGFloat)] = [
print("expected: \(text)") ("Kapture OCR test 1234, hello world", "Kapture OCR test 1234", 56, 1900, 220),
print("got: \(recognized)") ("你好世界,这是一个测试", "你好世界", 56, 1900, 220),
let ok = recognized == text // Tiny text: needs the upscale retry to read anything at all.
print(ok ? "PASS" : "FAIL") ("会话导出并发冲突报错 export fail", "导出", 7, 320, 40),
exit(ok ? 0 : 1) ]
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() RunLoop.main.run()
} }
+527 -41
View File
@@ -1,27 +1,48 @@
import AppKit import AppKit
import CoreGraphics import CoreGraphics
import ScreenCaptureKit import ScreenCaptureKit
import ApplicationServices
final class CaptureController: NSObject { final class CaptureController: NSObject {
static let shared = CaptureController() static let shared = CaptureController()
private var overlays: [NSWindow] = [] private var overlays: [OverlayWindow] = []
private(set) var isSelecting = false private(set) var isSelecting = false
private var previousApp: NSRunningApplication? private var previousApp: NSRunningApplication?
private var overlayIDs: [Int] = [] private var overlayIDs: [Int] = []
// Selection state (driven by the event tap; AppKit global coordinates)
private var marqueeStart: NSPoint?
private(set) var marqueeRect: NSRect?
private(set) var hoverWindow: (id: CGWindowID, frame: NSRect, pid: Int)?
var hoverFrame: NSRect? { hoverWindow?.frame }
private var trackedWindows: [(id: CGWindowID, frame: NSRect, pid: Int)] = []
private var lastWindowScan = Date.distantPast
private var eventTap: CFMachPort?
/// Session event log for debugging (dumped via Copy Diagnostics). /// Session event log for debugging (dumped via Copy Diagnostics).
/// Also appended to /tmp/kapture-debug.log so it survives relaunches.
static var sessionLog: [String] = [] static var sessionLog: [String] = []
static func log(_ line: String) { static func log(_ line: String) {
sessionLog.append(line) let stamped = String(format: "%@ %@", DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium), line)
if sessionLog.count > 100 { sessionLog.removeFirst() } sessionLog.append(stamped)
if sessionLog.count > 200 { sessionLog.removeFirst() }
NSLog("[Kapture] \(line)") NSLog("[Kapture] \(line)")
let path = "/tmp/kapture-debug.log"
if let handle = FileHandle(forWritingAtPath: path) {
handle.seekToEndOfFile()
handle.write((stamped + "\n").data(using: .utf8)!)
handle.closeFile()
} else {
FileManager.default.createFile(atPath: path, contents: (stamped + "\n").data(using: .utf8))
}
} }
/// Diagnostic snapshot for "Copy Diagnostics" menu item. /// Diagnostic snapshot for "Copy Diagnostics" menu item.
func diagnostics() -> String { func diagnostics() -> String {
var lines: [String] = [] var lines: [String] = []
lines.append("Kapture diagnostics — version \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "?")") lines.append("Kapture diagnostics — version \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "?")")
lines.append("accessibility=\(AXIsProcessTrusted()) eventTap=\(eventTap != nil)")
lines.append("--- session log (newest last) ---") lines.append("--- session log (newest last) ---")
lines.append(contentsOf: Self.sessionLog) lines.append(contentsOf: Self.sessionLog)
lines.append("--- screens ---") lines.append("--- screens ---")
@@ -53,54 +74,132 @@ final class CaptureController: NSObject {
return lines.joined(separator: "\n") return lines.joined(separator: "\n")
} }
func begin() { // MARK: - Session lifecycle
/// Frozen mode: the screen is snapshotted at hotkey press, so the pointer
/// and any hover state (tooltips, open menus) end up in the capture.
private(set) var isFrozenMode = false
/// Recording mode: the selection flow ends in a recording, not a still.
private(set) var isRecordingMode = false
private var frozenSnapshots: [ScreenCapture.DisplaySnapshot] = []
func begin(frozen: Bool = false, recording: Bool = false) {
guard !isSelecting else { return } guard !isSelecting else { return }
// No new captures while a recording is running (the selection
// overlays would end up in the video).
guard !RecordingController.shared.state.isRecording else { return }
guard PermissionManager.isGranted else { guard PermissionManager.isGranted else {
PermissionManager.request() PermissionManager.request()
return return
} }
isSelecting = true isSelecting = true
isFrozenMode = frozen && !recording
isRecordingMode = recording
previousApp = NSWorkspace.shared.frontmostApplication previousApp = NSWorkspace.shared.frontmostApplication
// Activation is ASYNC: isActive only flips on a later runloop tick // With the event tap driving input we must NOT activate: stealing
// (verified on macOS 26). Show overlays only once active, otherwise // focus turns the target app into background mode (dimmed chrome),
// the first click can pass through to the windows below. // and that is what would end up in the screenshot. Activate only on
NSApp.activate(ignoringOtherApps: true) // the fallback path, where panel events are the only input.
CaptureController.log("begin: activate issued, active=\(NSApp.isActive)") let usesTap = AXIsProcessTrusted()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in if !usesTap {
guard let self, self.isSelecting else { return } NSApp.activate(ignoringOtherApps: true)
CaptureController.log("begin: active=\(NSApp.isActive) — showing overlays") }
self.overlays = NSScreen.screens.map { screen in CaptureController.log("begin: frozen=\(isFrozenMode) recording=\(recording) usesTap=\(usesTap) active=\(NSApp.isActive)")
OverlayWindow(
screen: screen, if frozen {
onComplete: { [weak self] rect in self?.finishSelection(rect) }, ScreenCapture.shared.snapshotAllDisplays(includeCursor: true) { [weak self] snapshots in
onCancel: { [weak self] in self?.cancelSelection() }, DispatchQueue.main.async {
onWindowPick: { [weak self] rect in self?.finishWindowPick(rect) } guard let self, self.isSelecting else { return }
) self.frozenSnapshots = snapshots
CaptureController.log("frozen: captured \(snapshots.count) displays")
self.showOverlays()
}
} }
self.overlays.forEach { $0.makeKeyAndOrderFront(nil) } } else {
self.overlayIDs = self.overlays.map { $0.windowNumber } showOverlays()
CaptureController.log("begin: \(self.overlays.count) overlays, ids=\(self.overlayIDs), active=\(NSApp.isActive)")
} }
} }
/// A window was picked from hover — capture it with rounded corners. private func showOverlays() {
private func finishWindowPick(_ rect: NSRect) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self, self.isSelecting else { return }
self.overlays = NSScreen.screens.map { screen in
let panel = OverlayWindow(screen: screen, controller: self)
if let view = panel.contentView as? OverlayView {
view.controller = self
view.frozenImage = self.frozenSnapshots.first(where: { $0.frame == screen.frame })?.image
}
return panel
}
// orderFrontRegardless: show without taking key/focus.
self.overlays.forEach { $0.orderFrontRegardless() }
self.overlayIDs = self.overlays.map { $0.windowNumber }
CaptureController.log("begin: \(self.overlays.count) overlays, ids=\(self.overlayIDs) — prior app stays focused")
self.installEventTap()
}
}
/// A window was picked from hover — capture it directly (real rounded
/// corners come from the window's own alpha).
private func finishWindowPick(_ windowID: CGWindowID, _ rect: NSRect, ownedRisk: Bool) {
let ids = overlayIDs let ids = overlayIDs
let recording = isRecordingMode
hideOverlays() hideOverlays()
isSelecting = false isSelecting = false
restorePreviousApp() restorePreviousApp()
if recording {
// Owned-popup risk: record the screen region instead of the
// window filter, which could composite the parent into the video.
RecordingController.shared.begin(
region: rect,
windowID: ownedRisk ? nil : windowID,
excludingWindowIDs: ids
)
return
}
DispatchQueue.main.async { [self] in DispatchQueue.main.async { [self] in
capture(rect, excludingWindowIDs: ids, roundedCorners: true) if ownedRisk {
capture(rect, excludingWindowIDs: ids, roundedCorners: true)
} else {
capture(rect, excludingWindowIDs: ids, windowID: windowID)
}
} }
} }
private func finishSelection(_ rect: NSRect) { private func finishSelection(_ rect: NSRect) {
let ids = overlayIDs let ids = overlayIDs
let frozen = frozenSnapshots
let wasFrozen = isFrozenMode
let recording = isRecordingMode
hideOverlays() hideOverlays()
guard rect.width >= 2, rect.height >= 2 else { guard rect.width >= 2, rect.height >= 2 else {
cancelSelection() cancelSelection()
return return
} }
if wasFrozen {
let midpoint = NSPoint(x: rect.midX, y: rect.midY)
let snapshot = frozen.first(where: { $0.frame.contains(midpoint) }) ?? frozen.first
isSelecting = false
restorePreviousApp()
DispatchQueue.main.async { [self] in
if let snapshot, let image = ScreenCapture.cropFrozen(snapshot, to: rect) {
CaptureController.log("frozen: cropped \(Int(rect.width))x\(Int(rect.height)) -> \(image.width)x\(image.height)")
ResultWindow.shared.show(image: image)
} else {
ResultWindow.shared.show(image: ResultWindow.failureImage("Could not crop the frozen screen."))
}
}
return
}
if recording {
isSelecting = false
restorePreviousApp()
RecordingController.shared.begin(region: rect, windowID: nil, excludingWindowIDs: ids)
return
}
DispatchQueue.main.async { [self] in DispatchQueue.main.async { [self] in
capture(rect, excludingWindowIDs: ids) capture(rect, excludingWindowIDs: ids)
} }
@@ -113,8 +212,15 @@ final class CaptureController: NSObject {
} }
private func hideOverlays() { private func hideOverlays() {
removeEventTap()
overlays.forEach { $0.orderOut(nil) } overlays.forEach { $0.orderOut(nil) }
overlays = [] overlays = []
marqueeRect = nil
hoverWindow = nil
marqueeStart = nil
frozenSnapshots = []
isFrozenMode = false
isRecordingMode = false
} }
private func restorePreviousApp() { private func restorePreviousApp() {
@@ -122,8 +228,226 @@ final class CaptureController: NSObject {
previousApp = nil previousApp = nil
} }
private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int], roundedCorners: Bool = false) { private func renderOverlays() {
ScreenCapture.shared.capture(rect, excludingWindowIDs: ids, roundedCorners: roundedCorners) { [weak self] image in for overlay in overlays {
(overlay.contentView as? OverlayView)?.needsDisplay = true
}
}
// MARK: - Event tap
private func installEventTap() {
guard eventTap == nil else { return }
guard AXIsProcessTrusted() else {
CaptureController.log("tap: accessibility not granted — falling back to panel events")
PermissionManager.requestAccessibility()
return
}
let mask: CGEventMask = (1 << CGEventType.mouseMoved.rawValue)
| (1 << CGEventType.leftMouseDown.rawValue)
| (1 << CGEventType.leftMouseDragged.rawValue)
| (1 << CGEventType.leftMouseUp.rawValue)
| (1 << CGEventType.keyDown.rawValue)
guard let tap = CGEvent.tapCreate(
tap: .cghidEventTap,
place: .headInsertEventTap,
options: .defaultTap,
eventsOfInterest: CGEventMask(mask),
callback: Self.tapCallback,
userInfo: Unmanaged.passUnretained(self).toOpaque()
) else {
CaptureController.log("tap: creation FAILED — falling back to panel events")
return
}
eventTap = tap
if let source = CFMachPortCreateRunLoopSource(nil, tap, 0) {
CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
}
CGEvent.tapEnable(tap: tap, enable: true)
CaptureController.log("tap: installed")
}
private func removeEventTap() {
guard let tap = eventTap else { return }
CGEvent.tapEnable(tap: tap, enable: false)
if let source = CFMachPortCreateRunLoopSource(nil, tap, 0) {
CFRunLoopRemoveSource(CFRunLoopGetMain(), source, .commonModes)
}
CFMachPortInvalidate(tap)
eventTap = nil
CaptureController.log("tap: removed")
}
private static let tapCallback: CGEventTapCallBack = { _, type, event, refcon in
guard let refcon else { return Unmanaged.passUnretained(event) }
let controller = Unmanaged<CaptureController>.fromOpaque(refcon).takeUnretainedValue()
if let result = controller.handleTap(type, event) {
return Unmanaged.passUnretained(result)
}
return nil
}
/// Convert a CG global point (top-left origin) to AppKit global.
private func cgPointToAppKit(_ p: NSPoint) -> NSPoint {
guard let mainScreen = NSScreen.screens.first else { return p }
return NSPoint(x: p.x, y: mainScreen.frame.height - p.y)
}
/// Scan on-screen windows (AppKit global frames), front to back.
private func refreshTrackedWindows() {
guard let list = CGWindowListCopyWindowInfo(
[ .optionOnScreenOnly, .excludeDesktopElements ], kCGNullWindowID
) as? [[String: Any]] else { trackedWindows = []; return }
let ownIDs = Set(overlayIDs)
var found: [(id: CGWindowID, frame: NSRect, pid: Int)] = []
for info in list {
guard let cid = info[kCGWindowNumber as String] as? CGWindowID,
let bounds = info[kCGWindowBounds as String] as? [String: CGFloat],
let layer = info[kCGWindowLayer as String] as? Int,
let pid = info[kCGWindowOwnerPID as String] as? Int,
layer == 0
else { continue }
if ownIDs.contains(Int(cid)) { continue }
let cgRect = NSRect(
x: bounds["X"] ?? 0,
y: bounds["Y"] ?? 0,
width: bounds["Width"] ?? 0,
height: bounds["Height"] ?? 0
)
guard cgRect.width >= 80, cgRect.height >= 60 else { continue }
let rect = Self.cgToAppKit(rect: cgRect)
found.append((id: cid, frame: rect, pid: pid))
}
trackedWindows = found
}
/// Convert a CGWindowList (top-left origin) frame into AppKit screen
/// coordinates (bottom-left origin).
private static func cgToAppKit(rect: NSRect) -> NSRect {
guard let mainScreen = NSScreen.screens.first else { return rect }
let y = mainScreen.frame.height - rect.origin.y - rect.height
return NSRect(x: rect.origin.x, y: y, width: rect.width, height: rect.height)
}
private func topmostWindowContaining(_ appKitPoint: NSPoint) -> (id: CGWindowID, frame: NSRect, pid: Int)? {
for entry in trackedWindows {
if entry.frame.contains(appKitPoint) {
return entry
}
}
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.
func handleTap(_ type: CGEventType, _ event: CGEvent) -> CGEvent? {
guard isSelecting else { return event }
if isSystemDialogFrontmost() {
CaptureController.log("tap: system dialog frontmost — passing through \(type)")
return event
}
switch type {
case .tapDisabledByUserInput, .tapDisabledByTimeout:
// A timed-out tap must be re-enabled; guard against a torn-down tap.
if let tap = eventTap {
CGEvent.tapEnable(tap: tap, enable: true)
CaptureController.log("tap: re-enabled after \(type)")
}
return event
case .keyDown:
if event.getIntegerValueField(.keyboardEventKeycode) == 53 {
CaptureController.log("tap: Esc — cancel")
cancelSelection()
return nil
}
return event
case .mouseMoved, .leftMouseDragged:
// Throttle window scans while moving
if Date().timeIntervalSince(lastWindowScan) > 0.15 {
lastWindowScan = Date()
refreshTrackedWindows()
}
let appKitPoint = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y))
if type == .mouseMoved {
// Frozen mode is marquee-only: no window hover detection.
if isFrozenMode { return event }
let window = topmostWindowContaining(appKitPoint)
if window?.frame != hoverFrame {
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()
}
return event
}
// 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 }
let distance = hypot(appKitPoint.x - start.x, appKitPoint.y - start.y)
guard distance >= 4 else { return nil }
let rect = NSRect(
x: min(start.x, appKitPoint.x),
y: min(start.y, appKitPoint.y),
width: abs(appKitPoint.x - start.x),
height: abs(appKitPoint.y - start.y)
)
marqueeRect = rect
hoverWindow = nil
renderOverlays()
return nil
case .leftMouseDown:
marqueeStart = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y))
marqueeRect = nil
CaptureController.log("tap: mouseDown at \(marqueeStart.map { "\($0)" } ?? "?")")
return nil
case .leftMouseUp:
defer { marqueeStart = nil; marqueeRect = nil }
let appKitPoint = cgPointToAppKit(NSPoint(x: event.location.x, y: event.location.y))
let dragDistance = marqueeStart.map { hypot($0.x - appKitPoint.x, $0.y - appKitPoint.y) } ?? 0
if let rect = marqueeRect, dragDistance >= 4, rect.width >= 2, rect.height >= 2 {
CaptureController.log("tap: marquee complete \(rect)")
finishSelection(rect)
} else if isFrozenMode {
CaptureController.log("tap: frozen click without drag — cancel")
cancelSelection()
} else if let hover = hoverWindow {
// An overlapping window from the same app means this is an
// 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 {
CaptureController.log("tap: click with no hover/marquee — cancel")
cancelSelection()
}
return nil
default:
return event
}
}
// MARK: - Capture
private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int], roundedCorners: Bool = false, windowID: CGWindowID? = nil) {
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()
@@ -132,6 +456,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)
} }
} }
@@ -144,6 +469,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).
@@ -174,29 +534,123 @@ 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
} }
} }
return result return result
} }
/// Screen frame (AppKit global points) + full-resolution image per display.
struct DisplaySnapshot {
let frame: NSRect
let image: CGImage
}
/// Snapshot every display right now. Used by frozen mode: the images are
/// taken before the overlay appears, so the pointer and hover states
/// (tooltips, menus) are baked in.
func snapshotAllDisplays(includeCursor: Bool, completion: @escaping ([DisplaySnapshot]) -> Void) {
if #available(macOS 14.0, *) {
Task {
guard let content = try? await SCShareableContent.current else {
completion([])
return
}
var out: [DisplaySnapshot] = []
for display in content.displays {
guard let screen = NSScreen.screens.first(where: {
(($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0) == display.displayID
}) else { continue }
let config = SCStreamConfiguration()
let scale = screen.backingScaleFactor
config.width = Int(CGFloat(display.width) * scale)
config.height = Int(CGFloat(display.height) * scale)
config.showsCursor = includeCursor
let filter = SCContentFilter(display: display, excludingWindows: [])
if let image = try? await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config) {
out.append(DisplaySnapshot(frame: screen.frame, image: image))
}
}
completion(out)
}
} else {
var out: [DisplaySnapshot] = []
for screen in NSScreen.screens {
let cgRect = CGRect(
x: screen.frame.minX,
y: (NSScreen.screens.first(where: { $0.frame.origin == .zero })?.frame.height ?? screen.frame.height) - screen.frame.maxY,
width: screen.frame.width,
height: screen.frame.height
)
if let image = CGWindowListCreateImage(cgRect, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution, .boundsIgnoreFraming]) {
out.append(DisplaySnapshot(frame: screen.frame, image: image))
}
}
completion(out)
}
}
/// Crop a selection out of a frozen snapshot (top-left pixel space).
static func cropFrozen(_ snapshot: DisplaySnapshot, to rect: NSRect) -> CGImage? {
let scaleX = CGFloat(snapshot.image.width) / snapshot.frame.width
let scaleY = CGFloat(snapshot.image.height) / snapshot.frame.height
guard scaleX > 0, scaleY > 0 else { return nil }
let localX = rect.minX - snapshot.frame.minX
let localTopY = snapshot.frame.maxY - rect.maxY
let px = CGRect(
x: localX * scaleX,
y: localTopY * scaleY,
width: rect.width * scaleX,
height: rect.height * scaleY
)
let safe = px.intersection(CGRect(x: 0, y: 0, width: CGFloat(snapshot.image.width), height: CGFloat(snapshot.image.height))).integral
guard safe.width >= 1, safe.height >= 1 else { return nil }
return snapshot.image.cropping(to: safe)
}
@available(macOS 14.0, *) @available(macOS 14.0, *)
private func displayContaining(_ rect: NSRect, in content: SCShareableContent) -> SCDisplay? { private func displayContaining(_ rect: NSRect, in content: SCShareableContent) -> SCDisplay? {
let mid = NSPoint(x: rect.midX, y: rect.midY) let mid = NSPoint(x: rect.midX, y: rect.midY)
@@ -207,7 +661,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,
@@ -230,7 +684,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,
@@ -242,10 +696,42 @@ 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
} }
CaptureController.log("capture: rect=\(rect) screen=\(screen.frame) display=\(display.frame) backing=\(backingScale) image=\(image.width)x\(image.height) scRect=\(scRect)")
completion(self.crop(image, to: scRect, display: display, roundedCorners: roundedCorners)) completion(self.crop(image, to: scRect, display: display, roundedCorners: roundedCorners))
} }
} else { } else {
+37 -12
View File
@@ -4,11 +4,16 @@ import AppKit
final class HotkeyManager { final class HotkeyManager {
static let shared = HotkeyManager() static let shared = HotkeyManager()
/// ⌘⇧S — live capture (ScreenCaptureKit at capture time).
var onTrigger: (() -> Void)? var onTrigger: (() -> Void)?
/// ⌃⇧⌘S — frozen capture (snapshot at hotkey press).
var onFrozenTrigger: (() -> Void)?
/// ⌃⇧⌘R — screen recording (region or window); toggles stop.
var onRecordTrigger: (() -> Void)?
private var hotKeyRef: EventHotKeyRef? private var hotKeyRefs: [EventHotKeyRef?] = []
private var eventHandlerRef: EventHandlerRef? private var eventHandlerRef: EventHandlerRef?
private var hotKeyID = EventHotKeyID(signature: 0x4B505452, id: 1) private let signature: OSType = 0x4B505452
private init() {} private init() {}
@@ -17,18 +22,38 @@ final class HotkeyManager {
eventClass: OSType(kEventClassKeyboard), eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed) eventKind: UInt32(kEventHotKeyPressed)
) )
InstallEventHandler(GetApplicationEventTarget(), { _, _, _ in InstallEventHandler(GetApplicationEventTarget(), { _, event, _ in
HotkeyManager.shared.onTrigger?() var hotKeyID = EventHotKeyID()
let status = GetEventParameter(
event,
EventParamName(kEventParamDirectObject),
EventParamType(typeEventHotKeyID),
nil,
MemoryLayout<EventHotKeyID>.size,
nil,
&hotKeyID
)
guard status == noErr else { return noErr }
DispatchQueue.main.async {
switch hotKeyID.id {
case 1: HotkeyManager.shared.onTrigger?()
case 2: HotkeyManager.shared.onFrozenTrigger?()
case 3: HotkeyManager.shared.onRecordTrigger?()
default: break
}
}
return noErr return noErr
}, 1, &eventType, nil, &eventHandlerRef) }, 1, &eventType, nil, &eventHandlerRef)
RegisterEventHotKey( registerHotkey(id: 1, keyCode: UInt32(kVK_ANSI_S), modifiers: UInt32(cmdKey | shiftKey))
UInt32(kVK_ANSI_S), registerHotkey(id: 2, keyCode: UInt32(kVK_ANSI_S), modifiers: UInt32(cmdKey | shiftKey | controlKey))
UInt32(cmdKey | shiftKey), registerHotkey(id: 3, keyCode: UInt32(kVK_ANSI_R), modifiers: UInt32(cmdKey | shiftKey | controlKey))
hotKeyID, }
GetApplicationEventTarget(),
0, private func registerHotkey(id: UInt32, keyCode: UInt32, modifiers: UInt32) {
&hotKeyRef var ref: EventHotKeyRef?
) let hotKeyID = EventHotKeyID(signature: signature, id: id)
RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &ref)
hotKeyRefs.append(ref)
} }
} }
+58 -21
View File
@@ -8,29 +8,66 @@ final class OCRService {
func recognize(_ cgImage: CGImage, completion: @escaping (String) -> Void) { func recognize(_ cgImage: CGImage, completion: @escaping (String) -> Void) {
queue.async { queue.async {
let request = VNRecognizeTextRequest() let text = Self.perform(cgImage)
request.recognitionLevel = .accurate // Tiny text (small UI labels) often reads as nothing at native
request.usesLanguageCorrection = true // size; retry upscaled and keep whichever pass read more.
request.recognitionLanguages = ["en-US", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR"] guard text.count < 3 else {
completion(text)
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
completion("")
return return
} }
var best = text
let observations = request.results ?? [] for factor in [CGFloat(2), CGFloat(3)] {
let lines = observations guard let upscaled = Self.upscaled(cgImage, factor: factor) else { continue }
.sorted { a, b in let candidate = Self.perform(upscaled)
let ay = a.boundingBox.midY if candidate.count > best.count { best = candidate }
let by = b.boundingBox.midY if best.count >= 3 { break }
if abs(ay - by) > 0.02 { return ay > by } }
return a.boundingBox.minX < b.boundingBox.minX completion(best)
}
.compactMap { $0.topCandidates(1).first?.string }
completion(lines.joined(separator: "\n"))
} }
} }
private static func perform(_ cgImage: CGImage) -> String {
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
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: [:])
do {
try handler.perform([request])
} catch {
return ""
}
let observations = request.results ?? []
let lines = observations
.sorted { a, b in
let ay = a.boundingBox.midY
let by = b.boundingBox.midY
if abs(ay - by) > 0.02 { return ay > by }
return a.boundingBox.minX < b.boundingBox.minX
}
.compactMap { $0.topCandidates(1).first?.string }
return lines.joined(separator: "\n")
}
private static func upscaled(_ cgImage: CGImage, factor: CGFloat) -> CGImage? {
let width = Int(CGFloat(cgImage.width) * factor)
let height = Int(CGFloat(cgImage.height) * factor)
guard width > 0, height > 0, width <= 20000, height <= 20000 else { return nil }
guard let ctx = CGContext(
data: nil, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
ctx.interpolationQuality = .high
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)))
return ctx.makeImage()
}
} }
+117 -173
View File
@@ -1,17 +1,23 @@
import AppKit import AppKit
/// Renders the capture overlay. All interaction state lives in
/// CaptureController (driven by the CGEventTap); this view only draws.
final class OverlayWindow: NSPanel { final class OverlayWindow: NSPanel {
init(screen: NSScreen, onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void, onWindowPick: @escaping (NSRect) -> Void) { init(screen: NSScreen, controller: CaptureController) {
let content = OverlayView(onComplete: onComplete, onCancel: onCancel, onWindowPick: onWindowPick) 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, .nonactivatingPanel], styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered, backing: .buffered,
defer: false defer: false
) )
contentView = content contentView = content
content.controller = controller
level = .screenSaver level = .screenSaver
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .transient] collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
backgroundColor = .clear backgroundColor = .clear
isOpaque = false isOpaque = false
hasShadow = false hasShadow = false
@@ -23,29 +29,10 @@ final class OverlayWindow: NSPanel {
} }
final class OverlayView: NSView { final class OverlayView: NSView {
private let onComplete: (NSRect) -> Void weak var controller: CaptureController?
private let onCancel: () -> Void /// In frozen mode: the still image to display for this screen (already
private let onWindowPick: (NSRect) -> Void /// full resolution). Nil = live mode (dim the real screen).
var frozenImage: CGImage?
private var startPoint: NSPoint?
private(set) var selection: NSRect?
private var isDragging = false
private var dragDistance: CGFloat = 0
// Window detection (frames stored in AppKit global space)
private var trackedWindows: [(id: CGWindowID, frame: NSRect)] = []
private var hoveredWindowFrame: NSRect?
init(onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void, onWindowPick: @escaping (NSRect) -> Void) {
self.onComplete = onComplete
self.onCancel = onCancel
self.onWindowPick = onWindowPick
super.init(frame: .zero)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var acceptsFirstResponder: Bool { true } override var acceptsFirstResponder: Bool { true }
@@ -55,178 +42,135 @@ final class OverlayView: NSView {
addCursorRect(bounds, cursor: .crosshair) addCursorRect(bounds, cursor: .crosshair)
} }
// MARK: - Window detection /// The screen frame this overlay covers, in AppKit global space.
private var screenFrame: NSRect {
/// Rebuild the list of capturable windows intersecting this screen. guard let window else { return .zero }
/// Called lazily on mouse moves; cheap enough for interactive hover. return window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
private func refreshTrackedWindows() {
guard let window = window else { return }
let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
// Exclude this overlay's own window (the other overlays don't intersect
// this screen, so the on-screen filter handles them).
let ownID = window.windowNumber
guard let list = CGWindowListCopyWindowInfo(
[ .optionOnScreenOnly, .excludeDesktopElements ], kCGNullWindowID
) as? [[String: Any]] else { trackedWindows = []; return }
var found: [(CGWindowID, NSRect)] = []
for info in list {
guard let cid = info[kCGWindowNumber as String] as? CGWindowID,
let bounds = info[kCGWindowBounds as String] as? [String: CGFloat],
let layer = info[kCGWindowLayer as String] as? Int,
layer == 0
else { continue }
// Skip our own overlays
if Int(cid) == ownID { continue }
let cgRect = NSRect(
x: bounds["X"] ?? 0,
y: bounds["Y"] ?? 0,
width: bounds["Width"] ?? 0,
height: bounds["Height"] ?? 0
)
guard cgRect.width >= 80, cgRect.height >= 60 else { continue }
// Convert to AppKit space FIRST — CG and AppKit Y axes disagree on
// multi-desktop layouts, so intersect tests must use one space.
let rect = Self.cgToAppKit(rect: cgRect)
// Skip windows filling the whole screen (fullscreen apps)
guard rect.width < screenFrame.width || rect.height < screenFrame.height else { continue }
// Only windows intersecting this screen
guard rect.intersects(screenFrame) else { continue }
found.append((cid, rect))
}
trackedWindows = found
} }
private func windowFrame(at point: NSPoint) -> NSRect? { /// Convert an AppKit-global rect into this view's coordinates.
guard let window = window else { return nil } private func viewRect(_ global: NSRect) -> NSRect {
// convertToScreen gives AppKit-global coordinates; tracked frames are let sf = screenFrame
// already in that space. return NSRect(
let screenPoint = window.convertToScreen(NSRect(origin: point, size: .zero)).origin x: global.origin.x - sf.origin.x,
for (_, frame) in trackedWindows { y: global.origin.y - sf.origin.y,
if frame.contains(screenPoint) { width: global.width,
return frame height: global.height
}
}
return nil
}
/// Convert a CGWindowList (top-left origin, CG global space) frame into
/// AppKit screen coordinates (bottom-left origin) for capture.
private static func cgToAppKit(rect: NSRect) -> NSRect {
guard let mainScreen = NSScreen.screens.first else { return rect }
let y = mainScreen.frame.height - rect.origin.y - rect.height
return NSRect(x: rect.origin.x, y: y, width: rect.width, height: rect.height)
}
// MARK: - Events
override func mouseMoved(with event: NSEvent) {
guard !isDragging else { return }
refreshTrackedWindows()
let p = convert(event.locationInWindow, from: nil)
let frame = windowFrame(at: p)
if frame != hoveredWindowFrame {
hoveredWindowFrame = frame
needsDisplay = true
}
}
override func mouseDown(with event: NSEvent) {
let p = convert(event.locationInWindow, from: nil)
startPoint = p
dragDistance = 0
selection = NSRect(origin: p, size: .zero)
needsDisplay = true
}
override func mouseDragged(with event: NSEvent) {
guard let start = startPoint else { return }
let p = convert(event.locationInWindow, from: nil)
dragDistance = hypot(p.x - start.x, p.y - start.y)
// Only become a marquee after real movement; micro-jitter from a
// click must not cancel window-pick mode.
guard dragDistance >= 4 else { return }
isDragging = true
hoveredWindowFrame = nil
selection = NSRect(
x: min(start.x, p.x),
y: min(start.y, p.y),
width: abs(p.x - start.x),
height: abs(p.y - start.y)
) )
needsDisplay = true
} }
override func mouseUp(with event: NSEvent) { /// Draw the frozen snapshot filling the screen, then replace its centre
defer { /// hole with the live... no: the image IS the content, drawn opaque.
isDragging = false private func drawFrozen(_ image: CGImage) {
dragDistance = 0 guard let ctx = NSGraphicsContext.current?.cgContext else { return }
ctx.saveGState()
ctx.interpolationQuality = .high
// Image is in pixels, view in points — draw into the view bounds.
ctx.draw(image, in: bounds)
ctx.restoreGState()
}
private func drawSizeLabel(for sel: NSRect, in vf: NSRect) {
let label = "\(Int(sel.width)) × \(Int(sel.height))"
let attrs: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .medium),
.foregroundColor: NSColor.white,
]
let labelSize = label.size(withAttributes: attrs)
let labelRect = NSRect(
x: vf.maxX - labelSize.width - 8,
y: vf.minY - labelSize.height - 10,
width: labelSize.width + 8,
height: labelSize.height + 6
)
NSColor.black.withAlphaComponent(0.6).setFill()
NSBezierPath(roundedRect: labelRect, xRadius: 4, yRadius: 4).fill()
label.draw(at: NSPoint(x: labelRect.minX + 4, y: labelRect.minY + 3), withAttributes: attrs)
}
override func draw(_ dirtyRect: NSRect) {
if let frozenImage {
drawFrozen(frozenImage)
} else {
NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill()
} }
if isDragging {
guard let sel = selection else { return } // Esc hint — the only way out once clicks are consumed by the tap.
if sel.width >= 2, sel.height >= 2 { let hint: String
onComplete(window!.convertToScreen(sel)) if controller?.isRecordingMode == true {
} else { hint = "Drag to select · hover + click a window to record · Esc to cancel"
onCancel() } else if frozenImage != nil {
hint = "Frozen screen · drag to select · Esc to cancel"
} else {
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 }
// Frozen mode: same look as live mode — a dark veil over everything,
// with the selection showing the frozen pixels undimmed.
if let frozenImage {
NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill()
if let sel = controller.marqueeRect, sel.width > 0, sel.height > 0 {
let vf = viewRect(sel)
NSGraphicsContext.current?.saveGraphicsState()
NSBezierPath(rect: vf).addClip()
drawFrozen(frozenImage)
NSGraphicsContext.current?.restoreGraphicsState()
let border = NSBezierPath(rect: vf)
border.lineWidth = 1.5
NSColor.controlAccentColor.setStroke()
border.stroke()
drawSizeLabel(for: sel, in: vf)
} }
return return
} }
// It was a click. If a window is hovered, capture it with rounded // Highlight the hovered window (rounded, like real window corners)
// corners; otherwise treat as an empty marquee (cancel). if let frame = controller.hoverFrame {
if let frame = hoveredWindowFrame { let vf = viewRect(frame)
onWindowPick(frame)
} else {
onCancel()
}
}
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 {
onCancel()
} else {
super.keyDown(with: event)
}
}
override func draw(_ dirtyRect: NSRect) {
NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill()
// Highlight the hovered window (rounded, like the real window corners)
if let frame = hoveredWindowFrame {
// Tracked frames are AppKit-global; view space = global - window origin.
guard let window = window else { return }
let screenFrame = window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
let viewFrame = NSRect(
x: frame.origin.x - screenFrame.origin.x,
y: frame.origin.y - screenFrame.origin.y,
width: frame.width,
height: frame.height
)
NSGraphicsContext.current?.saveGraphicsState() NSGraphicsContext.current?.saveGraphicsState()
NSGraphicsContext.current?.compositingOperation = .clear NSGraphicsContext.current?.compositingOperation = .clear
NSColor.black.setFill() NSColor.black.setFill()
NSBezierPath(roundedRect: viewFrame, xRadius: 10, yRadius: 10).fill() NSBezierPath(roundedRect: vf, xRadius: 10, yRadius: 10).fill()
NSGraphicsContext.current?.restoreGraphicsState() NSGraphicsContext.current?.restoreGraphicsState()
let border = NSBezierPath(roundedRect: viewFrame, xRadius: 10, yRadius: 10) let border = NSBezierPath(roundedRect: vf, xRadius: 10, yRadius: 10)
border.lineWidth = 2 border.lineWidth = 2
NSColor.controlAccentColor.setStroke() NSColor.controlAccentColor.setStroke()
border.stroke() border.stroke()
return return
} }
guard let sel = selection, sel.width > 0, sel.height > 0 else { return } // Marquee selection
guard let sel = controller.marqueeRect, sel.width > 0, sel.height > 0 else { return }
let vf = viewRect(sel)
NSGraphicsContext.current?.saveGraphicsState() NSGraphicsContext.current?.saveGraphicsState()
NSGraphicsContext.current?.compositingOperation = .clear NSGraphicsContext.current?.compositingOperation = .clear
NSColor.black.setFill() NSColor.black.setFill()
NSBezierPath(rect: sel).fill() NSBezierPath(rect: vf).fill()
NSGraphicsContext.current?.restoreGraphicsState() NSGraphicsContext.current?.restoreGraphicsState()
let border = NSBezierPath(rect: sel) let border = NSBezierPath(rect: vf)
border.lineWidth = 1.5 border.lineWidth = 1.5
NSColor.controlAccentColor.setStroke() NSColor.controlAccentColor.setStroke()
border.stroke() border.stroke()
@@ -238,8 +182,8 @@ final class OverlayView: NSView {
] ]
let labelSize = label.size(withAttributes: attrs) let labelSize = label.size(withAttributes: attrs)
let labelRect = NSRect( let labelRect = NSRect(
x: sel.maxX - labelSize.width - 8, x: vf.maxX - labelSize.width - 8,
y: sel.minY - labelSize.height - 10, y: vf.minY - labelSize.height - 10,
width: labelSize.width + 8, width: labelSize.width + 8,
height: labelSize.height + 6 height: labelSize.height + 6
) )
+181
View File
@@ -0,0 +1,181 @@
import AppKit
import SwiftUI
/// Shared recording state, observed by the menu bar menu and control bar.
final class RecordingState: ObservableObject {
@Published var isRecording = false
@Published var elapsed: TimeInterval = 0
}
/// Owns a recording session: the red border around the recorded region and
/// the floating timer/stop bar. Both chrome windows are excluded from the
/// video. On stop, the file is auto-saved to ~/Movies and revealed in Finder.
final class RecordingController {
static let shared = RecordingController()
let state = RecordingState()
private var recorder: ScreenRecorder?
private var chromeWindows: [NSWindow] = []
private var timer: Timer?
/// `region` is in AppKit global points; `windowID` records that window
/// instead of the region. Called by CaptureController after the
/// selection overlays are hidden.
func begin(region: NSRect, windowID: CGWindowID?, excludingWindowIDs: [Int]) {
guard !state.isRecording else { return }
showChrome(region: region)
let excluded = excludingWindowIDs + chromeWindows.map { $0.windowNumber }
state.isRecording = true
let recorder = ScreenRecorder()
self.recorder = recorder
// Give the window server a beat so the chrome shows up in the
// shareable-content snapshot and gets excluded from the video.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
guard let self, self.recorder === recorder else { return }
recorder.start(region: region, windowID: windowID, excludingWindowIDs: excluded) { [weak self] ok in
DispatchQueue.main.async {
guard let self else { return }
if ok {
self.startTimer()
} else {
self.finish(url: nil)
ResultWindow.shared.show(image: ResultWindow.failureImage("Could not start the recording. Check Screen Recording permission."))
}
}
}
}
}
func stop() {
guard state.isRecording else { return }
state.isRecording = false
timer?.invalidate()
timer = nil
recorder?.stop { [weak self] url in
DispatchQueue.main.async { self?.finish(url: url) }
}
}
/// Best-effort finalize on quit so the .mov isn't left truncated.
/// Blocks briefly — only call from applicationWillTerminate.
func finalizeForTermination() {
guard let recorder else { return }
let semaphore = DispatchSemaphore(value: 0)
recorder.stop { _ in semaphore.signal() }
_ = semaphore.wait(timeout: .now() + 3)
}
private func finish(url: URL?) {
state.isRecording = false
hideChrome()
recorder = nil
state.elapsed = 0
if let url {
CaptureController.log("rec: saved \(url.path)")
NSWorkspace.shared.activateFileViewerSelecting([url])
}
}
private func startTimer() {
let started = Date()
state.elapsed = 0
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
self?.state.elapsed = Date().timeIntervalSince(started)
}
}
// MARK: - Chrome
private func showChrome(region: NSRect) {
// Red border around the recorded region.
let border = NSPanel(
contentRect: region,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
border.level = .screenSaver
border.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
border.backgroundColor = .clear
border.isOpaque = false
border.hasShadow = false
border.ignoresMouseEvents = true
border.contentView = RecordingBorderView()
border.orderFrontRegardless()
// Floating timer/stop bar at the bottom centre of the region.
let bar = NSPanel(
contentRect: .zero,
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
bar.level = .screenSaver
bar.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
bar.backgroundColor = .clear
bar.isOpaque = false
bar.hasShadow = false
let hosting = NSHostingView(rootView: RecordingBarView(state: state) { [weak self] in
self?.stop()
})
let size = hosting.fittingSize
bar.setContentSize(size)
bar.setFrameOrigin(NSPoint(x: region.midX - size.width / 2, y: region.minY + 14))
bar.contentView = hosting
bar.orderFrontRegardless()
chromeWindows = [border, bar]
}
private func hideChrome() {
chromeWindows.forEach { $0.orderOut(nil) }
chromeWindows = []
}
}
/// Draws the red rounded border around the recorded region.
private final class RecordingBorderView: NSView {
override func draw(_ dirtyRect: NSRect) {
let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 2, dy: 2), xRadius: 8, yRadius: 8)
NSColor.systemRed.setStroke()
path.lineWidth = 3
path.stroke()
}
}
/// The floating capsule bar: red dot, elapsed time, stop button.
struct RecordingBarView: View {
@ObservedObject var state: RecordingState
let onStop: () -> Void
private var timeString: String {
let total = Int(state.elapsed)
return String(format: "%02d:%02d", total / 60, total % 60)
}
var body: some View {
HStack(spacing: 10) {
Circle()
.fill(Color.red)
.frame(width: 8, height: 8)
Text(timeString)
.font(.system(size: 12, weight: .medium).monospacedDigit())
.foregroundStyle(.white)
Button(action: onStop) {
Image(systemName: "stop.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.white)
.frame(width: 22, height: 22)
.background(Circle().fill(Color.red))
.contentShape(Circle())
}
.buttonStyle(.plain)
.help("Stop recording (⌃⇧⌘R)")
}
.padding(.horizontal, 12)
.padding(.vertical, 7)
.background(Capsule().fill(Color.black.opacity(0.78)))
}
}
File diff suppressed because it is too large Load Diff
+203
View File
@@ -0,0 +1,203 @@
import AppKit
import AVFoundation
import CoreMedia
import ScreenCaptureKit
/// Records a screen region (or a single window) to an H.264 .mov via
/// SCStream -> AVAssetWriter. Works on macOS 13+ (no SCScreenshotManager).
///
/// Video-only for now: the audio path is wired behind `includeAudio`
/// (off) — enabling sound later means setting the flag to true, which
/// turns on `capturesAudio`, adds an AAC track, and starts appending
/// audio sample buffers. That path is untested until enabled.
final class ScreenRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
/// Future: also record system audio into the .mov.
var includeAudio = false
private let queue = DispatchQueue(label: "dev.tzgyn.kapture.rec")
private var stream: SCStream?
private var writer: AVAssetWriter?
private var videoInput: AVAssetWriterInput?
private var audioInput: AVAssetWriterInput?
private var sessionStarted = false
private var outputURL: URL?
/// `region` is in AppKit global points. When `windowID` is given the
/// stream records that window directly (and follows it if it moves);
/// otherwise it records `region` via a display filter + sourceRect.
func start(region: NSRect, windowID: CGWindowID?, excludingWindowIDs: [Int], completion: @escaping (Bool) -> Void) {
Task {
do {
try await prepare(region: region, windowID: windowID, excludingWindowIDs: excludingWindowIDs)
completion(true)
} catch {
CaptureController.log("rec: start failed: \(error.localizedDescription)")
completion(false)
}
}
}
private func prepare(region: NSRect, windowID: CGWindowID?, excludingWindowIDs: [Int]) async throws {
let content = try await SCShareableContent.current
let excludedSet = Set(excludingWindowIDs.map { CGWindowID($0) })
let excluded = content.windows.filter { excludedSet.contains($0.windowID) }
let config = SCStreamConfiguration()
let filter: SCContentFilter
let pixelWidth: Int
let pixelHeight: Int
let regionCentre = NSPoint(x: region.midX, y: region.midY)
let screen = NSScreen.screens.first(where: { $0.frame.contains(regionCentre) }) ?? NSScreen.screens.first
let scale = screen?.backingScaleFactor ?? 2
if let windowID, let scWindow = content.windows.first(where: { $0.windowID == windowID }) {
filter = SCContentFilter(desktopIndependentWindow: scWindow)
pixelWidth = max(2, Int(scWindow.frame.width * scale) / 2 * 2)
pixelHeight = max(2, Int(scWindow.frame.height * scale) / 2 * 2)
CaptureController.log("rec: window id=\(windowID) \(pixelWidth)x\(pixelHeight)")
} else {
guard let screen else {
throw NSError(domain: "dev.tzgyn.kapture", code: 1, userInfo: [NSLocalizedDescriptionKey: "No screen for region"])
}
let cgID = (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
guard let display = content.displays.first(where: { $0.displayID == cgID }) ?? content.displays.first else {
throw NSError(domain: "dev.tzgyn.kapture", code: 2, userInfo: [NSLocalizedDescriptionKey: "No display for region"])
}
filter = SCContentFilter(display: display, excludingWindows: excluded)
// sourceRect is display-local POINTS with a TOP-LEFT origin
// (same mapping cropFrozen uses for the frozen snapshots).
let local = CGRect(
x: region.minX - screen.frame.minX,
y: screen.frame.maxY - region.maxY,
width: region.width,
height: region.height
).intersection(CGRect(x: 0, y: 0, width: display.frame.width, height: display.frame.height))
guard local.width >= 2, local.height >= 2 else {
throw NSError(domain: "dev.tzgyn.kapture", code: 3, userInfo: [NSLocalizedDescriptionKey: "Region outside display"])
}
config.sourceRect = local
pixelWidth = max(2, Int(local.width * scale) / 2 * 2)
pixelHeight = max(2, Int(local.height * scale) / 2 * 2)
CaptureController.log("rec: region \(region) sourceRect=\(local) \(pixelWidth)x\(pixelHeight)")
}
config.width = pixelWidth
config.height = pixelHeight
config.minimumFrameInterval = CMTime(value: 1, timescale: 60)
config.pixelFormat = kCVPixelFormatType_32BGRA
// Recordings SHOULD include the pointer (unlike stills).
config.showsCursor = true
config.queueDepth = 6
config.capturesAudio = includeAudio
let movies = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory())
let url = movies.appendingPathComponent("Kapture-\(Self.timestamp()).mov")
let writer = try AVAssetWriter(url: url, fileType: .mov)
let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: pixelWidth,
AVVideoHeightKey: pixelHeight,
])
videoInput.expectsMediaDataInRealTime = true
writer.add(videoInput)
var audioInput: AVAssetWriterInput?
if includeAudio {
let input = AVAssetWriterInput(mediaType: .audio, outputSettings: [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 48000,
AVNumberOfChannelsKey: 2,
])
input.expectsMediaDataInRealTime = true
writer.add(input)
audioInput = input
}
guard writer.startWriting() else {
throw writer.error ?? NSError(domain: "dev.tzgyn.kapture", code: 4, userInfo: [NSLocalizedDescriptionKey: "AVAssetWriter failed to start"])
}
let stream = SCStream(filter: filter, configuration: config, delegate: self)
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: queue)
if includeAudio {
try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: queue)
}
try await stream.startCapture()
self.stream = stream
self.writer = writer
self.videoInput = videoInput
self.audioInput = audioInput
self.outputURL = url
CaptureController.log("rec: started -> \(url.lastPathComponent)")
}
/// Stops the stream and finalizes the file. Completion gets the URL on
/// success (called on an internal queue — hop to main for UI).
func stop(completion: @escaping (URL?) -> Void) {
Task {
try? await stream?.stopCapture()
stream = nil
queue.async {
guard let writer = self.writer else {
completion(nil)
return
}
self.videoInput?.markAsFinished()
self.audioInput?.markAsFinished()
let url = self.outputURL
self.writer = nil
self.videoInput = nil
self.audioInput = nil
self.sessionStarted = false
writer.finishWriting {
CaptureController.log("rec: finished status=\(writer.status.rawValue)")
completion(writer.status == .completed ? url : nil)
}
}
}
}
// MARK: SCStreamOutput (called on `queue`)
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
guard CMSampleBufferDataIsReady(sampleBuffer), let writer, let videoInput else { return }
switch type {
case .screen:
// Skip incomplete frames (macOS 13.1+ exposes a frame status).
if #available(macOS 13.1, *) {
guard let attachments = (CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [NSDictionary])?.first,
let dict = attachments as? [String: Any],
let statusRaw = dict[SCStreamFrameInfo.status.rawValue] as? Int,
SCFrameStatus(rawValue: statusRaw) == .complete else { return }
}
guard videoInput.isReadyForMoreMediaData else { return }
if !sessionStarted {
sessionStarted = true
writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(sampleBuffer))
}
videoInput.append(sampleBuffer)
case .audio:
guard includeAudio, sessionStarted, let audioInput, audioInput.isReadyForMoreMediaData else { return }
audioInput.append(sampleBuffer)
default:
break
}
}
// MARK: SCStreamDelegate
func stream(_ stream: SCStream, didStopWithError error: Error) {
CaptureController.log("rec: stream stopped with error: \(error.localizedDescription)")
}
private static func timestamp() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd-HHmmss"
return formatter.string(from: Date())
}
}
+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.6.5</string> <string>1.11.3</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>65</string> <string>113</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>