From fc1b3570d52cea565bd060f7726e30abbbea2912 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 13:03:47 +0530 Subject: [PATCH 1/2] Grab Text: add a configurable global shortcut for instant OCR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grab Text could only be started by opening its window and clicking a button, which is backwards for a tool whose value is measured in seconds saved. ⌃⌥G now starts the region capture from anywhere, and the recognised text goes straight to the clipboard under the existing "copy automatically" preference. The window stays closed on the happy path — interrupting the user is the thing the shortcut exists to avoid — and only comes forward when the result needs them: a failure, a missing permission, or text that was not copied and would otherwise be lost with nowhere to read it. The state moved out of the SwiftUI view into GrabTextController because there are now two entry points into one capture. With `isGrabbing` living in view state, a hotkey press while the window was closed had nothing to check against and would start a second `screencapture -i`, leaving two processes fighting over the crosshair and a stray temp file behind. The controller owns the single guard, and the Carbon handler runs the capture synchronously (via MainActor.assumeIsolated — Carbon dispatches on the main run loop) rather than hopping through a Task, so no second press can slip past the guard while a hop is queued. Reuses the existing hotkey machinery rather than growing a second one: GlobalHotKey's failure-surfacing `register`, WindowShortcut's storage codec and glyph rendering, and the shortcut recorder — which moved out of WindowManagerView.swift into its own file and is now keyed by an AnyHashable slot token, because Window Manager has one shortcut per WindowAction and Grab Text has exactly one. Sharing it keeps the parts that are easy to get wrong (Esc handling, the auto-cancel when the panel resigns key, releasing the live Carbon keys so the recorder can read a combination they would otherwise swallow) in a single place instead of copied per tool. Two failures that would otherwise be silent are now spoken: - A combination another app owns exclusively is reported with the OSStatus macOS gave, on the shortcut row and on the main window's hint line, as is a Secure Input session — which suppresses delivery while registration still succeeds, so without saying so the shortcut looks fine and does nothing. - Screen capture is gated on Screen Recording, and without the grant `screencapture` returns a redacted frame rather than an error, which reads as "no text found" and sends the user hunting through their selection for a problem that is a permission. `GrabTextKit.classifyCapture` is the pure decision that tells those apart, and the settings sheet explains and requests the grant. The shortcut persists under DefaultsKey.grabTextCaptureShortcut and is deliberately left out of registerDefaults(): an absent value has to keep meaning "never customized, use today's default", which is what lets the default change later without stranding everyone who never opened the setting. An explicit "off" is stored as a sentinel so it stays distinguishable from that. The controller takes an injectable hotkey registrar and capture closure, which is what makes any of this testable: RegisterEventHotKey can neither be made to fail on demand nor safely left registered by a test process, and `screencapture -i` wants a GUI session and a human with a mouse. 20 new tests cover the storage codec and its fallbacks, the capture classification, the in-flight guard, the registration-failure surface, and each branch of what the user is told (suite: 772 → 792, 0 failures). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 13 + README.md | 2 +- Sources/DMonteCore/GrabTextController.swift | 359 ++++++++++++++++ Sources/DMonteCore/GrabTextKit.swift | 116 ++++++ Sources/DMonteCore/GrabTextSizing.swift | 13 + Sources/DMonteCore/GrabTextView.swift | 344 +++++++++++----- Sources/DMonteCore/ShortcutRecorder.swift | 114 ++++++ Sources/DMonteCore/WindowManagerKit.swift | 4 +- Sources/DMonteCore/WindowManagerView.swift | 105 +---- .../GrabTextAppDelegate.swift | 15 +- .../DMonteCoreTests/GrabTextHotKeyTests.swift | 386 ++++++++++++++++++ 11 files changed, 1253 insertions(+), 218 deletions(-) create mode 100644 Sources/DMonteCore/GrabTextController.swift create mode 100644 Sources/DMonteCore/ShortcutRecorder.swift create mode 100644 Tests/DMonteCoreTests/GrabTextHotKeyTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e9936..a57a5d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 524f959..8609ca0 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/Sources/DMonteCore/GrabTextController.swift b/Sources/DMonteCore/GrabTextController.swift new file mode 100644 index 0000000..b53568e --- /dev/null +++ b/Sources/DMonteCore/GrabTextController.swift @@ -0,0 +1,359 @@ +import AppKit +import Combine +import Foundation + +public extension DefaultsKey { + /// The global shortcut that starts a capture, as `WindowShortcut.storageValue` + /// ("keyCode,modifiers"), or `GrabTextShortcutStore.disabledStorageValue` when the user has + /// deliberately cleared it. + /// + /// Intentionally *not* given a registered default: an absent value has to keep meaning "never + /// customized, use whatever the built-in default is today", which is what lets the default + /// change later without stranding everyone who never opened the setting. Registering a value + /// would make the key permanently present and erase that distinction. + static let grabTextCaptureShortcut = "tool.grabText.captureShortcut" +} + +/// Reads and writes Grab Text's single capture shortcut. Pure data in/out over a `UserDefaults`, +/// so the fallback and disable semantics are testable against a scratch suite. +public struct GrabTextShortcutStore { + /// ⌃⌥G — the built-in combination. ⌃⌥ follows Window Manager's choice of a prefix that apps + /// rarely claim, and G is free there because no window action uses a letter other than C. + public static let defaultShortcut = WindowShortcut(keyCode: HotKeyCode.g, modifiers: HotKeyModifier.controlOption) + + /// Persisted in place of a "keyCode,modifiers" pair when the user clears the shortcut. A + /// sentinel rather than removing the key, because an absent key already means "use the + /// default" and "off" has to stay distinguishable from it. + public static let disabledStorageValue = "off" + + private let defaults: UserDefaults + + public init(defaults: UserDefaults) { + self.defaults = defaults + } + + /// The shortcut that should actually be registered: the user's override, the built-in default + /// when they never chose one, or nil when they turned the hotkey off. + public func effectiveShortcut() -> WindowShortcut? { + guard let raw = defaults.string(forKey: DefaultsKey.grabTextCaptureShortcut) else { + return Self.defaultShortcut + } + if raw == Self.disabledStorageValue { return nil } + // An unparseable value is corruption, not intent — falling back to the default beats + // silently leaving the user with no hotkey and no explanation. + return WindowShortcut(storageValue: raw) ?? Self.defaultShortcut + } + + public func save(_ shortcut: WindowShortcut) { + defaults.set(shortcut.storageValue, forKey: DefaultsKey.grabTextCaptureShortcut) + } + + /// Records that the user wants no capture hotkey at all. + public func disable() { + defaults.set(Self.disabledStorageValue, forKey: DefaultsKey.grabTextCaptureShortcut) + } + + /// Drops the override (and any disable), restoring the built-in default. + public func reset() { + defaults.removeObject(forKey: DefaultsKey.grabTextCaptureShortcut) + } +} + +/// Outcome of assigning Grab Text's capture shortcut. There is no conflict case: the tool owns a +/// single shortcut, so the only combination it could clash with is its own. +public enum GrabTextShortcutAssignment: Equatable, Sendable { + case assigned + /// The combination lacks ⌘⌃⌥ (and isn't a bare function key), so it would swallow typing. + case needsModifiers +} + +/// Claims one global hotkey and returns an object whose lifetime keeps the claim alive (releasing +/// it unregisters — `GlobalHotKey` does that in `deinit`). Injectable because the failure path is +/// the entire point of `registrationFailure`, and `RegisterEventHotKey` can neither be made to +/// fail on demand nor be safely left behind in a headless test run. +public typealias GrabTextHotKeyRegistrar = @MainActor ( + _ shortcut: WindowShortcut, + _ id: UInt32, + _ handler: @escaping @Sendable () -> Void +) throws -> AnyObject + +/// Owns everything Grab Text does: the capture/OCR run, the recognized text and status line, the +/// "copy automatically" preference, and the global capture hotkey. +/// +/// One controller rather than view state because the capture now has two entry points — the window +/// button and the global hotkey — and they must share the same in-flight guard. With the state +/// living in the SwiftUI view, a hotkey press while the window was closed had nothing to check +/// against and would start a second `screencapture -i`. +@MainActor +public final class GrabTextController: ObservableObject { + /// Whether a capture is running (the region selector is up, or Vision is still working). + @Published public private(set) var isGrabbing = false + + /// Text from the most recent successful grab; empty until there is one. + @Published public private(set) var recognizedText = "" + + /// The sentence shown where the result would be, when there is no result. + @Published public private(set) var statusMessage = GrabTextController.idleMessage + + /// Whether a successful grab copies straight to the clipboard. Persisted on change. + @Published public var copyAutomatically: Bool { + didSet { + guard copyAutomatically != oldValue else { return } + defaults.set(copyAutomatically, forKey: DefaultsKey.grabTextCopyAutomatically) + } + } + + /// The capture shortcut, or nil when the user turned it off. + @Published public private(set) var shortcut: WindowShortcut? + + /// The OSStatus from the last failed registration attempt, or nil when the hotkey is live (or + /// deliberately off). Surfaced in the settings sheet: a hotkey another app already owns must + /// look broken, not merely silent. + @Published public private(set) var registrationFailure: Int32? + + /// Whether macOS Secure Event Input is currently suppressing every global hotkey. Registration + /// still succeeds in that state, so without this the shortcut would look fine and do nothing. + @Published public private(set) var secureInputBlocked = false + + /// Whether this process currently holds the Screen Recording grant that screen capture needs. + @Published public private(set) var hasScreenRecordingAccess: Bool + + /// Called after a hotkey-started grab whose result the user still has to see or act on — a + /// failure, a missing permission, or text that wasn't auto-copied. A hotkey grab deliberately + /// leaves the window closed, so this is the only way that outcome reaches the user. + public var onHotKeyResultNeedsAttention: (() -> Void)? + + /// Carbon id for the single capture hotkey. Any constant works (ids are per-process). + static let hotKeyID: UInt32 = 1 + + static let idleMessage = "Drag to select a region of the screen to grab its text." + + private let defaults: UserDefaults + private let store: GrabTextShortcutStore + private let capture: GrabTextCapture + private let screenRecordingAccess: @Sendable () -> Bool + private let registrar: GrabTextHotKeyRegistrar + + /// Held only to keep the Carbon registration alive; releasing it unregisters. + private var hotKey: AnyObject? + + /// Whether the owner has asked for a live hotkey. Assigning a shortcut re-registers only when + /// it is true, so a controller that was never asked to claim a hotkey (every unit test, and any + /// future embedding that only wants the OCR) never touches Carbon as a side effect. + private var wantsHotKey = false + + public init( + defaults: UserDefaults? = nil, + capture: @escaping GrabTextCapture = GrabTextCapturer.grabRegion, + screenRecordingAccess: @escaping @Sendable () -> Bool = { ScreenRecordingAccess.isGranted }, + registrar: @escaping GrabTextHotKeyRegistrar = GrabTextController.registerGlobalHotKey + ) { + let resolvedDefaults = defaults ?? AppDefaults.shared + self.defaults = resolvedDefaults + self.store = GrabTextShortcutStore(defaults: resolvedDefaults) + self.capture = capture + self.screenRecordingAccess = screenRecordingAccess + self.registrar = registrar + // Default to copying: the tool's whole point is getting text onto the clipboard, and the + // key may be unregistered in a scratch suite, where `bool(forKey:)` would read false. + self.copyAutomatically = resolvedDefaults.object(forKey: DefaultsKey.grabTextCopyAutomatically) == nil + ? true + : resolvedDefaults.bool(forKey: DefaultsKey.grabTextCopyAutomatically) + self.shortcut = store.effectiveShortcut() + self.hasScreenRecordingAccess = screenRecordingAccess() + } + + // MARK: - Hotkey + + /// The production registrar: a real Carbon claim through `GlobalHotKey`. Public only because + /// it is the default value of a public initializer's parameter. + public static func registerGlobalHotKey( + _ shortcut: WindowShortcut, + id: UInt32, + handler: @escaping @Sendable () -> Void + ) throws -> AnyObject { + try GlobalHotKey.register(keyCode: shortcut.keyCode, modifiers: shortcut.modifiers, id: id, handler: handler) + } + + /// Claims (or re-claims) the capture hotkey. Safe to call repeatedly: the old registration is + /// released first, which `GlobalHotKey` requires before the same id can be re-used. + public func registerHotKey() { + wantsHotKey = true + hotKey = nil + registrationFailure = nil + secureInputBlocked = SecureInputState.isBlockingHotKeys + + guard let shortcut else { return } // deliberately off + + do { + hotKey = try registrar(shortcut, Self.hotKeyID) { [weak self] in + // Carbon dispatches hotkey events on the main run loop, so this already *is* the + // main actor's thread. Asserting that instead of hopping through a Task starts the + // capture synchronously with the key press, which keeps the in-flight guard exact: + // a second press cannot slip past `isGrabbing` while a hop is still queued. + MainActor.assumeIsolated { + _ = self?.startGrab(source: .hotKey) + } + } + } catch let error as GlobalHotKeyRegistrationError { + registrationFailure = Self.statusCode(for: error) + } catch { + registrationFailure = -1 + } + } + + /// Releases the hotkey and stops wanting one (termination). Clearing `wantsHotKey` matters: + /// without it, a later `assignShortcut` would silently re-claim a hotkey the owner has + /// already torn down. + public func unregisterHotKey() { + wantsHotKey = false + hotKey = nil + registrationFailure = nil + } + + /// Releases the hotkey for the duration of a shortcut recording without forgetting that we + /// want it back — pair with `resumeHotKeyAfterRecording()`. + public func suspendHotKeyForRecording() { + hotKey = nil + } + + public func resumeHotKeyAfterRecording() { + if wantsHotKey { registerHotKey() } + } + + /// Re-reads the state that can change behind our back while the window is closed: Secure Input + /// and the Screen Recording grant. Neither posts a notification. + public func refreshEnvironment() { + secureInputBlocked = SecureInputState.isBlockingHotKeys + hasScreenRecordingAccess = screenRecordingAccess() + } + + private static func statusCode(for error: GlobalHotKeyRegistrationError) -> Int32 { + switch error { + case .duplicateID: -2 + case .eventHandlerUnavailable: -3 + case .registrationFailed(let status): status + } + } + + // MARK: - Shortcut customization + + /// Assigns and persists a new capture shortcut, re-registering live. Refuses combinations that + /// would swallow ordinary typing. + @discardableResult + public func assignShortcut(_ shortcut: WindowShortcut) -> GrabTextShortcutAssignment { + guard shortcut.isUsableGlobally else { return .needsModifiers } + store.save(shortcut) + self.shortcut = store.effectiveShortcut() + if wantsHotKey { registerHotKey() } + return .assigned + } + + /// Turns the capture hotkey off entirely. + public func disableShortcut() { + store.disable() + shortcut = store.effectiveShortcut() + if wantsHotKey { registerHotKey() } + } + + /// Restores the built-in default shortcut. + public func resetShortcut() { + store.reset() + shortcut = store.effectiveShortcut() + if wantsHotKey { registerHotKey() } + } + + // MARK: - Capture + + /// Where a capture was started from. It only changes what happens with the *result*: a hotkey + /// grab runs with no window on screen, so anything the user still has to read has to pull the + /// window forward, while a window grab is already being watched. + public enum GrabSource: Equatable, Sendable { + case window + case hotKey + } + + /// Starts one capture, or does nothing if one is already running. + /// + /// Returns the task performing the capture, or nil when the request was ignored. The return + /// value is what makes the in-flight guard observable — to tests, and to any caller that wants + /// to await the result. + @discardableResult + public func startGrab(source: GrabSource = .window) -> Task? { + // Never two at once. The hotkey can be pressed again while the region selector from the + // previous press is still up; a second `screencapture -i` would fight the first for the + // crosshair and leave its temp file behind. + guard !isGrabbing else { return nil } + + isGrabbing = true + recognizedText = "" + statusMessage = "Drag to select the region containing text…" + + return Task { + let outcome = await self.capture() + self.finish(outcome, source: source) + } + } + + private func finish(_ outcome: GrabTextOutcome, source: GrabSource) { + isGrabbing = false + hasScreenRecordingAccess = screenRecordingAccess() + + // Whether the user has to look at this. A hotkey grab that quietly landed on the clipboard + // is the success case and must stay invisible; everything else has to surface somewhere. + var needsAttention = true + + switch outcome { + case .cancelled: + statusMessage = "Grab cancelled. Press Grab Text to try again." + needsAttention = false // they cancelled it; they know + case .needsScreenRecordingPermission: + statusMessage = Self.permissionMessage + case .failure(let reason): + statusMessage = reason + case .success(let text): + if text.isEmpty { + statusMessage = "No text found in that region. Try again." + } else { + recognizedText = text + if copyAutomatically { + Self.copyToPasteboard(text) + statusMessage = "Copied to clipboard." + needsAttention = false + } else { + // Nothing was copied, so the text exists only in the window — which a hotkey + // grab left closed. Showing it is the only way the grab isn't wasted. + statusMessage = "Grabbed \(text.count) characters. Press Copy to put them on the clipboard." + } + } + } + + if source == .hotKey, needsAttention { + onHotKeyResultNeedsAttention?() + } + } + + /// Puts the current result on the clipboard (the window's Copy button). + public func copyRecognizedText() { + guard !recognizedText.isEmpty else { return } + Self.copyToPasteboard(recognizedText) + statusMessage = "Copied to clipboard." + } + + /// Asks for the Screen Recording grant and re-reads it. + public func requestScreenRecordingAccess() { + ScreenRecordingAccess.request() + hasScreenRecordingAccess = screenRecordingAccess() + } + + static let permissionMessage = """ + macOS is withholding Screen Recording access, so nothing usable could be captured. \ + Grant it in System Settings › Privacy & Security › Screen Recording, then try again. + """ + + private static func copyToPasteboard(_ string: String) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(string, forType: .string) + } +} diff --git a/Sources/DMonteCore/GrabTextKit.swift b/Sources/DMonteCore/GrabTextKit.swift index 4a12805..e7ec83a 100644 --- a/Sources/DMonteCore/GrabTextKit.swift +++ b/Sources/DMonteCore/GrabTextKit.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import Vision import ImageIO @@ -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 + } } diff --git a/Sources/DMonteCore/GrabTextSizing.swift b/Sources/DMonteCore/GrabTextSizing.swift index e1c7e0c..7e43841 100644 --- a/Sources/DMonteCore/GrabTextSizing.swift +++ b/Sources/DMonteCore/GrabTextSizing.swift @@ -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 diff --git a/Sources/DMonteCore/GrabTextView.swift b/Sources/DMonteCore/GrabTextView.swift index 5c166d3..c8ce47f 100644 --- a/Sources/DMonteCore/GrabTextView.swift +++ b/Sources/DMonteCore/GrabTextView.swift @@ -6,35 +6,51 @@ import SwiftUI /// selectable result area with a Copy button and a character/line count. Content is /// scaled to match the menu-bar/display scale so it fits the scaled panel (same /// approach as the other tools). +/// +/// The state lives in `GrabTextController` rather than here because the global capture hotkey +/// starts the same flow while this window is closed — see the controller for why sharing the +/// in-flight guard matters. public struct GrabTextWindowView: View { + @ObservedObject var controller: GrabTextController var onQuit: () -> Void - @State private var isGrabbing = false - @State private var recognizedText = "" - @State private var statusMessage = "Drag to select a region of the screen to grab its text." - @State private var copyAutomatically: Bool = GrabTextWindowView.initialCopyAutomatically() + @State private var isShowingSettings = false private let layout = GrabTextLayout.current - public init(onQuit: @escaping () -> Void) { + public init(controller: GrabTextController, onQuit: @escaping () -> Void) { + self.controller = controller self.onQuit = onQuit } public var body: some View { - VStack(spacing: 0) { - header - - VStack(spacing: layout.contentSpacing) { - grabButton - copyToggle - resultArea - Spacer(minLength: 0) + ZStack { + VStack(spacing: 0) { + header + + VStack(spacing: layout.contentSpacing) { + grabButton + hotKeyHint + copyToggle + resultArea + Spacer(minLength: 0) + } + .padding(.horizontal, layout.contentHorizontalPadding) + .padding(.bottom, layout.contentBottomPadding) + } + + if isShowingSettings { + PreferencesOverlay(cornerRadius: 18) { + GrabTextSettingsView( + controller: controller, + onClose: { isShowingSettings = false } + ) + } } - .padding(.horizontal, layout.contentHorizontalPadding) - .padding(.bottom, layout.contentBottomPadding) } .frame(width: layout.windowSize.width, height: layout.windowSize.height) .frostedPanel(cornerRadius: 18) + .onAppear { controller.refreshEnvironment() } } // MARK: - Header @@ -58,8 +74,16 @@ public struct GrabTextWindowView: View { Spacer() - Color.clear - .frame(width: layout.headerButtonSize, height: layout.headerButtonSize) + Button { + isShowingSettings = true + } label: { + Image(systemName: "gearshape.fill") + .font(.system(size: layout.closeIconSize, weight: .semibold)) + .foregroundStyle(hotKeyNeedsAttention ? Color.orange : Color.secondary) + .frame(width: layout.headerButtonSize, height: layout.headerButtonSize) + } + .buttonStyle(.plain) + .help("Settings") } .padding(.horizontal, layout.headerHorizontalPadding) .padding(.top, layout.headerTopPadding) @@ -70,9 +94,9 @@ public struct GrabTextWindowView: View { private var grabButton: some View { Button { - startGrab() + controller.startGrab() } label: { - Label(isGrabbing ? "Selecting region…" : "Grab Text", systemImage: "text.viewfinder") + Label(controller.isGrabbing ? "Selecting region…" : "Grab Text", systemImage: "text.viewfinder") .font(.system(size: layout.primaryButtonFontSize, weight: .bold)) .foregroundStyle(Color.white) .frame(maxWidth: .infinity) @@ -82,22 +106,52 @@ public struct GrabTextWindowView: View { .contentShape(RoundedRectangle(cornerRadius: layout.buttonCornerRadius, style: .continuous)) } .buttonStyle(.plain) - .disabled(isGrabbing) - .opacity(isGrabbing ? 0.5 : 1) + .disabled(controller.isGrabbing) + .opacity(controller.isGrabbing ? 0.5 : 1) .padding(.top, layout.previewTopPadding) } + /// One line under the button telling the user the shortcut exists — a global hotkey nobody + /// knows about is the same as no hotkey. Turns into the problem when there is one. + private var hotKeyHint: some View { + HStack(spacing: layout.buttonRowSpacing / 2) { + Image(systemName: hotKeyNeedsAttention ? "exclamationmark.triangle.fill" : "command") + .font(.system(size: layout.captionFontSize, weight: .semibold)) + Text(hotKeyHintText) + .font(.system(size: layout.captionFontSize, weight: .medium)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .foregroundStyle(hotKeyNeedsAttention ? Color.orange : Color.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var hotKeyNeedsAttention: Bool { + controller.registrationFailure != nil || controller.secureInputBlocked + } + + private var hotKeyHintText: String { + if controller.registrationFailure != nil { + return "The capture shortcut is already claimed by another app — pick a different one in Settings." + } + if controller.secureInputBlocked { + return "macOS Secure Input is blocking global shortcuts right now." + } + guard let shortcut = controller.shortcut else { + return "No capture shortcut set — assign one in Settings." + } + return "Press \(shortcut.displayString) anywhere to grab text without opening this window." + } + private var copyToggle: some View { - Toggle(isOn: $copyAutomatically) { + Toggle(isOn: $controller.copyAutomatically) { Text("Copy automatically") .font(.system(size: layout.fieldFontSize, weight: .medium)) .foregroundStyle(Color.primary) } .toggleStyle(.checkbox) .frame(maxWidth: .infinity, alignment: .leading) - .onChange(of: copyAutomatically) { _, newValue in - AppDefaults.shared.set(newValue, forKey: DefaultsKey.grabTextCopyAutomatically) - } } // MARK: - Result area @@ -114,9 +168,8 @@ public struct GrabTextWindowView: View { Spacer() - actionButton(title: "Copy", systemImage: "doc.on.doc", isEnabled: !recognizedText.isEmpty) { - copyToPasteboard(recognizedText) - statusMessage = "Copied to clipboard." + actionButton(title: "Copy", systemImage: "doc.on.doc", isEnabled: !controller.recognizedText.isEmpty) { + controller.copyRecognizedText() } } } @@ -125,8 +178,8 @@ public struct GrabTextWindowView: View { @ViewBuilder private var resultBox: some View { ScrollView { - if recognizedText.isEmpty { - Text(statusMessage) + if controller.recognizedText.isEmpty { + Text(controller.statusMessage) .font(.system(size: layout.fieldFontSize, weight: .medium)) .foregroundStyle(Color.secondary) .multilineTextAlignment(.leading) @@ -134,7 +187,7 @@ public struct GrabTextWindowView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(layout.cardPadding) } else { - Text(recognizedText) + Text(controller.recognizedText) .font(.system(size: layout.fieldFontSize, weight: .regular, design: .default)) .foregroundStyle(Color.primary) .textSelection(.enabled) @@ -155,10 +208,11 @@ public struct GrabTextWindowView: View { } private var countSummary: String { - let chars = recognizedText.count - let lines = recognizedText.isEmpty + let text = controller.recognizedText + let chars = text.count + let lines = text.isEmpty ? 0 - : recognizedText.split(separator: "\n", omittingEmptySubsequences: false).count + : text.split(separator: "\n", omittingEmptySubsequences: false).count return "\(chars) chars · \(lines) lines" } @@ -190,101 +244,167 @@ public struct GrabTextWindowView: View { .disabled(!isEnabled) .opacity(isEnabled ? 1 : 0.4) } +} - // MARK: - Actions +/// The settings sheet: recording the global capture shortcut, the reasons it might not be working, +/// and the Screen Recording grant the capture itself depends on. +private struct GrabTextSettingsView: View { + @ObservedObject var controller: GrabTextController + @StateObject private var recorder = ShortcutRecorder() + @State private var notice: String? + var onClose: () -> Void + + /// The recorder is keyed by a slot token; Grab Text has exactly one. + private enum Slot: Hashable { case captureShortcut } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("Grab Text Settings") + .font(.system(size: 16, weight: .bold)) + Spacer() + Button { + recorder.cancel() + onClose() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .frame(width: 24, height: 24) + } + .buttonStyle(.plain) + } - private func startGrab() { - guard !isGrabbing else { - return - } + shortcutRow - isGrabbing = true - recognizedText = "" - statusMessage = "Drag to select the region containing text…" - - Task { - let result = await GrabTextRunner.grabRegion() - // Back on the main actor here — safe to touch @State. - isGrabbing = false - - switch result { - case .cancelled: - statusMessage = "Grab cancelled. Click Grab Text to try again." - case let .failure(reason): - statusMessage = reason - case let .success(text): - if text.isEmpty { - statusMessage = "No text found in that region. Try again." - } else { - recognizedText = text - if copyAutomatically { - copyToPasteboard(text) - statusMessage = "Copied to clipboard." - } else { - statusMessage = "" - } - } + if let message = problemMessage { + Text(message) + .font(.system(size: 11)) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) } - } - } - private func copyToPasteboard(_ string: String) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(string, forType: .string) - } + Text("Click the shortcut, then press the new keys. Esc cancels.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + + Divider() - // Reads the toggle's stored value, defaulting to `true` even if unregistered. - private static func initialCopyAutomatically() -> Bool { - if AppDefaults.shared.object(forKey: DefaultsKey.grabTextCopyAutomatically) == nil { - return true + permissionSection + + Spacer(minLength: 0) } - return AppDefaults.shared.bool(forKey: DefaultsKey.grabTextCopyAutomatically) + .padding(20) + .frame(width: GrabTextSizing.settingsSize().width, height: GrabTextSizing.settingsSize().height) + .onAppear { controller.refreshEnvironment() } + .onDisappear { recorder.cancel() } } -} -/// 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. -private enum GrabTextRunner { - enum Outcome: Sendable { - case success(String) - case cancelled - case failure(String) - } + private var shortcutRow: some View { + HStack(spacing: 10) { + Text("Capture shortcut") + .font(.system(size: 13, weight: .semibold)) - static func grabRegion() async -> Outcome { - await Task.detached(priority: .userInitiated) { () -> Outcome in - let tempURL = FileManager.default.temporaryDirectory - .appendingPathComponent("dmonte-grabtext-\(UUID().uuidString).png") + Spacer() - defer { - try? FileManager.default.removeItem(at: tempURL) + Button { + beginRecording() + } label: { + Text(shortcutLabel) + .font(.system(size: 13, weight: .medium, design: recorder.isRecording(Slot.captureShortcut) ? .default : .rounded)) + .foregroundStyle(shortcutTint) + .padding(.horizontal, 10) + .padding(.vertical, 3) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(recorder.isRecording(Slot.captureShortcut) ? Color.accentColor.opacity(0.15) : Color.primary.opacity(0.07)) + ) } + .buttonStyle(.plain) + .help("Click to record a new capture shortcut") - 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] + Button("Clear") { + recorder.cancel() + notice = nil + controller.disableShortcut() + } + .font(.system(size: 11)) + .disabled(controller.shortcut == nil) - do { - try process.run() - } catch { - return .failure("Couldn't start screen capture: \(error.localizedDescription)") + Button("Default") { + recorder.cancel() + notice = nil + controller.resetShortcut() } + .font(.system(size: 11)) + } + } - process.waitUntilExit() + private var shortcutLabel: String { + if recorder.isRecording(Slot.captureShortcut) { return "Press keys…" } + return controller.shortcut?.displayString ?? "Off" + } - // screencapture writes no file when the user presses Escape to cancel. - guard FileManager.default.fileExists(atPath: tempURL.path) else { - return .cancelled + private var shortcutTint: Color { + if recorder.isRecording(Slot.captureShortcut) { return .accentColor } + if controller.registrationFailure != nil { return .orange } + return .primary.opacity(0.85) + } + + /// The first thing standing between the user and a working shortcut, if anything is. + private var problemMessage: String? { + if let notice { return notice } + if controller.secureInputBlocked { + return "macOS Secure Input is blocking all global shortcuts right now (a password field, the lock screen, or a stuck loginwindow). They resume automatically when it ends." + } + if let status = controller.registrationFailure { + return "macOS refused this shortcut (error \(status)) — another app already owns it exclusively. Pick a different combination." + } + return nil + } + + @ViewBuilder + private var permissionSection: some View { + VStack(alignment: .leading, spacing: 8) { + Label( + controller.hasScreenRecordingAccess ? "Screen Recording access granted" : "Screen Recording access needed", + systemImage: controller.hasScreenRecordingAccess ? "checkmark.shield" : "lock.shield" + ) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(controller.hasScreenRecordingAccess ? Color.secondary : Color.orange) + + Text("Grabbing text means photographing part of your screen, which macOS gates behind Screen Recording. Without it a capture comes back blank rather than failing outright, so Grab Text says so instead of reporting \u{201C}no text found\u{201D}.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if !controller.hasScreenRecordingAccess { + Button("Open Screen Recording Settings") { + controller.requestScreenRecordingAccess() + } + .font(.system(size: 12, weight: .medium)) } + } + } - let lines = GrabTextKit.recognizeText(in: tempURL) - let joined = lines.joined(separator: "\n") - .trimmingCharacters(in: .whitespacesAndNewlines) - return .success(joined) - }.value + private func beginRecording() { + notice = nil + if recorder.isRecording(Slot.captureShortcut) { + recorder.cancel() + return + } + recorder.begin( + for: Slot.captureShortcut, + suspendHotKeys: { controller.suspendHotKeyForRecording() }, + resumeHotKeys: { controller.resumeHotKeyAfterRecording() } + ) { shortcut in + guard let shortcut else { return } // Esc — cancelled + switch controller.assignShortcut(shortcut) { + case .assigned: + notice = nil + case .needsModifiers: + notice = "Add at least one of ⌘ ⌃ ⌥ (function keys may stand alone)." + } + } } } @@ -297,7 +417,7 @@ private struct GrabTextLayout { var windowSize: NSSize { GrabTextSizing.preferredSize() } - var contentSpacing: CGFloat { 12 * scale } + var contentSpacing: CGFloat { 10 * scale } var contentHorizontalPadding: CGFloat { 24 * scale } var contentBottomPadding: CGFloat { 18 * scale } @@ -310,7 +430,7 @@ private struct GrabTextLayout { var previewTopPadding: CGFloat { 6 * scale } - var resultHeight: CGFloat { 200 * scale } + var resultHeight: CGFloat { 180 * scale } var fieldCornerRadius: CGFloat { 8 * scale } var fieldFontSize: CGFloat { 13 * scale } var captionFontSize: CGFloat { 11 * scale } diff --git a/Sources/DMonteCore/ShortcutRecorder.swift b/Sources/DMonteCore/ShortcutRecorder.swift new file mode 100644 index 0000000..2b86202 --- /dev/null +++ b/Sources/DMonteCore/ShortcutRecorder.swift @@ -0,0 +1,114 @@ +import AppKit +import SwiftUI + +/// Captures the next keyDown while the user records a custom shortcut. A local NSEvent monitor +/// sees the panel's key events (the popover panel can become key); Esc cancels; the event is +/// swallowed so the recorded keystroke doesn't also type into the UI. +/// +/// The "which slot am I recording" token is `AnyHashable` rather than one tool's enum because more +/// than one tool records shortcuts and they disagree about what a slot is — Window Manager has one +/// per `WindowAction`, Grab Text has exactly one. Sharing the recorder keeps the parts that are +/// easy to get wrong (Esc handling, the auto-cancel when the panel resigns key, bracketing the +/// live Carbon hotkeys around the capture) in a single place instead of copied per tool. +@MainActor +final class ShortcutRecorder: ObservableObject { + /// The slot currently being recorded, or nil when idle. + @Published private(set) var recordingToken: AnyHashable? + + private var monitor: Any? + private var resignObserver: NSObjectProtocol? + private var onResumeHotKeys: (() -> Void)? + + /// Whether `token` is the slot currently being recorded. + func isRecording(_ token: Token) -> Bool { + recordingToken == AnyHashable(token) + } + + /// Starts recording for `token`. `onCapture` receives the captured shortcut, or nil when + /// the user cancels with Esc. Recording also auto-cancels when the popover panel resigns + /// key (it is ordered out without tearing down the SwiftUI hierarchy, so `onDisappear` + /// alone can't be relied on to clean the monitor up). + /// + /// `suspendHotKeys`/`resumeHotKeys` bracket the capture: the tool's global hotkeys are released + /// while recording so Carbon doesn't swallow a combination the recorder is trying to read + /// (otherwise pressing e.g. ⌃⌥→ just fires Right Half and the monitor never sees it), and are + /// re-registered on every exit path (capture, Esc, or the panel losing key). + func begin( + for token: Token, + suspendHotKeys: @escaping () -> Void = {}, + resumeHotKeys: @escaping () -> Void = {}, + onCapture: @escaping (WindowShortcut?) -> Void + ) { + cancel() // ends any prior recording (running its own resume) before we suspend again + onResumeHotKeys = resumeHotKeys + recordingToken = AnyHashable(token) + suspendHotKeys() + NSApp.keyWindow?.makeFirstResponder(nil) + resignObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.cancel() + } + } + monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in + guard let self else { return event } + let escapeKeyCode: UInt16 = 53 + if event.keyCode == escapeKeyCode, event.modifierFlags.intersection(.deviceIndependentFlagsMask).isEmpty { + self.end() + onCapture(nil) + } else { + let shortcut = Self.shortcut(from: event) + self.end() + onCapture(shortcut) + } + return nil // swallow the keystroke + } + } + + static func shortcut(from event: NSEvent) -> WindowShortcut { + WindowShortcut( + keyCode: normalizedKeyCode(UInt32(event.keyCode)), + modifiers: carbonModifiers(from: event.modifierFlags) + ) + } + + static func normalizedKeyCode(_ keyCode: UInt32) -> UInt32 { + keyCode == HotKeyCode.keypadEnter ? HotKeyCode.returnKey : keyCode + } + + /// Stops recording without capturing. + func cancel() { + end() + } + + private func end() { + if let monitor { + NSEvent.removeMonitor(monitor) + self.monitor = nil + } + if let resignObserver { + NotificationCenter.default.removeObserver(resignObserver) + self.resignObserver = nil + } + recordingToken = nil + // Re-register the global hotkeys we released for capture — after the local monitor is gone, + // and on every exit path. Cleared first so a re-entrant begin() can't double-resume. + let resume = onResumeHotKeys + onResumeHotKeys = nil + resume?() + } + + /// AppKit modifier flags → Carbon modifier mask (the format `RegisterEventHotKey` wants). + static func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 { + var mask: UInt32 = 0 + let device = flags.intersection(.deviceIndependentFlagsMask) + if device.contains(.command) { mask |= HotKeyModifier.command } + if device.contains(.shift) { mask |= HotKeyModifier.shift } + if device.contains(.option) { mask |= HotKeyModifier.option } + if device.contains(.control) { mask |= HotKeyModifier.control } + return mask + } +} diff --git a/Sources/DMonteCore/WindowManagerKit.swift b/Sources/DMonteCore/WindowManagerKit.swift index b8639d1..df08347 100644 --- a/Sources/DMonteCore/WindowManagerKit.swift +++ b/Sources/DMonteCore/WindowManagerKit.swift @@ -70,7 +70,8 @@ public enum WindowAction: String, CaseIterable, Sendable, Identifiable { } } -/// Carbon virtual key codes used for the default shortcuts. +/// Carbon virtual key codes used for the default shortcuts. Shared across the tools that claim +/// global hotkeys, not only Window Manager — hence letters that no window action uses. public enum HotKeyCode { public static let left: UInt32 = 0x7B public static let right: UInt32 = 0x7C @@ -79,6 +80,7 @@ public enum HotKeyCode { public static let returnKey: UInt32 = 0x24 public static let keypadEnter: UInt32 = 0x4C public static let c: UInt32 = 0x08 + public static let g: UInt32 = 0x05 } /// Carbon modifier masks (mirrors Carbon's `cmdKey`/`shiftKey`/`optionKey`/`controlKey` without diff --git a/Sources/DMonteCore/WindowManagerView.swift b/Sources/DMonteCore/WindowManagerView.swift index f6f9ea9..730784d 100644 --- a/Sources/DMonteCore/WindowManagerView.swift +++ b/Sources/DMonteCore/WindowManagerView.swift @@ -1,107 +1,6 @@ import AppKit import SwiftUI -/// Captures the next keyDown while the user records a custom shortcut. A local NSEvent monitor -/// sees the panel's key events (the popover panel can become key); Esc cancels; the event is -/// swallowed so the recorded keystroke doesn't also type into the UI. -@MainActor -final class ShortcutRecorder: ObservableObject { - /// The action currently being recorded, or nil when idle. - @Published private(set) var recordingAction: WindowAction? - - private var monitor: Any? - private var resignObserver: NSObjectProtocol? - private var onResumeHotKeys: (() -> Void)? - - /// Starts recording for `action`. `onCapture` receives the captured shortcut, or nil when - /// the user cancels with Esc. Recording also auto-cancels when the popover panel resigns - /// key (it is ordered out without tearing down the SwiftUI hierarchy, so `onDisappear` - /// alone can't be relied on to clean the monitor up). - /// - /// `suspendHotKeys`/`resumeHotKeys` bracket the capture: the global snap hotkeys are released - /// while recording so Carbon doesn't swallow a combination the recorder is trying to read - /// (otherwise pressing e.g. ⌃⌥→ just fires Right Half and the monitor never sees it), and are - /// re-registered on every exit path (capture, Esc, or the panel losing key). - func begin( - for action: WindowAction, - suspendHotKeys: @escaping () -> Void = {}, - resumeHotKeys: @escaping () -> Void = {}, - onCapture: @escaping (WindowShortcut?) -> Void - ) { - cancel() // ends any prior recording (running its own resume) before we suspend again - onResumeHotKeys = resumeHotKeys - recordingAction = action - suspendHotKeys() - NSApp.keyWindow?.makeFirstResponder(nil) - resignObserver = NotificationCenter.default.addObserver( - forName: NSWindow.didResignKeyNotification, - object: nil, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { - self?.cancel() - } - } - monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in - guard let self else { return event } - let escapeKeyCode: UInt16 = 53 - if event.keyCode == escapeKeyCode, event.modifierFlags.intersection(.deviceIndependentFlagsMask).isEmpty { - self.end() - onCapture(nil) - } else { - let shortcut = Self.shortcut(from: event) - self.end() - onCapture(shortcut) - } - return nil // swallow the keystroke - } - } - - static func shortcut(from event: NSEvent) -> WindowShortcut { - WindowShortcut( - keyCode: normalizedKeyCode(UInt32(event.keyCode)), - modifiers: carbonModifiers(from: event.modifierFlags) - ) - } - - static func normalizedKeyCode(_ keyCode: UInt32) -> UInt32 { - keyCode == HotKeyCode.keypadEnter ? HotKeyCode.returnKey : keyCode - } - - /// Stops recording without capturing. - func cancel() { - end() - } - - private func end() { - if let monitor { - NSEvent.removeMonitor(monitor) - self.monitor = nil - } - if let resignObserver { - NotificationCenter.default.removeObserver(resignObserver) - self.resignObserver = nil - } - recordingAction = nil - // Re-register the global hotkeys we released for capture — after the local monitor is gone, - // and on every exit path. Cleared first so a re-entrant begin() can't double-resume. - let resume = onResumeHotKeys - onResumeHotKeys = nil - resume?() - } - - /// AppKit modifier flags → Carbon modifier mask (the format `RegisterEventHotKey` wants). - static func carbonModifiers(from flags: NSEvent.ModifierFlags) -> UInt32 { - var mask: UInt32 = 0 - let device = flags.intersection(.deviceIndependentFlagsMask) - if device.contains(.command) { mask |= HotKeyModifier.command } - if device.contains(.shift) { mask |= HotKeyModifier.shift } - if device.contains(.option) { mask |= HotKeyModifier.option } - if device.contains(.control) { mask |= HotKeyModifier.control } - return mask - } -} - /// The Window Manager popover: a grid of snap tiles that resize the target app's focused window /// (the app snapshotted when the popover opened — shown as "Will snap: X"), an /// Accessibility-permission banner when the grant is missing, and a collapsible @@ -375,7 +274,7 @@ public struct WindowManagerPopoverView: View { } private func shortcutRow(_ action: WindowAction) -> some View { - let isRecording = recorder.recordingAction == action + let isRecording = recorder.isRecording(action) let failed = controller.registrationFailures[action] != nil return VStack(alignment: .leading, spacing: s(1)) { @@ -415,7 +314,7 @@ public struct WindowManagerPopoverView: View { private func beginRecording(_ action: WindowAction) { shortcutNotice = nil - if recorder.recordingAction == action { + if recorder.isRecording(action) { recorder.cancel() return } diff --git a/Sources/DMonteGrabTextApp/GrabTextAppDelegate.swift b/Sources/DMonteGrabTextApp/GrabTextAppDelegate.swift index c3c7696..a7ae37f 100644 --- a/Sources/DMonteGrabTextApp/GrabTextAppDelegate.swift +++ b/Sources/DMonteGrabTextApp/GrabTextAppDelegate.swift @@ -4,6 +4,8 @@ import SwiftUI @MainActor final class GrabTextAppDelegate: NSObject, NSApplicationDelegate { + private let controller = GrabTextController() + private var statusItem: HelperStatusItem? private var windowHost: HelperWindowHost? @@ -15,9 +17,10 @@ final class GrabTextAppDelegate: NSObject, NSApplicationDelegate { title: "DMonte Grab Text", sizing: .fixed(preferredSize: { GrabTextSizing.preferredSize() }) ), - makeContent: { [weak self] in + makeContent: { [controller, weak self] in NSHostingController( rootView: GrabTextWindowView( + controller: controller, onQuit: { self?.quitGrabText() } @@ -45,6 +48,15 @@ final class GrabTextAppDelegate: NSObject, NSApplicationDelegate { host.observeShowNotification(named: grabTextShowWindowNotification) + // A hotkey grab deliberately leaves the window closed — that is the point of the shortcut. + // It only comes forward when the result needs the user: a failure, a missing permission, or + // recognized text that "copy automatically" was off for and would otherwise be lost. + controller.onHotKeyResultNeedsAttention = { [weak self] in + NSApp.activate(ignoringOtherApps: true) + self?.windowHost?.show(relativeTo: self?.statusItem?.button) + } + controller.registerHotKey() + if CommandLine.arguments.contains("--open") || statusItem == nil { DispatchQueue.main.async { [weak self] in self?.windowHost?.show() @@ -53,6 +65,7 @@ final class GrabTextAppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + controller.unregisterHotKey() windowHost?.tearDownForTermination() statusItem?.remove() } diff --git a/Tests/DMonteCoreTests/GrabTextHotKeyTests.swift b/Tests/DMonteCoreTests/GrabTextHotKeyTests.swift new file mode 100644 index 0000000..7826f5a --- /dev/null +++ b/Tests/DMonteCoreTests/GrabTextHotKeyTests.swift @@ -0,0 +1,386 @@ +import AppKit +import XCTest +@testable import DMonteCore + +/// Headless coverage for Grab Text's global capture hotkey: how the shortcut is persisted and +/// resolved, how a capture attempt is classified (so a missing Screen Recording grant never +/// masquerades as "no text found"), and how the controller behaves when the hotkey fires — the +/// in-flight guard, the copy-automatically preference, and a registration macOS refuses. +/// +/// Nothing here touches Carbon or `screencapture`: the controller takes an injectable registrar +/// and an injectable capture, which is what makes the failure paths reachable at all. A real +/// `RegisterEventHotKey` can neither be made to fail on demand nor be safely left registered by a +/// test process. +final class GrabTextHotKeyTests: XCTestCase { + + private func scratchDefaults() -> UserDefaults { + let suite = "test.grabtext.hotkey.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + addTeardownBlock { UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite) } + return defaults + } + + // MARK: - Shortcut persistence + + func testEffectiveShortcutFallsBackToTheBuiltInDefault() { + let store = GrabTextShortcutStore(defaults: scratchDefaults()) + XCTAssertEqual(store.effectiveShortcut(), GrabTextShortcutStore.defaultShortcut) + XCTAssertEqual(GrabTextShortcutStore.defaultShortcut.displayString, "⌃⌥G") + } + + func testSavedShortcutRoundTripsThroughDefaults() { + let defaults = scratchDefaults() + let custom = WindowShortcut(keyCode: 0x11, modifiers: HotKeyModifier.command | HotKeyModifier.shift) // ⇧⌘T + GrabTextShortcutStore(defaults: defaults).save(custom) + + // A fresh store over the same suite is what the next launch sees. + XCTAssertEqual(GrabTextShortcutStore(defaults: defaults).effectiveShortcut(), custom) + } + + func testDisableIsDistinctFromNeverHavingChosen() { + let defaults = scratchDefaults() + let store = GrabTextShortcutStore(defaults: defaults) + + store.disable() + XCTAssertNil(store.effectiveShortcut(), "an explicit disable must not fall back to the default") + XCTAssertEqual( + defaults.string(forKey: DefaultsKey.grabTextCaptureShortcut), + GrabTextShortcutStore.disabledStorageValue + ) + + store.reset() + XCTAssertEqual(store.effectiveShortcut(), GrabTextShortcutStore.defaultShortcut) + } + + func testCorruptStoredValueFallsBackToTheDefaultRatherThanNoHotKey() { + let defaults = scratchDefaults() + defaults.set("not-a-shortcut", forKey: DefaultsKey.grabTextCaptureShortcut) + XCTAssertEqual(GrabTextShortcutStore(defaults: defaults).effectiveShortcut(), GrabTextShortcutStore.defaultShortcut) + } + + func testDefaultShortcutIsSafeToClaimGlobally() { + XCTAssertTrue(GrabTextShortcutStore.defaultShortcut.isUsableGlobally) + } + + // MARK: - Capture classification + + func testCaptureWithAccessReportsTextAndCancellation() { + XCTAssertEqual( + GrabTextKit.classifyCapture(fileWritten: true, recognizedText: "hello", hasScreenRecordingAccess: true), + .success("hello") + ) + // A grab of a genuinely text-free region is a success with nothing in it, not a failure. + XCTAssertEqual( + GrabTextKit.classifyCapture(fileWritten: true, recognizedText: "", hasScreenRecordingAccess: true), + .success("") + ) + XCTAssertEqual( + GrabTextKit.classifyCapture(fileWritten: false, recognizedText: "", hasScreenRecordingAccess: true), + .cancelled + ) + } + + func testCaptureWithoutAccessReportsThePermissionRatherThanAnEmptyResult() { + // No file at all: TCC blocked the capture outright. + XCTAssertEqual( + GrabTextKit.classifyCapture(fileWritten: false, recognizedText: "", hasScreenRecordingAccess: false), + .needsScreenRecordingPermission + ) + // A file was written but everything in it was redacted — the empty-result trap. + XCTAssertEqual( + GrabTextKit.classifyCapture(fileWritten: true, recognizedText: "", hasScreenRecordingAccess: false), + .needsScreenRecordingPermission + ) + } + + func testTextThatCameBackDespiteAMissingGrantIsStillASuccess() { + // The preflight can lag a grant given during this very capture; believe the pixels. + XCTAssertEqual( + GrabTextKit.classifyCapture(fileWritten: true, recognizedText: "hi", hasScreenRecordingAccess: false), + .success("hi") + ) + } + + // MARK: - Controller: dispatch through the hotkey + + /// Builds a controller whose hotkey registration is faked, and hands back the registered + /// handler so a test can "press" the shortcut. + @MainActor + private func makeController( + defaults: UserDefaults, + capture: @escaping GrabTextCapture, + hasScreenRecordingAccess: Bool = true, + registrarError: GlobalHotKeyRegistrationError? = nil + ) -> (controller: GrabTextController, press: () -> Void) { + let box = HandlerBox() + let controller = GrabTextController( + defaults: defaults, + capture: capture, + screenRecordingAccess: { hasScreenRecordingAccess }, + registrar: { _, _, handler in + if let registrarError { throw registrarError } + box.handler = handler + return NSObject() + } + ) + return (controller, { box.handler?() }) + } + + /// Holds the registered hotkey handler. A class so the registrar closure can write to it. + @MainActor + private final class HandlerBox { + var handler: (@Sendable () -> Void)? + } + + @MainActor + func testHotKeyPressStartsACaptureAndCopiesAutomatically() async { + let counter = CallCounter() + let (controller, press) = makeController( + defaults: scratchDefaults(), + capture: { + await counter.increment() + return .success("grabbed text") + } + ) + controller.registerHotKey() + XCTAssertNil(controller.registrationFailure) + + // The handler runs on the main run loop, exactly as Carbon delivers it, and starts the + // capture synchronously — so `isGrabbing` is already true when it returns. + press() + XCTAssertTrue(controller.isGrabbing) + + await settle() + let captures = await counter.value() + XCTAssertEqual(captures, 1) + XCTAssertFalse(controller.isGrabbing) + XCTAssertEqual(controller.recognizedText, "grabbed text") + XCTAssertEqual(controller.statusMessage, "Copied to clipboard.") + } + + @MainActor + func testSecondCaptureIsIgnoredWhileOneIsInFlight() async { + let counter = CallCounter() + let (controller, press) = makeController( + defaults: scratchDefaults(), + capture: { + await counter.increment() + return .success("once") + } + ) + controller.registerHotKey() + + let first = controller.startGrab() + XCTAssertNotNil(first) + // Both a second hotkey press and a second button press must be dropped: the region selector + // from the first is still up. + press() + XCTAssertNil(controller.startGrab(), "a second grab while one is in flight must be refused") + + await first?.value + let afterFirst = await counter.value() + XCTAssertEqual(afterFirst, 1, "only one capture may ever be running") + + // Once it finishes, the tool is available again. + XCTAssertNotNil(controller.startGrab()) + await settle() + let afterSecond = await counter.value() + XCTAssertEqual(afterSecond, 2) + } + + @MainActor + func testRegistrationFailureIsSurfacedRatherThanSwallowed() { + let (controller, press) = makeController( + defaults: scratchDefaults(), + capture: { .success("never runs") }, + registrarError: .registrationFailed(-9878) // eventHotKeyExistsErr + ) + controller.registerHotKey() + + XCTAssertEqual(controller.registrationFailure, -9878, "the OSStatus must reach the UI") + press() // no handler was ever registered, so this is a no-op + XCTAssertFalse(controller.isGrabbing) + } + + @MainActor + func testRegistrationFailureClearsAfterAWorkingShortcutIsAssigned() { + let defaults = scratchDefaults() + let attempts = FailureSchedule(failFirst: 1) + let controller = GrabTextController( + defaults: defaults, + capture: { .cancelled }, + screenRecordingAccess: { true }, + registrar: { _, _, _ in + if attempts.shouldFail() { throw GlobalHotKeyRegistrationError.registrationFailed(-9878) } + return NSObject() + } + ) + controller.registerHotKey() + XCTAssertEqual(controller.registrationFailure, -9878) + + let replacement = WindowShortcut(keyCode: 0x11, modifiers: HotKeyModifier.command | HotKeyModifier.control) + XCTAssertEqual(controller.assignShortcut(replacement), .assigned) + XCTAssertNil(controller.registrationFailure, "re-registering under a free combination must clear the alarm") + XCTAssertEqual(controller.shortcut, replacement) + } + + @MainActor + func testDisabledShortcutRegistersNothingAndReportsNoFailure() { + var registrarCalls = 0 + let controller = GrabTextController( + defaults: scratchDefaults(), + capture: { .cancelled }, + screenRecordingAccess: { true }, + registrar: { _, _, _ in + registrarCalls += 1 + return NSObject() + } + ) + controller.disableShortcut() + controller.registerHotKey() + + XCTAssertNil(controller.shortcut) + XCTAssertEqual(registrarCalls, 0, "an off shortcut must not claim anything") + XCTAssertNil(controller.registrationFailure, "off is a choice, not a failure") + } + + @MainActor + func testAssignRefusesCombinationsThatWouldSwallowTyping() { + let (controller, _) = makeController(defaults: scratchDefaults(), capture: { .cancelled }) + + XCTAssertEqual(controller.assignShortcut(WindowShortcut(keyCode: HotKeyCode.g, modifiers: 0)), .needsModifiers) + XCTAssertEqual( + controller.assignShortcut(WindowShortcut(keyCode: HotKeyCode.g, modifiers: HotKeyModifier.shift)), + .needsModifiers + ) + XCTAssertEqual(controller.shortcut, GrabTextShortcutStore.defaultShortcut, "a refused assignment changes nothing") + } + + @MainActor + func testAssignedShortcutSurvivesARelaunch() { + let defaults = scratchDefaults() + let (controller, _) = makeController(defaults: defaults, capture: { .cancelled }) + let custom = WindowShortcut(keyCode: 0x0E, modifiers: HotKeyModifier.command | HotKeyModifier.control) // ⌃⌘E + XCTAssertEqual(controller.assignShortcut(custom), .assigned) + + let (relaunched, _) = makeController(defaults: defaults, capture: { .cancelled }) + XCTAssertEqual(relaunched.shortcut, custom) + } + + // MARK: - Controller: what the user is told + + @MainActor + func testMissingScreenRecordingAccessIsExplainedInsteadOfLookingEmpty() async { + let (controller, press) = makeController( + defaults: scratchDefaults(), + capture: { .needsScreenRecordingPermission }, + hasScreenRecordingAccess: false + ) + controller.registerHotKey() + + var pulledWindowForward = false + controller.onHotKeyResultNeedsAttention = { pulledWindowForward = true } + + press() + await settle() + + XCTAssertEqual(controller.statusMessage, GrabTextController.permissionMessage) + XCTAssertTrue(controller.statusMessage.contains("Screen Recording")) + XCTAssertTrue(controller.recognizedText.isEmpty) + XCTAssertFalse(controller.hasScreenRecordingAccess) + XCTAssertTrue(pulledWindowForward, "a hotkey grab has no window on screen, so the problem must fetch one") + } + + @MainActor + func testHotKeyGrabStaysInvisibleWhenItAutoCopies() async { + let (controller, press) = makeController(defaults: scratchDefaults(), capture: { .success("quiet") }) + controller.registerHotKey() + controller.copyAutomatically = true + + var pulledWindowForward = false + controller.onHotKeyResultNeedsAttention = { pulledWindowForward = true } + + press() + await settle() + + XCTAssertFalse(pulledWindowForward, "the whole point of the shortcut is not interrupting the user") + } + + @MainActor + func testHotKeyGrabShowsTheWindowWhenNothingWasCopied() async { + let defaults = scratchDefaults() + defaults.set(false, forKey: DefaultsKey.grabTextCopyAutomatically) + let (controller, press) = makeController(defaults: defaults, capture: { .success("kept in the window") }) + controller.registerHotKey() + XCTAssertFalse(controller.copyAutomatically, "the existing preference must be honoured by the hotkey path") + + var pulledWindowForward = false + controller.onHotKeyResultNeedsAttention = { pulledWindowForward = true } + + press() + await settle() + + XCTAssertEqual(controller.recognizedText, "kept in the window") + XCTAssertTrue(pulledWindowForward, "text nobody copied only exists in the window, so the window has to appear") + } + + @MainActor + func testCancelledHotKeyGrabDoesNotBotherTheUser() async { + let (controller, press) = makeController(defaults: scratchDefaults(), capture: { .cancelled }) + controller.registerHotKey() + + var pulledWindowForward = false + controller.onHotKeyResultNeedsAttention = { pulledWindowForward = true } + + press() + await settle() + + XCTAssertFalse(pulledWindowForward, "they pressed Esc; they know what happened") + XCTAssertTrue(controller.statusMessage.contains("cancelled")) + } + + @MainActor + func testCopyAutomaticallyIsPersistedOnChange() { + let defaults = scratchDefaults() + let (controller, _) = makeController(defaults: defaults, capture: { .cancelled }) + XCTAssertTrue(controller.copyAutomatically, "unset means on — the tool exists to fill the clipboard") + + controller.copyAutomatically = false + XCTAssertFalse(defaults.bool(forKey: DefaultsKey.grabTextCopyAutomatically)) + + let (relaunched, _) = makeController(defaults: defaults, capture: { .cancelled }) + XCTAssertFalse(relaunched.copyAutomatically) + } + + // MARK: - Helpers + + /// Lets the capture task and its main-actor completion run. The capture closures under test + /// return immediately, so a couple of hops are enough; the loop just avoids depending on the + /// exact number. + @MainActor + private func settle() async { + for _ in 0..<20 { + await Task.yield() + } + } + + /// Counts capture invocations from the (non-isolated) capture closure. + private actor CallCounter { + private var count = 0 + func increment() { count += 1 } + func value() -> Int { count } + } + + /// Makes the first N registration attempts fail. Main-actor confined: the registrar runs there. + @MainActor + private final class FailureSchedule { + private var remaining: Int + init(failFirst: Int) { remaining = failFirst } + func shouldFail() -> Bool { + guard remaining > 0 else { return false } + remaining -= 1 + return true + } + } +} From f31fa99bdab945261a29afb84859c387703e0cd4 Mon Sep 17 00:00:00 2001 From: Mac Studio M4MAX DMonte Date: Tue, 21 Jul 2026 23:15:19 +0530 Subject: [PATCH 2/2] Grab Text: keep the shortcut's hint true after launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hint under the Grab Text button reports two things macOS changes without announcing: Secure Event Input, which silently swallows every hotkey press while it is held, and the Screen Recording grant. Both were read once and then never again. `refreshEnvironment()` was reachable only from `registerHotKey()` and two `onAppear`s, and `HelperWindowHost` builds its hosting controller exactly once and never tears it down — so the window's `onAppear` fires at launch and never again, however many times the window is shown. The result was a hint that could be permanently wrong in both directions: a user whose ⌃⌥G had stopped working opened the window to find out why and read "Press ⌃⌥G anywhere to grab text", while a helper that launched during a stuck `loginwindow` warned about Secure Input forever with a shortcut that worked fine. The host now reports when its window starts and stops being visible — `show()` reveals it, occlusion and closing end it — and Grab Text re-reads the environment on that edge and on a 1s poll for as long as the window is on screen, which is the same shape Window Manager uses for the same flag. A poll rather than a notification because macOS posts none for either state, and visibility-scoped rather than always-on because nobody can read a hint on a window they cannot see, and this is an idle helper the rest of the time. The Secure Input read moved behind an injected closure so the staleness is testable at all: the system flag can neither be turned on from a test process nor read back at a moment of our choosing. Two smaller things the shortcut got wrong: A grab cancelled with Esc while the Screen Recording grant reads missing used to activate the app and yank the window forward on *every* press. While the grant is missing an Esc-cancel is indistinguishable from a capture TCC blocked (`classifyCapture` documents why it resolves the ambiguity that way), and the preflight is read in this process while `screencapture` runs in another, so the two can disagree after a grant given without relaunching. Saying it once is enough to send the user to Settings; saying it on every cancelled press is precisely the interruption the shortcut exists to avoid. The announcement re-arms when the grant comes back, so a later revocation is still reported. "Grabbed N characters. Press Copy to put them on the clipboard." was written to `statusMessage` in the one branch where the result box renders the recognised text instead of the status line — unreachable UI, and the only sentence telling a user with "Copy automatically" off that nothing had been copied. The controller now flags an uncopied result and the window shows the line above the text, where it is read. Suite: 792 → 799, 0 failures. Co-Authored-By: Claude Opus 4.8 --- Sources/DMonteCore/GrabTextController.swift | 100 +++++++++- Sources/DMonteCore/GrabTextView.swift | 30 ++- Sources/DMonteCore/HelperWindowHost.swift | 38 ++++ .../GrabTextAppDelegate.swift | 15 ++ .../DMonteCoreTests/GrabTextHotKeyTests.swift | 183 ++++++++++++++++++ 5 files changed, 352 insertions(+), 14 deletions(-) diff --git a/Sources/DMonteCore/GrabTextController.swift b/Sources/DMonteCore/GrabTextController.swift index b53568e..bb67f20 100644 --- a/Sources/DMonteCore/GrabTextController.swift +++ b/Sources/DMonteCore/GrabTextController.swift @@ -118,6 +118,12 @@ public final class GrabTextController: ObservableObject { /// Whether this process currently holds the Screen Recording grant that screen capture needs. @Published public private(set) var hasScreenRecordingAccess: Bool + /// Whether the current result is still only in this window — recognized, but never put on the + /// clipboard because "copy automatically" was off. The window renders the status line *beside* + /// the text on the strength of this, since the result box shows the text instead of the status + /// once there is one, and a sentence written to `statusMessage` there would never be seen. + @Published public private(set) var resultNeedsCopying = false + /// Called after a hotkey-started grab whose result the user still has to see or act on — a /// failure, a missing permission, or text that wasn't auto-copied. A hotkey grab deliberately /// leaves the window closed, so this is the only way that outcome reaches the user. @@ -132,11 +138,21 @@ public final class GrabTextController: ObservableObject { private let store: GrabTextShortcutStore private let capture: GrabTextCapture private let screenRecordingAccess: @Sendable () -> Bool + private let secureInput: @MainActor () -> Bool private let registrar: GrabTextHotKeyRegistrar /// Held only to keep the Carbon registration alive; releasing it unregisters. private var hotKey: AnyObject? + /// Re-reads the environment while the window is on screen; nil when it isn't. See + /// `beginEnvironmentMonitoring(interval:)`. + private var environmentTimer: Timer? + + /// Whether a hotkey grab has already pulled the window forward to report the missing Screen + /// Recording grant. Re-armed the moment the grant comes back, so a later revocation is + /// announced again. + private var hasAnnouncedMissingScreenRecording = false + /// Whether the owner has asked for a live hotkey. Assigning a shortcut re-registers only when /// it is true, so a controller that was never asked to claim a hotkey (every unit test, and any /// future embedding that only wants the OCR) never touches Carbon as a side effect. @@ -146,6 +162,7 @@ public final class GrabTextController: ObservableObject { defaults: UserDefaults? = nil, capture: @escaping GrabTextCapture = GrabTextCapturer.grabRegion, screenRecordingAccess: @escaping @Sendable () -> Bool = { ScreenRecordingAccess.isGranted }, + secureInput: @escaping @MainActor () -> Bool = { SecureInputState.isBlockingHotKeys }, registrar: @escaping GrabTextHotKeyRegistrar = GrabTextController.registerGlobalHotKey ) { let resolvedDefaults = defaults ?? AppDefaults.shared @@ -153,6 +170,7 @@ public final class GrabTextController: ObservableObject { self.store = GrabTextShortcutStore(defaults: resolvedDefaults) self.capture = capture self.screenRecordingAccess = screenRecordingAccess + self.secureInput = secureInput self.registrar = registrar // Default to copying: the tool's whole point is getting text onto the clipboard, and the // key may be unregistered in a scratch suite, where `bool(forKey:)` would read false. @@ -163,6 +181,11 @@ public final class GrabTextController: ObservableObject { self.hasScreenRecordingAccess = screenRecordingAccess() } + // No `deinit` teardown for `environmentTimer`: under Swift 6 a nonisolated deinit may not + // touch a @MainActor, non-Sendable property (the same constraint WindowManagerController + // documents). The timer captures only `[weak self]`, so it cannot keep the controller alive, + // and it is invalidated deterministically on the main actor when the window leaves the screen. + // MARK: - Hotkey /// The production registrar: a real Carbon claim through `GlobalHotKey`. Public only because @@ -181,7 +204,7 @@ public final class GrabTextController: ObservableObject { wantsHotKey = true hotKey = nil registrationFailure = nil - secureInputBlocked = SecureInputState.isBlockingHotKeys + secureInputBlocked = secureInput() guard let shortcut else { return } // deliberately off @@ -221,11 +244,54 @@ public final class GrabTextController: ObservableObject { if wantsHotKey { registerHotKey() } } - /// Re-reads the state that can change behind our back while the window is closed: Secure Input - /// and the Screen Recording grant. Neither posts a notification. + /// Re-reads the state that can change behind our back: Secure Input and the Screen Recording + /// grant. Neither posts a notification, so the only way to have it right is to look again. public func refreshEnvironment() { - secureInputBlocked = SecureInputState.isBlockingHotKeys - hasScreenRecordingAccess = screenRecordingAccess() + secureInputBlocked = secureInput() + updateScreenRecordingAccess() + } + + /// How often the environment is re-read while the window is on screen. Matches Window + /// Manager's permission poll; both reads are cheap local lookups. + public static let environmentPollInterval: TimeInterval = 1.0 + + /// Starts re-reading the environment on a timer, and reads it once immediately. Pair with + /// `endEnvironmentMonitoring()`; the owner drives both from the window's on-screen visibility. + /// + /// A poll rather than a view lifecycle hook because there is no view lifecycle to hang this + /// on: `HelperWindowHost` builds the hosting controller once and never tears it down, so the + /// root view's `onAppear` fires at launch and never again however many times the window is + /// shown. Sampling only at launch leaves the hint permanently wrong in both directions — no + /// warning while Secure Input silently eats every press, or a warning that outlives the + /// stuck `loginwindow` that caused it. And a poll rather than a notification because macOS + /// announces neither of these two states. + public func beginEnvironmentMonitoring(interval: TimeInterval = GrabTextController.environmentPollInterval) { + refreshEnvironment() + + environmentTimer?.invalidate() + let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + // Timers fire on the run loop that scheduled them, which is the main one here. + MainActor.assumeIsolated { + self?.refreshEnvironment() + } + } + environmentTimer = timer + // Common modes so the hint keeps updating while a menu is open or the window is dragged. + RunLoop.main.add(timer, forMode: .common) + } + + /// Stops the environment poll — the window is off screen, so nobody can read the hint. + public func endEnvironmentMonitoring() { + environmentTimer?.invalidate() + environmentTimer = nil + } + + /// Re-reads the Screen Recording grant. Regaining it re-arms the one-shot hotkey announcement: + /// a grant that is given and later revoked has to be reported again. + private func updateScreenRecordingAccess() { + let granted = screenRecordingAccess() + hasScreenRecordingAccess = granted + if granted { hasAnnouncedMissingScreenRecording = false } } private static func statusCode(for error: GlobalHotKeyRegistrationError) -> Int32 { @@ -287,6 +353,7 @@ public final class GrabTextController: ObservableObject { isGrabbing = true recognizedText = "" + resultNeedsCopying = false statusMessage = "Drag to select the region containing text…" return Task { @@ -297,7 +364,6 @@ public final class GrabTextController: ObservableObject { private func finish(_ outcome: GrabTextOutcome, source: GrabSource) { isGrabbing = false - hasScreenRecordingAccess = screenRecordingAccess() // Whether the user has to look at this. A hotkey grab that quietly landed on the clipboard // is the success case and must stay invisible; everything else has to surface somewhere. @@ -309,6 +375,16 @@ public final class GrabTextController: ObservableObject { needsAttention = false // they cancelled it; they know case .needsScreenRecordingPermission: statusMessage = Self.permissionMessage + // Say this once, not on every press. While the grant is missing, an Esc-cancel is + // indistinguishable from a capture TCC blocked (see `GrabTextKit.classifyCapture`), + // so surfacing it every time would steal activation from whatever the user was typing + // in each time they dismissed the crosshair — the precise interruption the shortcut + // exists to avoid. The preflight can also disagree with reality (it is read in this + // process while `screencapture` runs in another), which makes "every time" a way to + // be repeatedly wrong. Once is enough to send them to Settings; the window stays open + // behind them with the explanation on it. + needsAttention = !hasAnnouncedMissingScreenRecording + hasAnnouncedMissingScreenRecording = true case .failure(let reason): statusMessage = reason case .success(let text): @@ -322,12 +398,19 @@ public final class GrabTextController: ObservableObject { needsAttention = false } else { // Nothing was copied, so the text exists only in the window — which a hotkey - // grab left closed. Showing it is the only way the grab isn't wasted. + // grab left closed. Showing it is the only way the grab isn't wasted, and + // `resultNeedsCopying` is what gets this sentence rendered next to the result + // rather than behind it. + resultNeedsCopying = true statusMessage = "Grabbed \(text.count) characters. Press Copy to put them on the clipboard." } } } + // After the switch: regaining the grant re-arms the announcement above, so this must not + // run before the branch that consumes it. + updateScreenRecordingAccess() + if source == .hotKey, needsAttention { onHotKeyResultNeedsAttention?() } @@ -337,13 +420,14 @@ public final class GrabTextController: ObservableObject { public func copyRecognizedText() { guard !recognizedText.isEmpty else { return } Self.copyToPasteboard(recognizedText) + resultNeedsCopying = false statusMessage = "Copied to clipboard." } /// Asks for the Screen Recording grant and re-reads it. public func requestScreenRecordingAccess() { ScreenRecordingAccess.request() - hasScreenRecordingAccess = screenRecordingAccess() + updateScreenRecordingAccess() } static let permissionMessage = """ diff --git a/Sources/DMonteCore/GrabTextView.swift b/Sources/DMonteCore/GrabTextView.swift index c8ce47f..0ba4502 100644 --- a/Sources/DMonteCore/GrabTextView.swift +++ b/Sources/DMonteCore/GrabTextView.swift @@ -50,6 +50,9 @@ public struct GrabTextWindowView: View { } .frame(width: layout.windowSize.width, height: layout.windowSize.height) .frostedPanel(cornerRadius: 18) + // Fires exactly once, at launch: the hosting controller is built once and never torn + // down, so this is the first reading rather than the mechanism that keeps it true. The + // window host drives that — see `GrabTextController.beginEnvironmentMonitoring()`. .onAppear { controller.refreshEnvironment() } } @@ -187,12 +190,27 @@ public struct GrabTextWindowView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(layout.cardPadding) } else { - Text(controller.recognizedText) - .font(.system(size: layout.fieldFontSize, weight: .regular, design: .default)) - .foregroundStyle(Color.primary) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(layout.cardPadding) + VStack(alignment: .leading, spacing: layout.buttonRowSpacing / 2) { + // With a result on screen the box shows the text, so the status line has + // nowhere left to be read — and after a hotkey grab with "copy automatically" + // off it is carrying the one thing the user has to act on: nothing was copied. + // Above the text is the only place that sentence is ever seen. + if controller.resultNeedsCopying { + Text(controller.statusMessage) + .font(.system(size: layout.captionFontSize, weight: .semibold)) + .foregroundStyle(Color.orange) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Text(controller.recognizedText) + .font(.system(size: layout.fieldFontSize, weight: .regular, design: .default)) + .foregroundStyle(Color.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(layout.cardPadding) } } .frame(maxWidth: .infinity) diff --git a/Sources/DMonteCore/HelperWindowHost.swift b/Sources/DMonteCore/HelperWindowHost.swift index db7789a..a29c3df 100644 --- a/Sources/DMonteCore/HelperWindowHost.swift +++ b/Sources/DMonteCore/HelperWindowHost.swift @@ -53,11 +53,25 @@ public final class HelperWindowHost: NSObject, NSWindowDelegate { public private(set) var window: NSWindow? + /// Called when the window starts or stops being visible on screen: `show()` reveals it, and + /// occlusion hides it (another window covers it, the app is hidden, the user switches Space), + /// as does closing it. + /// + /// Exists because there is no view-side equivalent. `makeContent` runs exactly once, in + /// `configureWindow()`, and the hosting controller is never torn down, so the root view's + /// `onAppear` fires at launch and never again no matter how often the window is shown. A tool + /// whose UI reports state macOS changes without announcing it — a TCC grant, Secure Event + /// Input — has to re-read that state itself, and this is the edge to do it on. + public var onVisibilityChanged: (@MainActor (Bool) -> Void)? + private let configuration: Configuration private let makeContent: @MainActor () -> NSViewController private let onUserClosedWindow: @MainActor () -> Void private var hasPositionedWindow = false + /// Last value handed to `onVisibilityChanged`, so it only ever sees changes. + private var isVisibleOnScreen = false + /// - Parameters: /// - makeContent: builds the tool's hosting controller (called once, during /// `configureWindow()`). @@ -161,6 +175,22 @@ public final class HelperWindowHost: NSObject, NSWindowDelegate { NSApp.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) + // Asserted rather than read back from `occlusionState`, which AppKit updates + // asynchronously and would still report the window as hidden at this instant. + updateVisibility(true) + } + + // MARK: - Visibility + + private func updateVisibility(_ visible: Bool) { + guard visible != isVisibleOnScreen else { return } + isVisibleOnScreen = visible + onVisibilityChanged?(visible) + } + + private var windowIsOnScreen: Bool { + guard let window else { return false } + return window.isVisible && window.occlusionState.contains(.visible) } // MARK: - Show-notification observation @@ -188,14 +218,22 @@ public final class HelperWindowHost: NSObject, NSWindowDelegate { window?.orderOut(nil) window?.delegate = nil window = nil + updateVisibility(false) } // MARK: - NSWindowDelegate public func windowWillClose(_ notification: Notification) { + // Before the tool's own hook, which may terminate the app: whatever the observer stops + // (a poll, an observation) should stop while there is still someone to stop it. + updateVisibility(false) onUserClosedWindow() } + public func windowDidChangeOcclusionState(_ notification: Notification) { + updateVisibility(windowIsOnScreen) + } + // Drop the rounded corners in full screen (where the content fills the whole display) // and restore them when returning to a windowed frame. Only resizable windows whose // collection behaviour allows full screen (Disk Analyzer) can ever trigger these. diff --git a/Sources/DMonteGrabTextApp/GrabTextAppDelegate.swift b/Sources/DMonteGrabTextApp/GrabTextAppDelegate.swift index a7ae37f..839ec2e 100644 --- a/Sources/DMonteGrabTextApp/GrabTextAppDelegate.swift +++ b/Sources/DMonteGrabTextApp/GrabTextAppDelegate.swift @@ -36,6 +36,20 @@ final class GrabTextAppDelegate: NSObject, NSApplicationDelegate { windowHost = host host.configureWindow() + // The hint line under the button reports two things macOS changes without telling anyone: + // Secure Input (which silently swallows every hotkey press) and the Screen Recording grant. + // Sampled once at launch they go stale in both directions — no warning while the shortcut + // is being eaten, or a warning that outlives the password field that caused it. The window + // is built once and never rebuilt, so `onAppear` cannot do this; re-read while it is on + // screen, and stop the moment it isn't, because this is an idle helper the rest of the time. + host.onVisibilityChanged = { [controller] isVisible in + if isVisible { + controller.beginEnvironmentMonitoring() + } else { + controller.endEnvironmentMonitoring() + } + } + let icon = NSImage(systemSymbolName: "text.viewfinder", accessibilityDescription: "Grab Text") ?? NSImage() statusItem = HelperStatusItem( image: icon, @@ -65,6 +79,7 @@ final class GrabTextAppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + controller.endEnvironmentMonitoring() controller.unregisterHotKey() windowHost?.tearDownForTermination() statusItem?.remove() diff --git a/Tests/DMonteCoreTests/GrabTextHotKeyTests.swift b/Tests/DMonteCoreTests/GrabTextHotKeyTests.swift index 7826f5a..1d343f2 100644 --- a/Tests/DMonteCoreTests/GrabTextHotKeyTests.swift +++ b/Tests/DMonteCoreTests/GrabTextHotKeyTests.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import XCTest @testable import DMonteCore @@ -111,6 +112,7 @@ final class GrabTextHotKeyTests: XCTestCase { defaults: UserDefaults, capture: @escaping GrabTextCapture, hasScreenRecordingAccess: Bool = true, + secureInput: @escaping @MainActor () -> Bool = { false }, registrarError: GlobalHotKeyRegistrationError? = nil ) -> (controller: GrabTextController, press: () -> Void) { let box = HandlerBox() @@ -118,6 +120,7 @@ final class GrabTextHotKeyTests: XCTestCase { defaults: defaults, capture: capture, screenRecordingAccess: { hasScreenRecordingAccess }, + secureInput: secureInput, registrar: { _, _, handler in if let registrarError { throw registrarError } box.handler = handler @@ -340,6 +343,178 @@ final class GrabTextHotKeyTests: XCTestCase { XCTAssertTrue(controller.statusMessage.contains("cancelled")) } + @MainActor + func testUncopiedTextIsFlaggedSoTheWindowCanSayItWasNotCopied() async { + let defaults = scratchDefaults() + defaults.set(false, forKey: DefaultsKey.grabTextCopyAutomatically) + let (controller, press) = makeController(defaults: defaults, capture: { .success("only in the window") }) + controller.registerHotKey() + + press() + await settle() + + // The sentence explaining that nothing was copied lives in `statusMessage`, which the + // result box replaces with the text itself as soon as there is one. `resultNeedsCopying` + // is what lets the window render it anyway — without it the instruction is unreachable UI. + XCTAssertTrue(controller.resultNeedsCopying) + XCTAssertTrue(controller.statusMessage.contains("Press Copy")) + + controller.copyRecognizedText() + XCTAssertFalse(controller.resultNeedsCopying, "once it is on the clipboard there is nothing left to say") + XCTAssertEqual(controller.statusMessage, "Copied to clipboard.") + } + + @MainActor + func testAutoCopiedTextIsNotFlaggedAsNeedingACopy() async { + let (controller, press) = makeController(defaults: scratchDefaults(), capture: { .success("straight to the clipboard") }) + controller.registerHotKey() + controller.copyAutomatically = true + + press() + await settle() + + XCTAssertFalse(controller.resultNeedsCopying) + } + + @MainActor + func testMissingScreenRecordingInterruptsOnceRatherThanOnEveryPress() async { + let (controller, press) = makeController( + defaults: scratchDefaults(), + capture: { .needsScreenRecordingPermission }, + hasScreenRecordingAccess: false + ) + controller.registerHotKey() + + var interruptions = 0 + controller.onHotKeyResultNeedsAttention = { interruptions += 1 } + + press() + await settle() + XCTAssertEqual(interruptions, 1, "a missing grant has to be explained") + + // While the grant is missing an Esc-cancel is classified as a permission problem, so + // surfacing it every time would steal activation on every cancelled press — the exact + // interruption the shortcut exists to avoid. + press() + await settle() + press() + await settle() + XCTAssertEqual(interruptions, 1, "saying it again on every press is the interruption, not the fix") + XCTAssertEqual(controller.statusMessage, GrabTextController.permissionMessage, "the window still explains it") + } + + @MainActor + func testMissingScreenRecordingIsAnnouncedAgainAfterTheGrantIsLostASecondTime() async { + let grant = EnvironmentSwitch(isOn: false) + let box = HandlerBox() + let controller = GrabTextController( + defaults: scratchDefaults(), + capture: { .needsScreenRecordingPermission }, + screenRecordingAccess: { MainActor.assumeIsolated { grant.isOn } }, + secureInput: { false }, + registrar: { _, _, handler in + box.handler = handler + return NSObject() + } + ) + controller.registerHotKey() + + var interruptions = 0 + controller.onHotKeyResultNeedsAttention = { interruptions += 1 } + + box.handler?() + await settle() + XCTAssertEqual(interruptions, 1) + + // The user grants it; the one-shot has to re-arm, or a later revocation would be silent. + grant.isOn = true + controller.refreshEnvironment() + XCTAssertTrue(controller.hasScreenRecordingAccess) + + grant.isOn = false + box.handler?() + await settle() + XCTAssertEqual(interruptions, 2, "a grant that is lost again is a new problem, not the old one") + } + + // MARK: - Controller: the environment behind the window + + @MainActor + func testSecureInputIsReReadRatherThanTrustedFromLaunch() { + let secure = EnvironmentSwitch(isOn: false) + let (controller, _) = makeController( + defaults: scratchDefaults(), + capture: { .cancelled }, + secureInput: { secure.isOn } + ) + controller.registerHotKey() + XCTAssertFalse(controller.secureInputBlocked) + + // Secure Input comes on behind our back — a password field, the lock screen. macOS + // announces nothing, so only looking again can tell. + secure.isOn = true + controller.refreshEnvironment() + XCTAssertTrue(controller.secureInputBlocked, "the hint has to say why the shortcut stopped working") + + // And the other direction: a warning that outlives its cause is just as wrong. + secure.isOn = false + controller.refreshEnvironment() + XCTAssertFalse(controller.secureInputBlocked, "the warning must not outlive the block it describes") + } + + @MainActor + func testEnvironmentPollNoticesSecureInputTurningOnWhileTheWindowIsOpen() async { + let secure = EnvironmentSwitch(isOn: false) + let (controller, _) = makeController( + defaults: scratchDefaults(), + capture: { .cancelled }, + secureInput: { secure.isOn } + ) + controller.registerHotKey() + + // The window is on screen and stays there. Its `onAppear` has already fired and can never + // fire again — the hosting controller is built once — so the poll is the only thing that + // can keep this true. + controller.beginEnvironmentMonitoring(interval: 0.02) + XCTAssertFalse(controller.secureInputBlocked) + + let blocked = expectation(description: "the poll surfaces Secure Input turning on") + blocked.assertForOverFulfill = false + let subscription = controller.$secureInputBlocked + .filter { $0 } + .sink { _ in blocked.fulfill() } + + secure.isOn = true + await fulfillment(of: [blocked], timeout: 5) + subscription.cancel() + controller.endEnvironmentMonitoring() + + XCTAssertTrue(controller.secureInputBlocked) + } + + @MainActor + func testEnvironmentPollStopsWhenTheWindowLeavesTheScreen() async { + let secure = EnvironmentSwitch(isOn: false) + let (controller, _) = makeController( + defaults: scratchDefaults(), + capture: { .cancelled }, + secureInput: { secure.isOn } + ) + + controller.beginEnvironmentMonitoring(interval: 0.02) + controller.endEnvironmentMonitoring() + + // Nobody can read the hint, so nothing should be spent keeping it fresh. + secure.isOn = true + try? await Task.sleep(nanoseconds: 200_000_000) + XCTAssertFalse(controller.secureInputBlocked, "the poll must not outlive the window it serves") + + // And it comes back when the window does. + controller.beginEnvironmentMonitoring(interval: 0.02) + controller.endEnvironmentMonitoring() + XCTAssertTrue(controller.secureInputBlocked, "showing the window re-reads immediately, not a tick later") + } + @MainActor func testCopyAutomaticallyIsPersistedOnChange() { let defaults = scratchDefaults() @@ -372,6 +547,14 @@ final class GrabTextHotKeyTests: XCTestCase { func value() -> Int { count } } + /// A system flag the test can flip mid-run — Secure Input, the Screen Recording grant. Both + /// are read through injected closures precisely because macOS gives no way to script them. + @MainActor + private final class EnvironmentSwitch { + var isOn: Bool + init(isOn: Bool) { self.isOn = isOn } + } + /// Makes the first N registration attempts fail. Main-actor confined: the registrar runs there. @MainActor private final class FailureSchedule {