Safety commit so the 94 uncommitted files in the working tree (in-flight Account/Mac/Tip work plus the feature-preview recording tooling) survive the move onto cursor/cloud-agent-1787824905651-vxuwc. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
311 lines
12 KiB
Swift
311 lines
12 KiB
Swift
// MacTextInsertionService.swift
|
|
// OSGKeyboard · Mac
|
|
//
|
|
// Inserts transcribed text into the target app: clipboard first, then
|
|
// a synthetic ⌘V (SayIt / Typeless-style). Requires Accessibility trust.
|
|
// Re-activates the app the user was dictating into (the popover steals
|
|
// focus) and restores the original clipboard once the paste has landed.
|
|
//
|
|
// Also hosts `MacPermissionMonitor` — the live Accessibility / microphone
|
|
// authorization watcher shared by onboarding and Settings.
|
|
|
|
import AVFoundation
|
|
import AppKit
|
|
@preconcurrency import ApplicationServices
|
|
import Carbon
|
|
import Combine
|
|
import Foundation
|
|
|
|
enum MacTextInsertionService {
|
|
/// Paste has no completion callback. Keep the transcript available long
|
|
/// enough for slower apps to consume the event before restoring clipboard.
|
|
static let pasteboardRestoreDelayNanoseconds: UInt64 = 500_000_000
|
|
|
|
enum InsertionError: Error, LocalizedError {
|
|
case accessibilityNotGranted
|
|
|
|
var errorDescription: String? {
|
|
MacL10n.string("mac.error.accessibilityRequired")
|
|
}
|
|
}
|
|
|
|
static var isAccessibilityTrusted: Bool {
|
|
AXIsProcessTrusted()
|
|
}
|
|
|
|
@discardableResult
|
|
static func requestAccessibilityIfNeeded() -> Bool {
|
|
if AXIsProcessTrusted() { return true }
|
|
let promptKey = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String
|
|
let options = [promptKey: true] as CFDictionary
|
|
return AXIsProcessTrustedWithOptions(options)
|
|
}
|
|
|
|
// MARK: - Paste-target tracking
|
|
|
|
/// Start observing app activations early (app launch) so a paste target
|
|
/// can still be recovered while OSGKeyboard itself is frontmost — e.g.
|
|
/// when a recording is started from the menu-bar popover.
|
|
@MainActor
|
|
static func beginTrackingFrontmostApp() {
|
|
_ = FrontmostAppTracker.shared
|
|
}
|
|
|
|
/// The app a synthesized ⌘V should land in: the current frontmost app,
|
|
/// or — when OSGKeyboard is frontmost because the popover has key
|
|
/// focus — the app that was active immediately before it.
|
|
@MainActor
|
|
static func captureTargetApplication() -> NSRunningApplication? {
|
|
let selfPid = NSRunningApplication.current.processIdentifier
|
|
if let front = NSWorkspace.shared.frontmostApplication,
|
|
front.processIdentifier != selfPid {
|
|
return front
|
|
}
|
|
return FrontmostAppTracker.shared.lastExternalApp
|
|
}
|
|
|
|
// MARK: - Insertion
|
|
|
|
/// Copy to pasteboard and optionally simulate ⌘V in the target app.
|
|
/// Returns `true` only if the ⌘V event was actually synthesized. After a
|
|
/// successful paste the user's original clipboard is put back, so
|
|
/// dictation never permanently clobbers it.
|
|
@MainActor
|
|
static func insert(
|
|
_ text: String,
|
|
autoPaste: Bool,
|
|
targetApp: NSRunningApplication? = nil
|
|
) async throws -> Bool {
|
|
guard !text.isEmpty else { return false }
|
|
let pasteboard = NSPasteboard.general
|
|
let snapshot = snapshotItems(of: pasteboard)
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(text, forType: .string)
|
|
let transcriptChangeCount = pasteboard.changeCount
|
|
|
|
guard autoPaste else { return false }
|
|
guard AXIsProcessTrusted() else { throw InsertionError.accessibilityNotGranted }
|
|
|
|
// Make sure ⌘V lands in the app the user was dictating into, not in
|
|
// OSGKeyboard's own popover / window.
|
|
if let targetApp { await activate(targetApp) }
|
|
try? await Task.sleep(nanoseconds: 80_000_000)
|
|
guard postCommandV() else { return false }
|
|
|
|
// Give the target app time to read the transcript off the
|
|
// pasteboard, then restore whatever the user had on it.
|
|
try? await Task.sleep(nanoseconds: pasteboardRestoreDelayNanoseconds)
|
|
if shouldRestorePasteboard(
|
|
transcriptChangeCount: transcriptChangeCount,
|
|
currentChangeCount: pasteboard.changeCount
|
|
) {
|
|
restoreItems(snapshot, to: pasteboard)
|
|
}
|
|
return true
|
|
}
|
|
|
|
/// Do not overwrite clipboard content written by the user, target app, or
|
|
/// a clipboard manager while the synthesized paste was in flight.
|
|
static func shouldRestorePasteboard(
|
|
transcriptChangeCount: Int,
|
|
currentChangeCount: Int
|
|
) -> Bool {
|
|
transcriptChangeCount == currentChangeCount
|
|
}
|
|
|
|
/// Brings `app` forward and waits (up to ~1 s) until it is frontmost so
|
|
/// the synthesized keystroke isn't swallowed mid-switch.
|
|
@MainActor
|
|
private static func activate(_ app: NSRunningApplication) async {
|
|
func isFront() -> Bool {
|
|
NSWorkspace.shared.frontmostApplication?.processIdentifier == app.processIdentifier
|
|
}
|
|
guard !isFront() else { return }
|
|
app.activate()
|
|
var attempts = 0
|
|
while !isFront(), attempts < 20 {
|
|
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
attempts += 1
|
|
}
|
|
}
|
|
|
|
// MARK: - Pasteboard preservation
|
|
|
|
/// Every representation of every pasteboard item, so restore round-trips
|
|
/// rich content (images, files, multiple flavours) losslessly.
|
|
private static func snapshotItems(
|
|
of pasteboard: NSPasteboard
|
|
) -> [[NSPasteboard.PasteboardType: Data]] {
|
|
(pasteboard.pasteboardItems ?? []).map { item in
|
|
item.types.reduce(into: [NSPasteboard.PasteboardType: Data]()) { flavours, type in
|
|
flavours[type] = item.data(forType: type)
|
|
}
|
|
}
|
|
}
|
|
|
|
static func restoreItems(
|
|
_ items: [[NSPasteboard.PasteboardType: Data]],
|
|
to pasteboard: NSPasteboard
|
|
) {
|
|
pasteboard.clearContents()
|
|
guard !items.isEmpty else { return }
|
|
pasteboard.writeObjects(items.map { flavours in
|
|
let item = NSPasteboardItem()
|
|
for (type, data) in flavours { item.setData(data, forType: type) }
|
|
return item
|
|
})
|
|
}
|
|
|
|
/// Returns `false` when the CGEvents could not be created — in that case
|
|
/// nothing was pasted and callers must not report success.
|
|
private static func postCommandV() -> Bool {
|
|
let source = CGEventSource(stateID: .combinedSessionState)
|
|
let keyCode = CGKeyCode(kVK_ANSI_V)
|
|
guard
|
|
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true),
|
|
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
|
|
else { return false }
|
|
keyDown.flags = CGEventFlags.maskCommand
|
|
keyUp.flags = CGEventFlags.maskCommand
|
|
keyDown.post(tap: CGEventTapLocation.cghidEventTap)
|
|
keyUp.post(tap: CGEventTapLocation.cghidEventTap)
|
|
return true
|
|
}
|
|
}
|
|
|
|
// MARK: - Frontmost-app tracker
|
|
|
|
/// Remembers the most recent non-OSGKeyboard frontmost app. Needed because
|
|
/// the menu-bar popover activates OSGKeyboard, hiding the real paste target
|
|
/// from `NSWorkspace.frontmostApplication`.
|
|
@MainActor
|
|
private final class FrontmostAppTracker: NSObject {
|
|
static let shared = FrontmostAppTracker()
|
|
|
|
private(set) var lastExternalApp: NSRunningApplication?
|
|
|
|
private override init() {
|
|
super.init()
|
|
// Seed with whatever is frontmost now (usually not us at launch).
|
|
if let front = NSWorkspace.shared.frontmostApplication,
|
|
front.processIdentifier != NSRunningApplication.current.processIdentifier {
|
|
lastExternalApp = front
|
|
}
|
|
NSWorkspace.shared.notificationCenter.addObserver(
|
|
self,
|
|
selector: #selector(appDidActivate(_:)),
|
|
name: NSWorkspace.didActivateApplicationNotification,
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
deinit {
|
|
NSWorkspace.shared.notificationCenter.removeObserver(self)
|
|
}
|
|
|
|
@objc private func appDidActivate(_ notification: Notification) {
|
|
guard
|
|
let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
|
|
app.processIdentifier != NSRunningApplication.current.processIdentifier
|
|
else { return }
|
|
lastExternalApp = app
|
|
}
|
|
}
|
|
|
|
// MARK: - Live permission monitoring
|
|
|
|
/// Publishes Accessibility / microphone authorization while a permission UI is
|
|
/// on screen.
|
|
///
|
|
/// Both grants are made *outside* the app — in System Settings — and macOS
|
|
/// sends no notification when they flip. `AXIsProcessTrusted()` also keeps
|
|
/// returning the value captured when the process was launched for a short
|
|
/// while after the toggle, so a single delayed re-check (what onboarding and
|
|
/// Settings used to do) reads stale far more often than not. Polling on a
|
|
/// short timer plus re-reading on app activation is the only reliable way to
|
|
/// see the change without asking the user to relaunch.
|
|
@MainActor
|
|
final class MacPermissionMonitor: ObservableObject {
|
|
@Published private(set) var isAccessibilityTrusted = MacTextInsertionService.isAccessibilityTrusted
|
|
@Published private(set) var microphoneStatus = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
|
|
/// Fast enough that returning from System Settings feels instantaneous,
|
|
/// slow enough to be free — `AXIsProcessTrusted` is a cheap XPC-free read.
|
|
private static let pollInterval: TimeInterval = 0.6
|
|
|
|
private var pollTask: Task<Void, Never>?
|
|
/// Observed as an `AsyncSequence` rather than a block observer so the
|
|
/// registration is owned by the task and torn down by cancelling it —
|
|
/// a nonisolated `deinit` cannot hand a block observer back safely.
|
|
private var activationTask: Task<Void, Never>?
|
|
|
|
deinit {
|
|
pollTask?.cancel()
|
|
activationTask?.cancel()
|
|
}
|
|
|
|
/// Begins polling. Safe to call repeatedly — extra calls are ignored.
|
|
func start() {
|
|
refresh()
|
|
guard pollTask == nil else { return }
|
|
|
|
// Returning from System Settings usually means the grant just changed;
|
|
// refresh immediately instead of waiting out the poll interval.
|
|
activationTask = Task { [weak self] in
|
|
let activations = NotificationCenter.default.notifications(
|
|
named: NSApplication.didBecomeActiveNotification
|
|
)
|
|
for await _ in activations {
|
|
guard !Task.isCancelled else { return }
|
|
await MainActor.run { self?.refresh() }
|
|
}
|
|
}
|
|
|
|
pollTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(
|
|
nanoseconds: UInt64(Self.pollInterval * 1_000_000_000)
|
|
)
|
|
guard !Task.isCancelled else { return }
|
|
self?.refresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
func stop() {
|
|
pollTask?.cancel()
|
|
pollTask = nil
|
|
activationTask?.cancel()
|
|
activationTask = nil
|
|
}
|
|
|
|
func refresh() {
|
|
let trusted = MacTextInsertionService.isAccessibilityTrusted
|
|
if trusted != isAccessibilityTrusted {
|
|
isAccessibilityTrusted = trusted
|
|
// Showing a green checkmark is only half the grant: the global
|
|
// hotkey monitor was refused at launch and has to be re-attached.
|
|
if trusted { MacDictationViewModel.shared.reattachHotkeyIfNeeded() }
|
|
}
|
|
let mic = AVCaptureDevice.authorizationStatus(for: .audio)
|
|
if mic != microphoneStatus { microphoneStatus = mic }
|
|
}
|
|
|
|
/// Prompts, then opens the Accessibility pane. Polling picks the grant up.
|
|
func openAccessibilitySettings() {
|
|
_ = MacTextInsertionService.requestAccessibilityIfNeeded()
|
|
if let url = URL(
|
|
string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"
|
|
) {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
refresh()
|
|
}
|
|
|
|
func requestMicrophone() {
|
|
AVCaptureDevice.requestAccess(for: .audio) { [weak self] _ in
|
|
Task { @MainActor in self?.refresh() }
|
|
}
|
|
}
|
|
}
|