Compare commits

...
36 Commits
Author SHA1 Message Date
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
TZGyn a7cad2fc2f Terminate stale Kapture instances at launch; log topmost windows
A stale copy surviving an update keeps its own hotkey and overlays on
screen, silently eating every click meant for the new instance (matching
the empty session logs). New copies now force-terminate older instances,
and the first window scan logs the topmost on-screen windows so anything
covering the overlays is visible in diagnostics.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 11:59:03 +08:00
TZGyn 69e1a7fccb Show overlays only after activation completes
The local probe confirmed activation is asynchronous on macOS 26:
isActive flips on a later runloop tick, so overlays shown in the same
tick as activate() belong to a still-inactive app and misses clicks.
Delay overlay creation by a tick and log the activation state at show
time.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 11:51:13 +08:00
TZGyn e22bcb99c4 Make overlay panels activating so clicks are received
The session log showed active=false after every activation attempt on
macOS 26: cooperative activation is denied for LSUIElement apps, and an
inactive app's panels let clicks fall through to the windows below.
Remove .nonactivatingPanel so showing the overlay activates the app
(previous app focus is restored after capture as before).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 11:46:45 +08:00
TZGyn 457e303152 Force app activation on capture; log to /tmp for durable diagnostics
Cooperative NSApp.activate() can silently fail for LSUIElement apps,
leaving the app inactive so overlay clicks pass through to windows below.
Force activation with ignoringOtherApps on all OS versions. Session logs
now also append to /tmp/kapture-debug.log so they survive relaunches, and
the first window-scan result is always logged.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 11:00:44 +08:00
TZGyn 345f306edf Add session event logging to capture flow for remote debugging
OverlayView now logs window tracking counts, hover changes, and
mouseDown/Up branch decisions into a session log that Copy Diagnostics
includes along with the app version, so capture failures on machines we
cannot access directly can be diagnosed from a pasted report.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:52:16 +08:00
TZGyn 45e4962ec3 Fix multi-display window detection and click-to-capture; separate Redact tool
Window frames are now converted to AppKit space once at detection time, so
hover highlighting and picking work on every display. A click is only
treated as a drag after 4pt of movement, so clicking a detected window
captures it instead of passing a stray marquee. Redact is now a standalone
tool (Select/Draw/Redact) with its own toolbar hints instead of a toggle
inside the draw bar.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:46:32 +08:00
TZGyn 5f137f89bf Fix window hover detection; add redaction tool
Window detection was broken by wrong CG-to-AppKit coordinate conversion
of the cursor and highlight rects — both now go through the same
cgToAppKit mapping. The draw toolbar gains a Pen/Redact toggle: redact
drags cover content with a solid black bar (participates in undo/redo
and export like pen strokes).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:39:26 +08:00
TZGyn b998ea210e Document brew trust step for the Gitea tap 2026-09-09 10:36:20 +08:00
TZGyn 86826f7ecc Point Homebrew install at the Gitea-hosted tap
brew tap without a URL resolves to GitHub, where the tap no longer exists;
the full Gitea URL is required.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:32:47 +08:00
TZGyn 969cda6f14 Add window detection capture with rounded corners
Hovering during selection now highlights the window under the cursor;
clicking captures it with a rounded-corner alpha mask matching macOS
window chrome. Dragging still switches to manual marquee selection.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-09-09 10:29:24 +08:00
TZGyn de642ad692 Fix flipped drawing when copying/saving annotated images
The strokes were stored in top-left image coordinates but drawn into an
AppKit CGContext with a bottom-left origin, so exports came out mirrored
and offset. Flip Y in the context before stroking so annotations land
where they were drawn.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-24 09:08:37 +08:00
TZGyn 8b27263962 Make stroke thickness per-stroke; restore Clear button
Each drawn stroke now stores its own width and color, so changing the
thickness slider no longer retroactively resizes existing strokes. The
Clear button is back in the draw toolbar.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 14:00:00 +08:00
TZGyn 1961d0dc9c Move draw controls into the draw toolbar
The color picker, undo/redo, and clear eraser now live in the dedicated
draw top bar instead of the main toolbar, keeping the primary actions
(Copy/Save/Get Text) uncluttered. Keyboard shortcuts (⌘Z/⌘⇧Z) remain.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:55:00 +08:00
TZGyn 41cb06dcb5 Add draw toolbar with thickness, undo/redo, and color picker
When drawing is enabled a dedicated top bar shows a thickness slider,
color picker, and undo/redo buttons (⌘Z / ⌘⇧Z). Strokes render and export
with the chosen width and color.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:39:06 +08:00
TZGyn 52e36b5c5a Add drawing color picker to the result window
A compact ColorPicker appears next to the Draw button when drawing is
enabled; strokes render and export in the chosen color (defaults to
system red).

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:33:32 +08:00
TZGyn a74a86d001 Fix vertical offset in ScreenCaptureKit crops
The captured CGImage has a top-left origin, while the crop rect was
computed in SCDisplay space (bottom-left). Flip Y within the display's
bounds when converting to pixel coordinates so the crop lands exactly
where the user selected.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:27:41 +08:00
TZGyn f50057d951 Fix screenshot offset after pixel-exact crop change
The new CGImage.cropping path expects a top-left pixel rect; the previous
CGContext redraw had implicitly flipped Y. Keep the existing SCDisplay-space
conversion so the crop matches the selected region.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:23:37 +08:00
TZGyn 16b876807d Add freehand drawing and fix residual capture pixelation
The screenshot window now supports toggling a pen to draw annotations
that are flattened into copied and saved images. Captures are also
cropped with a pixel-exact CGImage.cropping instead of a CGContext
redraw, removing residual blur on retina displays.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-23 13:21:22 +08:00
TZGyn 6a2c8df180 Modernize the result window UI
Restyles the window with a cleaner layout: icon-based toolbar buttons
(Copy/Save/Get All Text, highlighted OCR Selection action), a bottom status
bar with progress indicator and contextual hints, monospaced text tab, and
a divider separating chrome from content.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-13 09:40:51 +08:00
TZGyn c9447d2a8b Make Kapture screenshot-first with marquee text selection
The result window now works like a screenshot tool: drag on the image to
select a region, then click Get Selection Text to OCR only that area
(full-image OCR stays as Get All Text). Selection is rendered in image
coordinates so it stays accurate at any window size.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
2026-08-12 21:06:09 +08:00
7 changed files with 1010 additions and 157 deletions
+5 -2
View File
@@ -25,11 +25,14 @@ No network calls, no API keys — everything runs locally.
## Install with Homebrew ## Install with Homebrew
```bash ```bash
brew tap TZGyn/tap brew tap TZGyn/tap https://gitea.tzgyn.dev/TZGyn/homebrew-tap.git
brew trust TZGyn/tap # Homebrew 6+ requires trusting third-party taps brew trust TZGyn/tap
brew install --cask kapture brew install --cask kapture
``` ```
The tap is hosted on Gitea, so the full URL is required. Homebrew may print
an "Unsupported tap" warning — it's harmless.
Releases are published as zips on GitHub Releases; `release.sh` packages a Releases are published as zips on GitHub Releases; `release.sh` packages a
new one (`./release.sh 1.0.1 && git tag v1.0.1 && git push origin v1.0.1`). new one (`./release.sh 1.0.1 && git tag v1.0.1 && git push origin v1.0.1`).
The cask lives in the [TZGyn/homebrew-tap](https://github.com/TZGyn/homebrew-tap) The cask lives in the [TZGyn/homebrew-tap](https://github.com/TZGyn/homebrew-tap)
+45 -13
View File
@@ -21,6 +21,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
runOCRSelfTest() runOCRSelfTest()
return 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.onTrigger = { CaptureController.shared.begin() }
HotkeyManager.shared.register() HotkeyManager.shared.register()
} }
@@ -79,6 +90,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 {
@@ -101,33 +119,47 @@ enum LoginItem {
} }
} }
func runOCRSelfTest() { func renderTestImage(_ text: String, fontSize: CGFloat = 56) -> CGImage? {
let text = "Kapture OCR test 1234, hello world" let size = NSSize(width: 1900, height: 220)
let size = NSSize(width: 1600, height: 220)
guard let rep = NSBitmapImageRep( guard let rep = NSBitmapImageRep(
bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height), bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height),
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0 colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
) else { exit(1) } ) else { return nil }
NSGraphicsContext.saveGraphicsState() NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
NSColor.white.setFill() NSColor.white.setFill()
NSRect(origin: .zero, size: size).fill() NSRect(origin: .zero, size: size).fill()
let attrs: [NSAttributedString.Key: Any] = [ let attrs: [NSAttributedString.Key: Any] = [
.font: NSFont.boldSystemFont(ofSize: 56), .font: NSFont.systemFont(ofSize: fontSize),
.foregroundColor: NSColor.black, .foregroundColor: NSColor.black,
] ]
text.draw(at: NSPoint(x: 60, y: 75), withAttributes: attrs) text.draw(at: NSPoint(x: 60, y: 75), withAttributes: attrs)
NSGraphicsContext.restoreGraphicsState() NSGraphicsContext.restoreGraphicsState()
return rep.cgImage
}
guard let cgImage = rep.cgImage else { exit(1) } func runOCRSelfTest() {
OCRService.shared.recognize(cgImage) { recognized in let cases: [(expected: String, contains: String)] = [
print("expected: \(text)") ("Kapture OCR test 1234, hello world", "Kapture OCR test 1234"),
print("got: \(recognized)") ("你好世界,这是一个测试", "你好世界"),
let ok = recognized == text ]
print(ok ? "PASS" : "FAIL") var index = 0
exit(ok ? 0 : 1)
func runNext() {
guard index < cases.count else { exit(0) }
let current = cases[index]
index += 1
guard let image = renderTestImage(current.expected) else { exit(1) }
OCRService.shared.recognize(image) { recognized in
print("case: \(current.expected)")
print("got: \(recognized)")
let ok = recognized.contains(current.contains)
print(ok ? "PASS" : "FAIL")
if !ok { exit(1) }
runNext()
}
} }
runNext()
RunLoop.main.run() RunLoop.main.run()
} }
+426 -33
View File
@@ -1,19 +1,51 @@
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).
/// Also appended to /tmp/kapture-debug.log so it survives relaunches.
static var sessionLog: [String] = []
static func log(_ line: String) {
let stamped = String(format: "%@ %@", DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium), line)
sessionLog.append(stamped)
if sessionLog.count > 200 { sessionLog.removeFirst() }
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") lines.append("Kapture diagnostics — version \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "?")")
lines.append("accessibility=\(AXIsProcessTrusted()) eventTap=\(eventTap != nil)")
lines.append("--- session log (newest last) ---")
lines.append(contentsOf: Self.sessionLog)
lines.append("--- screens ---")
for (i, s) in NSScreen.screens.enumerated() { for (i, s) in NSScreen.screens.enumerated() {
let scale = s.backingScaleFactor let scale = s.backingScaleFactor
let cgID = (s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0 let cgID = (s.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value ?? 0
@@ -42,6 +74,8 @@ final class CaptureController: NSObject {
return lines.joined(separator: "\n") return lines.joined(separator: "\n")
} }
// MARK: - Session lifecycle
func begin() { func begin() {
guard !isSelecting else { return } guard !isSelecting else { return }
guard PermissionManager.isGranted else { guard PermissionManager.isGranted else {
@@ -50,20 +84,44 @@ final class CaptureController: NSObject {
} }
isSelecting = true isSelecting = true
previousApp = NSWorkspace.shared.frontmostApplication previousApp = NSWorkspace.shared.frontmostApplication
if #available(macOS 14.0, *) { // With the event tap driving input we must NOT activate: stealing
NSApp.activate() // focus turns the target app into background mode (dimmed chrome),
} else { // and that is what would end up in the screenshot. Activate only on
// the fallback path, where panel events are the only input.
let usesTap = AXIsProcessTrusted()
if !usesTap {
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
} }
overlays = NSScreen.screens.map { screen in CaptureController.log("begin: usesTap=\(usesTap) active=\(NSApp.isActive)")
OverlayWindow( DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
screen: screen, guard let self, self.isSelecting else { return }
onComplete: { [weak self] rect in self?.finishSelection(rect) }, self.overlays = NSScreen.screens.map { screen in
onCancel: { [weak self] in self?.cancelSelection() } let panel = OverlayWindow(screen: screen, controller: self)
) (panel.contentView as? OverlayView)?.controller = self
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
hideOverlays()
isSelecting = false
restorePreviousApp()
DispatchQueue.main.async { [self] in
if ownedRisk {
capture(rect, excludingWindowIDs: ids, roundedCorners: true)
} else {
capture(rect, excludingWindowIDs: ids, windowID: windowID)
}
} }
overlays.forEach { $0.makeKeyAndOrderFront(nil) }
overlayIDs = overlays.map { $0.windowNumber }
} }
private func finishSelection(_ rect: NSRect) { private func finishSelection(_ rect: NSRect) {
@@ -85,8 +143,12 @@ 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
} }
private func restorePreviousApp() { private func restorePreviousApp() {
@@ -94,8 +156,221 @@ final class CaptureController: NSObject {
previousApp = nil previousApp = nil
} }
private func capture(_ rect: NSRect, excludingWindowIDs ids: [Int]) { private func renderOverlays() {
ScreenCapture.shared.capture(rect, excludingWindowIDs: ids) { [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 {
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 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()
@@ -104,6 +379,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)
} }
} }
@@ -116,17 +392,55 @@ 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) -> CGImage? { private func crop(_ image: CGImage, to cgRect: CGRect, display: SCDisplay, roundedCorners: Bool = false) -> CGImage? {
// cgRect is already in SCDisplay space (Y flipped relative to AppKit). // cgRect is in SCDisplay space (bottom-left origin, Y flipped vs AppKit).
// Derive the pixel scale from the image we actually got, relative to // The captured image has a TOP-LEFT origin. So within the display,
// the display's point frame. // we must flip Y: pixelY = displayHeight - (cgY - displayMinY) - cgHeight.
let scaleX = CGFloat(image.width) / display.frame.width let scaleX = CGFloat(image.width) / display.frame.width
let scaleY = CGFloat(image.height) / display.frame.height let scaleY = CGFloat(image.height) / display.frame.height
guard scaleX > 0, scaleY > 0 else { return nil } guard scaleX > 0, scaleY > 0 else { return nil }
let localMinX = cgRect.minX - display.frame.minX
let localMaxY = cgRect.maxY - display.frame.minY
let flippedY = display.frame.height - localMaxY
let pxRect = CGRect( let pxRect = CGRect(
x: (cgRect.minX - display.frame.minX) * scaleX, x: localMinX * scaleX,
y: (cgRect.minY - display.frame.minY) * scaleY, y: flippedY * scaleY,
width: cgRect.width * scaleX, width: cgRect.width * scaleX,
height: cgRect.height * scaleY height: cgRect.height * scaleY
) )
@@ -134,15 +448,62 @@ final class ScreenCapture {
// display's frame (e.g. secondary monitor coordinate quirks). // display's frame (e.g. secondary monitor coordinate quirks).
let safeRect = pxRect.intersection(CGRect(x: 0, y: 0, width: CGFloat(image.width), height: CGFloat(image.height))) let safeRect = pxRect.intersection(CGRect(x: 0, y: 0, width: CGFloat(image.width), height: CGFloat(image.height)))
guard safeRect.width >= 1, safeRect.height >= 1 else { return nil } guard safeRect.width >= 1, safeRect.height >= 1 else { return nil }
guard let cropCtx = CGContext( // Use a pixel-exact subimage crop instead of redrawing through a
data: nil, width: Int(safeRect.width), height: Int(safeRect.height), // CGContext, which resamples and loses quality on retina captures.
bitsPerComponent: 8, bytesPerRow: 0, let rounded = safeRect.integral
space: CGColorSpaceCreateDeviceRGB(), guard rounded.width >= 1, rounded.height >= 1 else { return nil }
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue guard var result = image.cropping(to: rounded) else { return nil }
) else { return nil }
cropCtx.interpolationQuality = .high // Apply a rounded-corner alpha mask for window captures, matching
cropCtx.draw(image, in: CGRect(x: -safeRect.minX, y: -safeRect.minY, width: CGFloat(image.width), height: CGFloat(image.height))) // macOS window chrome (≈10pt radius, scaled to image pixels).
return cropCtx.makeImage() if roundedCorners {
// The screen crop contains the app's real rounded corners against
// the desktop — measure the radius from the corner pixels, then
// 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,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return result }
ctx.clip(to: CGRect(x: 0, y: 0, width: result.width, height: result.height), mask: mask1x)
ctx.draw(result, in: CGRect(x: 0, y: 0, width: result.width, height: result.height))
if let masked = ctx.makeImage() {
result = masked
}
}
return result
} }
@available(macOS 14.0, *) @available(macOS 14.0, *)
@@ -155,7 +516,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], 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,
@@ -178,7 +539,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,
@@ -190,11 +551,43 @@ 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
} }
completion(self.crop(image, to: scRect, display: display)) 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))
} }
} else { } else {
guard let primary = NSScreen.screens.first(where: { $0.frame.origin == .zero }) ?? NSScreen.screens.first else { guard let primary = NSScreen.screens.first(where: { $0.frame.origin == .zero }) ?? NSScreen.screens.first else {
+5
View File
@@ -12,6 +12,11 @@ final class OCRService {
request.recognitionLevel = .accurate request.recognitionLevel = .accurate
request.usesLanguageCorrection = true request.usesLanguageCorrection = true
request.recognitionLanguages = ["en-US", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR"] request.recognitionLanguages = ["en-US", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR"]
// Without this, Vision defaults to the first listed language and
// renders CJK as Latin garbage (verified: 你好世界 -> "177WR").
if #available(macOS 13.0, *) {
request.automaticallyDetectsLanguage = true
}
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do { do {
+64 -54
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) { init(screen: NSScreen, controller: CaptureController) {
let content = OverlayView(onComplete: onComplete, onCancel: onCancel) 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,21 +29,7 @@ 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
private var startPoint: NSPoint?
private(set) var selection: NSRect?
init(onComplete: @escaping (NSRect) -> Void, onCancel: @escaping () -> Void) {
self.onComplete = onComplete
self.onCancel = onCancel
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 }
@@ -47,55 +39,73 @@ final class OverlayView: NSView {
addCursorRect(bounds, cursor: .crosshair) addCursorRect(bounds, cursor: .crosshair)
} }
override func mouseDown(with event: NSEvent) { /// The screen frame this overlay covers, in AppKit global space.
let p = convert(event.locationInWindow, from: nil) private var screenFrame: NSRect {
startPoint = p guard let window else { return .zero }
selection = NSRect(origin: p, size: .zero) return window.convertToScreen(NSRect(origin: .zero, size: bounds.size))
needsDisplay = true
} }
override func mouseDragged(with event: NSEvent) { /// Convert an AppKit-global rect into this view's coordinates.
guard let start = startPoint else { return } private func viewRect(_ global: NSRect) -> NSRect {
let p = convert(event.locationInWindow, from: nil) let sf = screenFrame
selection = NSRect( return NSRect(
x: min(start.x, p.x), x: global.origin.x - sf.origin.x,
y: min(start.y, p.y), y: global.origin.y - sf.origin.y,
width: abs(p.x - start.x), width: global.width,
height: abs(p.y - start.y) height: global.height
) )
needsDisplay = true
}
override func mouseUp(with event: NSEvent) {
guard let sel = selection else { return }
if sel.width >= 2, sel.height >= 2 {
onComplete(window!.convertToScreen(sel))
} else {
onCancel()
}
}
override func keyDown(with event: NSEvent) {
if event.keyCode == 53 {
onCancel()
} else {
super.keyDown(with: event)
}
} }
override func draw(_ dirtyRect: NSRect) { override func draw(_ dirtyRect: NSRect) {
NSColor.black.withAlphaComponent(0.35).setFill() NSColor.black.withAlphaComponent(0.35).setFill()
NSBezierPath(rect: bounds).fill() NSBezierPath(rect: bounds).fill()
guard let sel = selection, sel.width > 0, sel.height > 0 else { return } // Esc hint — the only way out once clicks are consumed by the tap.
let hint = "Drag to select · hover + click a window to capture it · Esc to cancel"
let hintAttrs: [NSAttributedString.Key: Any] = [
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .medium),
.foregroundColor: NSColor.white.withAlphaComponent(0.85),
]
let hintSize = hint.size(withAttributes: hintAttrs)
let hintRect = NSRect(
x: (bounds.width - hintSize.width) / 2 - 10,
y: bounds.height - 46,
width: hintSize.width + 20,
height: hintSize.height + 12
)
NSColor.black.withAlphaComponent(0.65).setFill()
NSBezierPath(roundedRect: hintRect, xRadius: 8, yRadius: 8).fill()
hint.draw(at: NSPoint(x: hintRect.minX + 10, y: hintRect.minY + 6), withAttributes: hintAttrs)
guard let controller else { return }
// Highlight the hovered window (rounded, like real window corners)
if let frame = controller.hoverFrame {
let vf = viewRect(frame)
NSGraphicsContext.current?.saveGraphicsState()
NSGraphicsContext.current?.compositingOperation = .clear
NSColor.black.setFill()
NSBezierPath(roundedRect: vf, xRadius: 10, yRadius: 10).fill()
NSGraphicsContext.current?.restoreGraphicsState()
let border = NSBezierPath(roundedRect: vf, xRadius: 10, yRadius: 10)
border.lineWidth = 2
NSColor.controlAccentColor.setStroke()
border.stroke()
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()
@@ -107,8 +117,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
) )
+463 -53
View File
@@ -88,6 +88,31 @@ final class CaptureModel: ObservableObject {
@Published var extractedText: String? @Published var extractedText: String?
@Published var isOCRRunning = false @Published var isOCRRunning = false
@Published var statusMessage: String? @Published var statusMessage: String?
/// Drag selection rect in IMAGE point space (nil = none)
@Published var selection: CGRect?
/// Freehand drawing strokes in IMAGE point space
@Published var strokes: [Stroke] = []
/// Redo stack of removed strokes
@Published var redoStack: [Stroke] = []
/// Whether the pen is active (drag draws instead of selecting)
@Published var tool: Tool = .select
/// Current drawing color
@Published var drawColor: NSColor = .systemRed
/// Stroke thickness in points (image space)
@Published var strokeWidth: CGFloat = 3.0
enum Tool {
case select
case draw
case redact
}
struct Stroke {
let points: [CGPoint]
let width: CGFloat
let color: NSColor
var isRedaction: Bool = false
}
enum CaptureMode { enum CaptureMode {
case screenshot case screenshot
@@ -98,24 +123,126 @@ final class CaptureModel: ObservableObject {
self.image = image self.image = image
} }
var hasSelection: Bool { selection != nil && selection!.width >= 2 && selection!.height >= 2 }
var canUndo: Bool { !strokes.isEmpty }
var canRedo: Bool { !redoStack.isEmpty }
func undoStroke() {
guard let last = strokes.popLast() else { return }
redoStack.append(last)
}
func redoStroke() {
guard let last = redoStack.popLast() else { return }
strokes.append(last)
}
func clearAnnotations() {
strokes = []
redoStack = []
}
/// Image with drawn strokes flattened in, for copy/save/export.
var annotatedImage: CGImage {
guard !strokes.isEmpty else { return image }
let size = NSSize(width: image.width, height: image.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 image }
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
let ctx = NSGraphicsContext.current!.cgContext
// CGImage draws with a flipped Y in NSGraphicsContext
ctx.draw(image, in: NSRect(origin: .zero, size: size))
// Strokes are stored in TOP-LEFT image coordinates (from the SwiftUI
// canvas), but the CGContext uses a BOTTOM-LEFT origin. Flip Y so the
// strokes land where they were drawn.
ctx.saveGState()
ctx.translateBy(x: 0, y: size.height)
ctx.scaleBy(x: 1, y: -1)
ctx.setLineCap(.round)
ctx.setLineJoin(.round)
for stroke in strokes {
guard stroke.points.count > 1 else { continue }
if stroke.isRedaction {
// Redaction bar: filled black rect covering the stroke bounds,
// padded so it fully covers text.
let xs = stroke.points.map { $0.x }
let ys = stroke.points.map { $0.y }
let bar = NSRect(
x: xs.min()! - 2,
y: ys.min()! - 2,
width: (xs.max()! - xs.min()!) + 4,
height: max(stroke.width, ys.max()! - ys.min()!) + 4
)
NSColor.black.setFill()
NSBezierPath(rect: bar).fill()
} else {
stroke.color.setStroke()
ctx.setLineWidth(stroke.width)
ctx.beginPath()
ctx.move(to: stroke.points[0])
for point in stroke.points.dropFirst() {
ctx.addLine(to: point)
}
ctx.strokePath()
}
}
ctx.restoreGState()
NSGraphicsContext.restoreGraphicsState()
return rep.cgImage ?? image
}
/// `widthPx` is the stroke thickness in IMAGE PIXELS (the preview
/// converts the slider value so exports match what was drawn).
func addStroke(_ points: [CGPoint], widthPx: CGFloat) {
guard points.count > 1 else { return }
if tool == .redact {
// Redaction: a filled black bar from start to end.
strokes.append(Stroke(points: points, width: max(widthPx, 12), color: .black, isRedaction: true))
} else {
strokes.append(Stroke(points: points, width: widthPx, color: drawColor))
}
redoStack = []
}
var displayText: String { var displayText: String {
if isOCRRunning { if isOCRRunning {
return "Recognizing text…" return "Recognizing text…"
} }
return extractedText ?? "Text appears here — click “Get Text”." return extractedText ?? "Select text in the screenshot, then click “Get Text”."
} }
func runOCR() { func cropImage(to rectInImage: CGRect) -> CGImage? {
return image.cropping(to: rectInImage)
}
func runOCR(over selectionOnly: CGRect? = nil) {
guard !isOCRRunning else { return } guard !isOCRRunning else { return }
isOCRRunning = true isOCRRunning = true
statusMessage = nil statusMessage = nil
OCRService.shared.recognize(image) { [weak self] text in
let targetImage: CGImage
if let sel = selectionOnly {
guard let cropped = image.cropping(to: sel) else {
self.isOCRRunning = false
self.statusMessage = "Selection too small."
return
}
targetImage = cropped
} else {
targetImage = image
}
OCRService.shared.recognize(targetImage) { [weak self] text in
Task { @MainActor in Task { @MainActor in
guard let self else { return } guard let self else { return }
self.isOCRRunning = false self.isOCRRunning = false
self.mode = .text self.mode = .text
if text.isEmpty { if text.isEmpty {
self.extractedText = "No text found in the screenshot." self.extractedText = "No text found in the selection."
} else { } else {
self.extractedText = text self.extractedText = text
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
@@ -127,6 +254,11 @@ final class CaptureModel: ObservableObject {
} }
} }
func runOCRSelection() {
guard let sel = selection, hasSelection else { return }
runOCR(over: sel)
}
func copyText() { func copyText() {
guard let text = extractedText else { return } guard let text = extractedText else { return }
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
@@ -138,7 +270,7 @@ final class CaptureModel: ObservableObject {
func copyImage() { func copyImage() {
let pasteboard = NSPasteboard.general let pasteboard = NSPasteboard.general
pasteboard.clearContents() pasteboard.clearContents()
pasteboard.writeObjects([NSImage(cgImage: image, size: .zero)]) pasteboard.writeObjects([NSImage(cgImage: annotatedImage, size: .zero)])
statusMessage = "Image copied to clipboard." statusMessage = "Image copied to clipboard."
} }
@@ -151,7 +283,7 @@ final class CaptureModel: ObservableObject {
statusMessage = "Could not create PNG file." statusMessage = "Could not create PNG file."
return return
} }
CGImageDestinationAddImage(dest, image, nil) CGImageDestinationAddImage(dest, annotatedImage, nil)
if CGImageDestinationFinalize(dest) { if CGImageDestinationFinalize(dest) {
statusMessage = "Saved PNG." statusMessage = "Saved PNG."
} else { } else {
@@ -171,73 +303,351 @@ struct ResultView: View {
let onClose: () -> Void let onClose: () -> Void
var body: some View { var body: some View {
VStack(spacing: 10) { VStack(spacing: 0) {
HStack { // Toolbar
HStack(spacing: 10) {
Picker("", selection: $model.mode) { Picker("", selection: $model.mode) {
Text("Screenshot").tag(CaptureModel.CaptureMode.screenshot) Text("Screenshot").tag(CaptureModel.CaptureMode.screenshot)
Text("Text").tag(CaptureModel.CaptureMode.text) Text("Text").tag(CaptureModel.CaptureMode.text)
if model.isOCRRunning {
Text("…").tag(CaptureModel.CaptureMode.text)
}
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.fixedSize() .fixedSize()
Spacer()
Button("Copy Image") { model.copyImage() }
Button("Save PNG…") { model.savePNG() }
Button("Get Text") { model.runOCR() }
.disabled(model.isOCRRunning || model.extractedText != nil)
Button("Close") { onClose() }
}
.padding(.horizontal, 12)
.padding(.top, 10)
if model.mode == .screenshot { Spacer()
GeometryReader { geo in
let imageWidth = CGFloat(model.image.width) if model.hasSelection {
Image(nsImage: NSImage(cgImage: model.image, size: .zero)) Button {
.resizable() model.runOCRSelection()
// Downscale with smoothing (AA); upscale keeps pixels crisp. } label: {
.interpolation(imageWidth >= geo.size.width ? .high : .none) Label("OCR Selection", systemImage: "text.magnifyingglass")
.aspectRatio(contentMode: .fit) }
.buttonStyle(.borderedProminent)
.disabled(model.isOCRRunning)
.help("Recognize text inside the selected region")
} }
.border(Color.secondary.opacity(0.4))
.padding(.horizontal, 12) Button {
model.runOCR()
} label: {
Label("Get All Text", systemImage: "text.viewfinder")
}
.buttonStyle(.bordered)
.disabled(model.isOCRRunning)
.help("Recognize all text in the screenshot")
Divider().frame(height: 18)
Button {
model.tool = model.tool == .draw ? .select : .draw
if model.tool != .select { model.selection = nil }
} label: {
Label("Draw", systemImage: model.tool == .draw ? "pencil.tip.crop.circle.fill" : "pencil.tip.crop.circle")
}
.buttonStyle(.bordered)
.foregroundStyle(model.tool == .draw ? .primary : .secondary)
.help("Draw on the screenshot")
Button {
model.tool = model.tool == .redact ? .select : .redact
if model.tool != .select { model.selection = nil }
} label: {
Label("Redact", systemImage: model.tool == .redact ? "rectangle.fill.on.rectangle.fill" : "rectangle.dashed")
}
.buttonStyle(.bordered)
.foregroundStyle(model.tool == .redact ? .primary : .secondary)
.help("Cover sensitive areas with black bars")
Button {
model.copyImage()
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
.buttonStyle(.bordered)
.help("Copy image to clipboard")
Button {
model.savePNG()
} label: {
Label("Save", systemImage: "square.and.arrow.down")
}
.buttonStyle(.bordered)
.help("Save screenshot as PNG");
Button {
onClose()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.help("Close")
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
Divider()
// Tool toolbar (only when a draw tool is active)
if model.tool != .select {
HStack(spacing: 14) {
Label(model.tool == .redact ? "Redact" : "Draw", systemImage: model.tool == .redact ? "rectangle.fill" : "pencil.tip")
.font(.caption)
.foregroundStyle(.secondary)
Divider().frame(height: 18)
if model.tool == .draw {
HStack(spacing: 6) {
Image(systemName: "circle.fill").font(.system(size: 4))
Image(systemName: "circle.fill").font(.system(size: 7))
Image(systemName: "circle.fill").font(.system(size: 10))
}
.foregroundStyle(.secondary)
Slider(value: $model.strokeWidth, in: 1...12, step: 0.5)
.frame(width: 120)
.help("Stroke thickness")
Divider().frame(height: 18)
ColorPicker("", selection: Binding(
get: { Color(nsColor: model.drawColor) },
set: { model.drawColor = NSColor($0) }
), supportsOpacity: false)
.labelsHidden()
.frame(width: 28)
.help("Drawing color")
} else {
Label("Drag to cover sensitive text", systemImage: "eye.slash")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
model.undoStroke()
} label: {
Label("Undo", systemImage: "arrow.uturn.backward")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!model.canUndo)
.keyboardShortcut("z", modifiers: .command)
.help("Undo last stroke (⌘Z)")
Button {
model.redoStroke()
} label: {
Label("Redo", systemImage: "arrow.uturn.forward")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!model.canRedo)
.keyboardShortcut("z", modifiers: [.command, .shift])
.help("Redo stroke (⌘⇧Z)")
Button {
model.clearAnnotations()
} label: {
Label("Clear", systemImage: "eraser")
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(model.strokes.isEmpty)
.help("Remove all drawn annotations")
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.bar)
Divider()
}
// Content
if model.mode == .screenshot {
ScreenshotPane(model: model)
.padding(12)
} else { } else {
ZStack(alignment: .bottomTrailing) { ZStack {
ScrollView { ScrollView {
Text(model.displayText) Text(model.displayText)
.font(.system(.body)) .font(.system(.body, design: .monospaced))
.textSelection(.enabled) .textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.padding(8) .padding(12)
} }
.overlay( }
RoundedRectangle(cornerRadius: 6) .frame(maxWidth: .infinity, maxHeight: .infinity)
.stroke(Color.secondary.opacity(0.4)) }
)
HStack(spacing: 8) { // Status bar
if let msg = model.statusMessage { HStack(spacing: 8) {
Text(msg) if model.isOCRRunning {
.font(.caption) ProgressView()
.foregroundColor(.secondary) .controlSize(.small)
} }
Button("Copy Text") { model.copyText() } Text(model.statusMessage ?? (model.mode == .screenshot
? (model.hasSelection ? "Region selected — click OCR Selection." : "Drag to select a region, or Get All Text.")
: "Selectable text"))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
if model.mode == .text, model.extractedText != nil {
Button {
model.copyText()
} label: {
Label("Copy Text", systemImage: "doc.on.doc")
} }
.padding(8) .buttonStyle(.bordered)
.controlSize(.small)
} }
} }
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.bar)
}
.frame(minWidth: 520, minHeight: 420)
}
}
if model.statusMessage != nil && model.mode == .screenshot { struct ScreenshotPane: View {
Text(model.statusMessage!) @ObservedObject var model: CaptureModel
.font(.caption)
.foregroundColor(.secondary) private var imageSize: CGSize {
.frame(maxWidth: .infinity, alignment: .leading) CGSize(width: model.image.width, height: model.image.height)
.padding(.horizontal, 12) }
private func geometry(for geo: GeometryProxy) -> (scale: CGFloat, drawSize: CGSize, origin: CGPoint) {
let size = imageSize
let scale = min(geo.size.width / size.width, geo.size.height / size.height)
let drawSize = CGSize(width: size.width * scale, height: size.height * scale)
let origin = CGPoint(
x: (geo.size.width - drawSize.width) / 2,
y: (geo.size.height - drawSize.height) / 2
)
return (scale, drawSize, origin)
}
private func toImagePoint(_ viewPoint: CGPoint, scale: CGFloat, origin: CGPoint) -> CGPoint {
CGPoint(
x: (viewPoint.x - origin.x) / scale,
y: (viewPoint.y - origin.y) / scale
)
}
private func clampSelection(_ rect: CGRect) -> CGRect {
rect.intersection(CGRect(origin: .zero, size: imageSize))
}
@State private var currentStroke: [CGPoint] = []
var body: some View {
GeometryReader { geo in
let (scale, drawSize, origin) = geometry(for: geo)
ZStack {
Image(nsImage: NSImage(cgImage: model.image, size: .zero))
.resizable()
.interpolation(imageSize.width >= geo.size.width ? .high : .none)
.aspectRatio(contentMode: .fit)
.frame(width: drawSize.width, height: drawSize.height)
.position(x: origin.x + drawSize.width / 2, y: origin.y + drawSize.height / 2)
// Completed strokes
Canvas { context, _ in
for stroke in model.strokes {
guard stroke.points.count > 1 else { continue }
if stroke.isRedaction {
let xs = stroke.points.map { $0.x }
let ys = stroke.points.map { $0.y }
let bar = CGRect(
x: origin.x + (xs.min()! - 2) * scale,
y: origin.y + (ys.min()! - 2) * scale,
width: ((xs.max()! - xs.min()!) + 4) * scale,
height: (max(stroke.width, ys.max()! - ys.min()!) + 4) * scale
)
context.fill(Path(bar), with: .color(.black))
} else {
var path = Path()
path.move(to: CGPoint(x: origin.x + stroke.points[0].x * scale, y: origin.y + stroke.points[0].y * scale))
for point in stroke.points.dropFirst() {
path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale))
}
// stroke.width is in image pixels; scale to view points
context.stroke(path, with: .color(Color(nsColor: stroke.color)), lineWidth: stroke.width * scale)
}
}
}
.allowsHitTesting(false)
// In-progress stroke
if !currentStroke.isEmpty {
Canvas { context, _ in
var path = Path()
if currentStroke.count > 1 {
path.move(to: CGPoint(x: origin.x + currentStroke[0].x * scale, y: origin.y + currentStroke[0].y * scale))
for point in currentStroke.dropFirst() {
path.addLine(to: CGPoint(x: origin.x + point.x * scale, y: origin.y + point.y * scale))
}
}
context.stroke(path, with: .color(Color(nsColor: model.drawColor)), lineWidth: model.strokeWidth)
}
.allowsHitTesting(false)
}
if model.tool == .select, let sel = model.selection {
let viewRect = CGRect(
x: origin.x + sel.minX * scale,
y: origin.y + sel.minY * scale,
width: sel.width * scale,
height: sel.height * scale
)
Rectangle()
.fill(Color.accentColor.opacity(0.15))
.border(Color.accentColor, width: 1.5)
.frame(width: viewRect.width, height: viewRect.height)
.position(x: viewRect.midX, y: viewRect.midY)
.allowsHitTesting(false)
}
} }
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
if model.tool != .select {
let p = toImagePoint(value.location, scale: scale, origin: origin)
let imgRect = CGRect(origin: .zero, size: imageSize)
if imgRect.contains(p) {
currentStroke.append(p)
}
} else {
let start = toImagePoint(value.startLocation, scale: scale, origin: origin)
let current = toImagePoint(value.location, scale: scale, origin: origin)
let rect = CGRect(
x: min(start.x, current.x),
y: min(start.y, current.y),
width: abs(current.x - start.x),
height: abs(current.y - start.y)
)
model.selection = clampSelection(rect)
}
}
.onEnded { _ in
if model.tool != .select {
if currentStroke.count > 1 {
model.addStroke(currentStroke, widthPx: model.strokeWidth / max(scale, 0.01))
}
currentStroke = []
} else {
if let s = model.selection, (s.width < 2 || s.height < 2) {
model.selection = nil
}
}
}
)
} }
.padding(.bottom, 10)
.frame(minWidth: 480, minHeight: 380)
} }
} }
+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.1.8</string> <string>1.9.2</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>18</string> <string>92</string>
<key>LSUIElement</key> <key>LSUIElement</key>
<true/> <true/>
<key>NSHighResolutionCapable</key> <key>NSHighResolutionCapable</key>