Files
OSGKeyboard/OSGKeyboardMac/MacTextInsertionService.swift
T
Rocky 4c929d8b8b fix(mac,asr): harden menu-bar delivery, chunk retry, and polish validator
Retain the external target app for menu-bar paste and polish context, retry
failed middle ASR chunks once, and stop false-positive path/number violations.
2026-07-29 18:30:36 +08:00

209 lines
7.8 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.
import AppKit
@preconcurrency import ApplicationServices
import Carbon
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
}
}