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..bb67f20 --- /dev/null +++ b/Sources/DMonteCore/GrabTextController.swift @@ -0,0 +1,443 @@ +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 + + /// 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. + 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 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. + private var wantsHotKey = false + + public init( + 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 + self.defaults = resolvedDefaults + 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. + self.copyAutomatically = resolvedDefaults.object(forKey: DefaultsKey.grabTextCopyAutomatically) == nil + ? true + : resolvedDefaults.bool(forKey: DefaultsKey.grabTextCopyAutomatically) + self.shortcut = store.effectiveShortcut() + 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 + /// 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 = secureInput() + + 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: 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 = 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 { + 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 = "" + resultNeedsCopying = false + 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 + + // 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 + // 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): + 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, 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?() + } + } + + /// Puts the current result on the clipboard (the window's Copy button). + 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() + updateScreenRecordingAccess() + } + + 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..0ba4502 100644 --- a/Sources/DMonteCore/GrabTextView.swift +++ b/Sources/DMonteCore/GrabTextView.swift @@ -6,35 +6,54 @@ 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) + // 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() } } // MARK: - Header @@ -58,8 +77,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 +97,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 +109,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 +171,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 +181,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,12 +190,27 @@ public struct GrabTextWindowView: View { .frame(maxWidth: .infinity, alignment: .leading) .padding(layout.cardPadding) } else { - Text(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) @@ -155,10 +226,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 +262,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() + + permissionSection - // 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 + 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" + } + + 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 + } - // screencapture writes no file when the user presses Escape to cancel. - guard FileManager.default.fileExists(atPath: tempURL.path) else { - return .cancelled + @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 +435,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 +448,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/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/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..839ec2e 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() } @@ -33,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, @@ -45,6 +62,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 +79,8 @@ 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 new file mode 100644 index 0000000..1d343f2 --- /dev/null +++ b/Tests/DMonteCoreTests/GrabTextHotKeyTests.swift @@ -0,0 +1,569 @@ +import AppKit +import Combine +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, + secureInput: @escaping @MainActor () -> Bool = { false }, + registrarError: GlobalHotKeyRegistrationError? = nil + ) -> (controller: GrabTextController, press: () -> Void) { + let box = HandlerBox() + let controller = GrabTextController( + defaults: defaults, + capture: capture, + screenRecordingAccess: { hasScreenRecordingAccess }, + secureInput: secureInput, + 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 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() + 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 } + } + + /// 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 { + private var remaining: Int + init(failFirst: Int) { remaining = failFirst } + func shouldFail() -> Bool { + guard remaining > 0 else { return false } + remaining -= 1 + return true + } + } +}