Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ adheres to [Semantic Versioning](https://semver.org) and the

## [Unreleased]

### Added
- **Grab Text** global shortcut: press ⌃⌥G anywhere to start the region capture
and OCR without opening the window first. The recognised text lands on the
clipboard when "Copy automatically" is on, and the window only comes forward
when the result needs you — a failure, a missing permission, or text that
wasn't copied and would otherwise be lost. The combination is recorded in a
new Settings sheet and can be changed or turned off. A combination another app
already owns exclusively is reported with the error macOS gave, rather than
quietly doing nothing, as is a Secure Input session suppressing every global
shortcut. Screen capture needs Screen Recording access; without it a grab
comes back blank rather than failing, so Grab Text now says so instead of
reporting "no text found".

## [0.14.0] — 2026-07-21

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ The app updates itself automatically via [Sparkle](https://sparkle-project.org);
| **Uninstall Apps** | Remove apps and their leftover support files |
| **Download Video** | Save videos from the web (bundled, checksum‑verified yt‑dlp) |
| **Image Converter** | Batch convert / resize / compress images (HEIC, JPEG, PNG, …) |
| **Grab Text** | OCR any region of the screen straight to your clipboard |
| **Grab Text** | OCR any region of the screen straight to your clipboard, from a global shortcut (⌃⌥G) |
| **QR Studio** | Generate QR codes and scan them from the screen |
| **Color Picker** | System eyedropper with hex / RGB / HSL and a recent‑colors palette |
| **Window Manager** | Snap windows to halves, thirds, and corners with global shortcuts (⌃⌥ + arrows) |
Expand Down
443 changes: 443 additions & 0 deletions Sources/DMonteCore/GrabTextController.swift

Large diffs are not rendered by default.

116 changes: 116 additions & 0 deletions Sources/DMonteCore/GrabTextKit.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import Foundation
import Vision
import ImageIO
Expand Down Expand Up @@ -52,4 +53,119 @@ public enum GrabTextKit {
observation.topCandidates(1).first?.string
}
}

/// Interprets one finished `screencapture -i` run. Pure, so the decision that matters most —
/// telling a missing Screen Recording grant apart from an ordinary cancel or a text-free
/// region — is unit-tested rather than inferred from a live TCC state we can't script.
///
/// The distinction exists because the two look identical from here: without the grant,
/// `screencapture` either writes nothing at all or writes a frame with every other app's
/// windows redacted, and both shapes are exactly what a user pressing Esc or grabbing a blank
/// patch of desktop produces. Reporting those as "no text found" sends the user hunting for a
/// problem in their selection when the actual blocker is a permission they have to grant.
///
/// The cost of resolving the ambiguity this way is that cancelling with Esc *while the grant
/// is missing* also reads as a permission problem. That is the right trade: the permission is
/// a real blocker the user has to clear before any grab can work, so saying so early is more
/// useful than a "cancelled" they already knew about.
public static func classifyCapture(
fileWritten: Bool,
recognizedText: String,
hasScreenRecordingAccess: Bool
) -> GrabTextOutcome {
if hasScreenRecordingAccess {
return fileWritten ? .success(recognizedText) : .cancelled
}
if !fileWritten || recognizedText.isEmpty {
return .needsScreenRecordingPermission
}
// Text came back despite the preflight saying no — believe the pixels, not the flag.
return .success(recognizedText)
}
}

/// What one capture-and-recognize attempt produced.
public enum GrabTextOutcome: Equatable, Sendable {
/// The region was captured; the payload is the recognized text, which may be empty when the
/// region genuinely holds none.
case success(String)
/// The user dismissed the region selector.
case cancelled
/// The Screen Recording TCC grant is missing, so nothing usable could be captured.
case needsScreenRecordingPermission
/// Something else went wrong; the payload is a sentence to show the user.
case failure(String)
}

/// One capture attempt. A closure type rather than a direct call so `GrabTextController` can be
/// driven headlessly: the real implementation spawns `/usr/sbin/screencapture`, which needs both a
/// GUI session and a human to drag a rectangle.
public typealias GrabTextCapture = @Sendable () async -> GrabTextOutcome

/// This process's Screen Recording (TCC) state. Screen capture is gated on it, and unlike
/// Accessibility there is no notification when it changes, so callers re-read it around each grab.
public enum ScreenRecordingAccess {
/// Whether the grant is currently held. `CGPreflightScreenCaptureAccess` only reads the stored
/// decision — it never prompts — so it is safe to call on every capture.
public static var isGranted: Bool { CGPreflightScreenCaptureAccess() }

/// Asks for the grant. `CGRequestScreenCaptureAccess` shows the system prompt the first time
/// only; on every later call it silently returns the stored answer, which would look like the
/// button did nothing. Opening the Settings pane as well means the button always leads
/// somewhere the user can act.
@discardableResult
public static func request() -> Bool {
let granted = CGRequestScreenCaptureAccess()
if !granted,
let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
NSWorkspace.shared.open(url)
}
return granted
}
}

/// Runs the interactive screen capture and Vision OCR off the main actor. The heavy work (spawning
/// `screencapture` and running Vision) happens in a detached task; the result is returned to the
/// awaiting caller, which is back on its original actor.
public enum GrabTextCapturer {
public static func grabRegion() async -> GrabTextOutcome {
await Task.detached(priority: .userInitiated) { () -> GrabTextOutcome in
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("dmonte-grabtext-\(UUID().uuidString).png")

defer {
try? FileManager.default.removeItem(at: tempURL)
}

let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture")
// -i: interactive region selection, -x: silence the capture sound.
process.arguments = ["-i", "-x", tempURL.path]

do {
try process.run()
} catch {
return .failure("Couldn't start screen capture: \(error.localizedDescription)")
}

process.waitUntilExit()

// Read the grant *after* the run: the first attempt is what triggers the system
// prompt, so a preflight taken beforehand would report "denied" for the very grab the
// user just approved.
let hasAccess = ScreenRecordingAccess.isGranted
let fileWritten = FileManager.default.fileExists(atPath: tempURL.path)
let text = fileWritten
? GrabTextKit.recognizeText(in: tempURL)
.joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
: ""

return GrabTextKit.classifyCapture(
fileWritten: fileWritten,
recognizedText: text,
hasScreenRecordingAccess: hasAccess
)
}.value
}
}
13 changes: 13 additions & 0 deletions Sources/DMonteCore/GrabTextSizing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ public enum GrabTextSizing {
return NSSize(width: (460 * scale).rounded(), height: (420 * scale).rounded())
}

/// The settings overlay, which is centred *over* the panel and therefore must never be wider
/// than it. A hard-coded size looks right only at `currentScale == 1`; on a Mac whose menu bar
/// is thinner than 26pt the panel shrinks and the sheet does not, so it overflows equally on
/// both sides and its first and last characters are clipped.
public static func settingsSize() -> NSSize {
let panel = preferredSize()
// Leave room for the 18pt padding PreferencesOverlay adds around the sheet on every
// side: a sheet sized to the full panel becomes panel+36 once padded and spills
// the panel, dragging the content behind it off both edges.
let overlayChrome: CGFloat = 36
return NSSize(width: min(400, panel.width - overlayChrome), height: min(360, panel.height - overlayChrome))
}

static var currentScale: CGFloat {
let visibleFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
let screenScale = visibleFrame.height / 950
Expand Down
Loading
Loading