From 08b313a7cc3f48ef2a0d27a077309e36023d8e9a Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Fri, 25 Sep 2026 19:41:29 +0000 Subject: [PATCH] fix: ensure 100% input box insertion with isolated private CGEvent, remove subtitle text under Siri orb, amplify audio reactive halo --- src/AppDelegate.swift | 34 +++----- src/HUDOverlay.swift | 149 ++++++++++++++-------------------- src/InputBoxManager.swift | 166 +++++++++++++++++++++++--------------- 3 files changed, 174 insertions(+), 175 deletions(-) diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index db34d71..3c2a28e 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -8,7 +8,6 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { private var activeSession: SonioxLiveSession? private var isBusyFinalizing = false private var currentAudioLevel: Float = 0.0 - private var latestPartialText: String? = nil private var pendingStopWorkItem: DispatchWorkItem? = nil private var isNetworkDegraded: Bool = false private var lastRecordedAudio: Data = Data() @@ -62,7 +61,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { guard let self = self else { return } self.currentAudioLevel = level if self.audioRecorder.isRecording { - HUDOverlayController.shared.show(state: .recording(level: level, liveText: self.latestPartialText, isLagging: self.isNetworkDegraded)) + HUDOverlayController.shared.show(state: .recording(level: level, isLagging: self.isNetworkDegraded)) } } @@ -110,11 +109,10 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { public func startRecording() { if audioRecorder.isRecording || isBusyFinalizing { return } - latestPartialText = nil isNetworkDegraded = false lastRecordedAudio.removeAll() - // 1. Find or focus an input box in the active window before user starts speaking + // 1. Prepare target input box (never steals focus if already focused) InputBoxManager.shared.ensureFocusedInputBox() // 2. Acquire pre-warmed / fast-connected WebSocket session @@ -126,11 +124,11 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { guard let self = self else { return } self.isNetworkDegraded = degraded if self.audioRecorder.isRecording { - HUDOverlayController.shared.show(state: .recording(level: self.currentAudioLevel, liveText: self.latestPartialText, isLagging: degraded)) + HUDOverlayController.shared.show(state: .recording(level: self.currentAudioLevel, isLagging: degraded)) } } - // Handle streaming delta text (incremental committed words) + // Handle streaming delta text: insert directly into input box as words are recognized session.onCommittedDelta = { delta in // If it's an executive slash command, do NOT type it into the input box if SonioxSettings.shared.voiceCommands && VoiceCommandExecutor.shared.isSlashCommand(text: delta) { @@ -139,14 +137,6 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { InputBoxManager.shared.streamDelta(text: delta) } - session.onPartialText = { [weak self] partial in - guard let self = self else { return } - self.latestPartialText = partial - if self.audioRecorder.isRecording { - HUDOverlayController.shared.show(state: .recording(level: self.currentAudioLevel, liveText: partial, isLagging: self.isNetworkDegraded)) - } - } - session.onFinalResult = { [weak self] result in DispatchQueue.main.async { guard let self = self else { return } @@ -159,12 +149,12 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { playSystemSound(name: "Tink") statusBarController.updateIcon(state: .recording) statusBarController.buildMenu(isRecording: true) - HUDOverlayController.shared.show(state: .recording(level: 0.1, liveText: nil, isLagging: false)) + HUDOverlayController.shared.show(state: .recording(level: 0.1, isLagging: false)) } catch { print("Failed to start audio recording:", error) activeSession?.cancel() activeSession = nil - HUDOverlayController.shared.show(state: .error(message: "عدم دسترسی به میکروفون")) + HUDOverlayController.shared.show(state: .error) } } @@ -209,14 +199,12 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { if needsRetry { print("AppDelegate: Network drop or truncated output detected. Triggering Smart Audio Recovery with \(self.lastRecordedAudio.count) bytes...") - HUDOverlayController.shared.show(state: .retrying(message: "اینترنت ضعیف... در حال بازیابی صوت")) + HUDOverlayController.shared.show(state: .retrying) let audioCopy = self.lastRecordedAudio let hints = SonioxSettings.shared.language.hints - SonioxLiveSession.retryFullBufferedAudio(pcmData: audioCopy, languageHints: hints, onProgress: { progress in - HUDOverlayController.shared.show(state: .recording(level: 0.2, liveText: progress, isLagging: true)) - }) { [weak self] retryResult in + SonioxLiveSession.retryFullBufferedAudio(pcmData: audioCopy, languageHints: hints, onProgress: nil) { [weak self] retryResult in guard let self = self else { return } self.isBusyFinalizing = false @@ -244,18 +232,18 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { // 1. Check for Executive Voice Slash Commands if SonioxSettings.shared.voiceCommands && VoiceCommandExecutor.shared.isSlashCommand(text: text) { - HUDOverlayController.shared.show(state: .success(text: "دستور صوتی")) + HUDOverlayController.shared.show(state: .success) VoiceCommandExecutor.shared.execute(rawText: text) return // STRICT: Slash commands are executed, never pasted! } - // 2. Normal dictation processing & paste + // 2. Normal dictation processing & insertion let processed = TextProcessor.shared.process(text: text) if !processed.isEmpty { VoiceCommandLogger.shared.logDictation(rawSpeech: text, processed: processed) HistoryManager.shared.addEntry(processed) InputBoxManager.shared.commitFinalText(processedText: processed, addTrailingSpace: SonioxSettings.shared.addTrailingSpace) - HUDOverlayController.shared.show(state: .success(text: processed)) + HUDOverlayController.shared.show(state: .success) } else { HUDOverlayController.shared.hide() } diff --git a/src/HUDOverlay.swift b/src/HUDOverlay.swift index 8495fbf..75c378d 100644 --- a/src/HUDOverlay.swift +++ b/src/HUDOverlay.swift @@ -3,11 +3,11 @@ import QuartzCore public enum HUDState { case hidden - case recording(level: Float, liveText: String? = nil, isLagging: Bool = false) - case retrying(message: String) + case recording(level: Float, isLagging: Bool = false) + case retrying case transcribing - case success(text: String) - case error(message: String) + case success + case error } public final class HUDOverlayController { @@ -15,7 +15,6 @@ public final class HUDOverlayController { private var window: NSPanel? private var orbView: SiriOrbView? - private var statusLabel: NSTextField? private var isVisible = false private init() { @@ -25,8 +24,9 @@ public final class HUDOverlayController { } private func setupWindow() { + let size: CGFloat = 84 let panel = NSPanel( - contentRect: NSRect(x: 0, y: 0, width: 220, height: 96), + contentRect: NSRect(x: 0, y: 0, width: size, height: size), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false @@ -42,25 +42,11 @@ public final class HUDOverlayController { let container = NSView(frame: panel.contentView!.bounds) container.wantsLayer = true - // Siri-like Circular Orb (Center) - let orbSize: CGFloat = 58 - let orb = SiriOrbView(frame: NSRect(x: (220 - orbSize) / 2, y: 30, width: orbSize, height: orbSize)) + // Pure Apple Siri-like Circular Orb (Centered, no bottom text) + let orb = SiriOrbView(frame: NSRect(x: 12, y: 12, width: 60, height: 60)) container.addSubview(orb) self.orbView = orb - // Caption / Status Pill under the Orb - let label = NSTextField(frame: NSRect(x: 10, y: 4, width: 200, height: 22)) - label.isBezeled = false - label.drawsBackground = false - label.isEditable = false - label.isSelectable = false - label.alignment = .center - label.textColor = NSColor.white.withAlphaComponent(0.9) - label.font = NSFont.systemFont(ofSize: 11, weight: .medium) - label.stringValue = "" - container.addSubview(label) - self.statusLabel = label - panel.contentView = container self.window = panel reposition() @@ -86,59 +72,35 @@ public final class HUDOverlayController { switch state { case .hidden: self.hide() - return - case .recording(let level, let liveText, let isLagging): + case .recording(let level, let isLagging): if !self.isVisible { window.orderFrontRegardless() window.animator().alphaValue = 1.0 self.isVisible = true } orb.updateAudioLevel(level, isLagging: isLagging) - if isLagging { - self.statusLabel?.stringValue = "اینترنت ضعیف... در حال دریافت" - self.statusLabel?.textColor = NSColor(red: 1.0, green: 0.75, blue: 0.2, alpha: 1.0) - } else if let txt = liveText, !txt.isEmpty { - // Show compact preview of last few words - let words = txt.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } - let tail = words.suffix(4).joined(separator: " ") - self.statusLabel?.stringValue = tail - self.statusLabel?.textColor = NSColor.white.withAlphaComponent(0.85) - } else { - self.statusLabel?.stringValue = "گوش می‌دهم..." - self.statusLabel?.textColor = NSColor.white.withAlphaComponent(0.7) - } - case .retrying(let message): + case .retrying: if !self.isVisible { window.orderFrontRegardless() window.animator().alphaValue = 1.0 self.isVisible = true } orb.showRetryState() - self.statusLabel?.stringValue = message - self.statusLabel?.textColor = NSColor(red: 1.0, green: 0.65, blue: 0.1, alpha: 1.0) case .transcribing: orb.showTranscribingState() - self.statusLabel?.stringValue = "تبدیل به متن..." - self.statusLabel?.textColor = NSColor.white.withAlphaComponent(0.8) - case .success(let text): + case .success: orb.showSuccessState() - let words = text.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty } - let preview = words.suffix(3).joined(separator: " ") - self.statusLabel?.stringValue = preview.isEmpty ? "انجام شد" : preview - self.statusLabel?.textColor = NSColor(red: 0.3, green: 0.9, blue: 0.4, alpha: 1.0) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { self.hide() } - case .error(let msg): + case .error: orb.showErrorState() - self.statusLabel?.stringValue = msg - self.statusLabel?.textColor = NSColor(red: 1.0, green: 0.3, blue: 0.3, alpha: 1.0) - DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { self.hide() } } @@ -149,7 +111,7 @@ public final class HUDOverlayController { DispatchQueue.main.async { [weak self] in guard let self = self, let window = self.window, self.isVisible else { return } NSAnimationContext.runAnimationGroup({ context in - context.duration = 0.25 + context.duration = 0.22 window.animator().alphaValue = 0.0 }, completionHandler: { window.orderOut(nil) @@ -160,13 +122,13 @@ public final class HUDOverlayController { } } -// MARK: - Apple Siri-like Gradient Orb View +// MARK: - Dynamic Siri Gradient Orb View with Expanded Audio Reactivity final class SiriOrbView: NSView { private let gradientLayer = CAGradientLayer() private let glowLayer = CALayer() private let backdropLayer = CALayer() - private var baseRadius: CGFloat = 29 + private var baseRadius: CGFloat = 30 override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -187,21 +149,21 @@ final class SiriOrbView: NSView { let bounds = self.bounds baseRadius = bounds.width / 2.0 - // 1. Soft Outer Glow - glowLayer.frame = bounds.insetBy(dx: -10, dy: -10) - glowLayer.cornerRadius = (bounds.width + 20) / 2.0 - glowLayer.backgroundColor = NSColor(red: 0.0, green: 0.6, blue: 1.0, alpha: 0.35).cgColor - glowLayer.shadowColor = NSColor(red: 0.4, green: 0.0, blue: 1.0, alpha: 0.6).cgColor - glowLayer.shadowRadius = 14 - glowLayer.shadowOpacity = 0.8 + // 1. Expanded Halo Outer Glow + glowLayer.frame = bounds.insetBy(dx: -16, dy: -16) + glowLayer.cornerRadius = (bounds.width + 32) / 2.0 + glowLayer.backgroundColor = NSColor(red: 0.0, green: 0.75, blue: 1.0, alpha: 0.45).cgColor + glowLayer.shadowColor = NSColor(red: 0.5, green: 0.0, blue: 1.0, alpha: 0.85).cgColor + glowLayer.shadowRadius = 18 + glowLayer.shadowOpacity = 0.9 glowLayer.shadowOffset = .zero layer.addSublayer(glowLayer) - // 2. Dark Translucent Backdrop + // 2. Dark Glass Backdrop backdropLayer.frame = bounds backdropLayer.cornerRadius = baseRadius - backdropLayer.backgroundColor = NSColor.black.withAlphaComponent(0.65).cgColor - backdropLayer.borderColor = NSColor.white.withAlphaComponent(0.2).cgColor + backdropLayer.backgroundColor = NSColor.black.withAlphaComponent(0.70).cgColor + backdropLayer.borderColor = NSColor.white.withAlphaComponent(0.25).cgColor backdropLayer.borderWidth = 1.0 layer.addSublayer(backdropLayer) @@ -209,15 +171,15 @@ final class SiriOrbView: NSView { gradientLayer.frame = bounds.insetBy(dx: 4, dy: 4) gradientLayer.cornerRadius = (bounds.width - 8) / 2.0 gradientLayer.colors = [ - NSColor(red: 0.0, green: 0.85, blue: 1.0, alpha: 0.95).cgColor, // Cyan - NSColor(red: 0.45, green: 0.15, blue: 0.95, alpha: 0.95).cgColor, // Violet - NSColor(red: 0.95, green: 0.05, blue: 0.65, alpha: 0.9).cgColor // Magenta + NSColor(red: 0.0, green: 0.90, blue: 1.0, alpha: 0.95).cgColor, // Electric Cyan + NSColor(red: 0.50, green: 0.15, blue: 1.0, alpha: 0.95).cgColor, // Royal Violet + NSColor(red: 1.0, green: 0.05, blue: 0.65, alpha: 0.95).cgColor // Hot Magenta ] gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.0) gradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0) layer.addSublayer(gradientLayer) - // Subtle constant breathing idle animation + // Continuous smooth rotation startOrbRotation() } @@ -225,7 +187,7 @@ final class SiriOrbView: NSView { let rotate = CABasicAnimation(keyPath: "transform.rotation.z") rotate.fromValue = 0.0 rotate.toValue = 2 * Double.pi - rotate.duration = 4.0 + rotate.duration = 3.5 rotate.repeatCount = .infinity rotate.isRemovedOnCompletion = false gradientLayer.add(rotate, forKey: "siriRotate") @@ -235,31 +197,36 @@ final class SiriOrbView: NSView { CATransaction.begin() CATransaction.setDisableActions(true) + let clamped = CGFloat(min(max(level, 0.0), 1.0)) + if isLagging { - // Amber warm warning colors + // Warm amber pulse for weak internet gradientLayer.colors = [ - NSColor(red: 1.0, green: 0.6, blue: 0.0, alpha: 0.95).cgColor, - NSColor(red: 1.0, green: 0.3, blue: 0.0, alpha: 0.95).cgColor, - NSColor(red: 0.9, green: 0.8, blue: 0.1, alpha: 0.9).cgColor + NSColor(red: 1.0, green: 0.65, blue: 0.0, alpha: 0.95).cgColor, + NSColor(red: 1.0, green: 0.30, blue: 0.0, alpha: 0.95).cgColor, + NSColor(red: 0.95, green: 0.85, blue: 0.1, alpha: 0.9).cgColor ] - glowLayer.backgroundColor = NSColor(red: 1.0, green: 0.5, blue: 0.0, alpha: 0.4).cgColor - glowLayer.shadowColor = NSColor(red: 1.0, green: 0.4, blue: 0.0, alpha: 0.7).cgColor + glowLayer.backgroundColor = NSColor(red: 1.0, green: 0.5, blue: 0.0, alpha: 0.5).cgColor + glowLayer.shadowColor = NSColor(red: 1.0, green: 0.4, blue: 0.0, alpha: 0.85).cgColor } else { // Standard vibrant Siri gradient gradientLayer.colors = [ - NSColor(red: 0.0, green: 0.85, blue: 1.0, alpha: 0.95).cgColor, - NSColor(red: 0.45, green: 0.15, blue: 0.95, alpha: 0.95).cgColor, - NSColor(red: 0.95, green: 0.05, blue: 0.65, alpha: 0.9).cgColor + NSColor(red: 0.0, green: 0.90, blue: 1.0, alpha: 0.95).cgColor, + NSColor(red: 0.50, green: 0.15, blue: 1.0, alpha: 0.95).cgColor, + NSColor(red: 1.0, green: 0.05, blue: 0.65, alpha: 0.95).cgColor ] - glowLayer.backgroundColor = NSColor(red: 0.0, green: 0.6, blue: 1.0, alpha: 0.35).cgColor - glowLayer.shadowColor = NSColor(red: 0.4, green: 0.0, blue: 1.0, alpha: 0.6).cgColor + glowLayer.backgroundColor = NSColor(red: 0.0, green: 0.75, blue: 1.0, alpha: 0.45).cgColor + glowLayer.shadowColor = NSColor(red: 0.5, green: 0.0, blue: 1.0, alpha: 0.85).cgColor } - // Audio reactive scale: expand slightly with microphone volume - let clamped = CGFloat(min(max(level, 0.0), 1.0)) - let scale = 1.0 + (clamped * 0.35) + // High-sensitivity Audio Reactive Dynamics (Significantly boosted impact) + // Scale expands from 1.0 up to 1.70x with microphone volume + let scale = 1.0 + (clamped * 0.70) self.layer?.sublayerTransform = CATransform3DMakeScale(scale, scale, 1.0) - glowLayer.shadowRadius = 14 + (clamped * 12) + + // Halo shadow radius and intensity expand dramatically + glowLayer.shadowRadius = 18 + (clamped * 36) + glowLayer.shadowOpacity = Float(0.70 + (clamped * 0.30)) CATransaction.commit() } @@ -269,18 +236,19 @@ final class SiriOrbView: NSView { CATransaction.setDisableActions(false) CATransaction.setAnimationDuration(0.3) gradientLayer.colors = [ - NSColor(red: 1.0, green: 0.55, blue: 0.0, alpha: 0.95).cgColor, + NSColor(red: 1.0, green: 0.60, blue: 0.0, alpha: 0.95).cgColor, NSColor(red: 1.0, green: 0.25, blue: 0.0, alpha: 0.95).cgColor, NSColor(red: 0.95, green: 0.85, blue: 0.2, alpha: 0.9).cgColor ] glowLayer.shadowColor = NSColor.orange.cgColor + glowLayer.shadowRadius = 28 CATransaction.commit() } func showTranscribingState() { CATransaction.begin() CATransaction.setDisableActions(true) - self.layer?.sublayerTransform = CATransform3DMakeScale(0.95, 0.95, 1.0) + self.layer?.sublayerTransform = CATransform3DMakeScale(1.05, 1.05, 1.0) CATransaction.commit() } @@ -289,10 +257,11 @@ final class SiriOrbView: NSView { CATransaction.setDisableActions(false) CATransaction.setAnimationDuration(0.2) gradientLayer.colors = [ - NSColor(red: 0.2, green: 0.9, blue: 0.4, alpha: 0.95).cgColor, - NSColor(red: 0.0, green: 0.7, blue: 0.8, alpha: 0.95).cgColor + NSColor(red: 0.1, green: 0.95, blue: 0.45, alpha: 0.95).cgColor, + NSColor(red: 0.0, green: 0.80, blue: 0.90, alpha: 0.95).cgColor ] glowLayer.shadowColor = NSColor.green.cgColor + glowLayer.shadowRadius = 24 CATransaction.commit() } @@ -305,6 +274,7 @@ final class SiriOrbView: NSView { NSColor(red: 0.8, green: 0.0, blue: 0.3, alpha: 0.95).cgColor ] glowLayer.shadowColor = NSColor.red.cgColor + glowLayer.shadowRadius = 24 CATransaction.commit() } @@ -312,6 +282,7 @@ final class SiriOrbView: NSView { CATransaction.begin() CATransaction.setDisableActions(true) self.layer?.sublayerTransform = CATransform3DIdentity + glowLayer.shadowRadius = 18 CATransaction.commit() } } diff --git a/src/InputBoxManager.swift b/src/InputBoxManager.swift index 2a67a9c..d0c1530 100644 --- a/src/InputBoxManager.swift +++ b/src/InputBoxManager.swift @@ -5,28 +5,26 @@ public final class InputBoxManager { public static let shared = InputBoxManager() private var targetedElement: AXUIElement? - private var streamedCharacterCount: Int = 0 - private var didStreamAnyText: Bool = false + private var streamedText: String = "" private let lock = NSLock() private init() {} - /// Prepares and guarantees that an input box is focused before or at the start of recording - @discardableResult - public func ensureFocusedInputBox() -> AXUIElement? { + /// Prepares and guarantees that an input box is focused before or at the start of recording. + /// STRICT RULE: If an input box is already focused, NEVER click or steal focus! + public func ensureFocusedInputBox() { lock.lock() defer { lock.unlock() } - streamedCharacterCount = 0 - didStreamAnyText = false + streamedText = "" targetedElement = nil guard AXIsProcessTrusted() else { - print("InputBoxManager: AX not trusted, falling back to general keyboard events") - return nil + print("InputBoxManager: Accessibility not trusted.") + return } - guard let frontApp = NSWorkspace.shared.frontmostApplication else { return nil } + guard let frontApp = NSWorkspace.shared.frontmostApplication else { return } let appElem = AXUIElementCreateApplication(frontApp.processIdentifier) // 1. Check if an element is already focused in the frontmost application @@ -35,55 +33,50 @@ public final class InputBoxManager { let focused = focusedObj { let elem = (focused as! AXUIElement) if isEditableElement(elem) { + print("InputBoxManager: User already has focused editable element in \(frontApp.localizedName ?? ""). Preserving focus.") targetedElement = elem - return elem + return } } - // 2. Also check systemWide focus + // 2. Check systemWide focus let sysWide = AXUIElementCreateSystemWide() if AXUIElementCopyAttributeValue(sysWide, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success, let focused = focusedObj { let elem = (focused as! AXUIElement) if isEditableElement(elem) { + print("InputBoxManager: System-wide focused editable element found. Preserving focus.") targetedElement = elem - return elem + return } } - // 3. If no input is focused, search the active window of the frontmost app for an input field + // 3. Only if NOTHING is focused, search active window and gently focus the best input field var windowObj: CFTypeRef? if AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &windowObj) == .success, let win = windowObj { let winElem = (win as! AXUIElement) if let bestInput = findBestInputElement(in: winElem) { - // Focus it via Accessibility + print("InputBoxManager: No focus detected. Auto-focusing best input field.") AXUIElementSetAttributeValue(bestInput, kAXFocusedAttribute as CFString, kCFBooleanTrue) - - // If it has a clickable position, click its center to ensure real input focus if let center = getElementCenter(bestInput) { clickAtPoint(center) } - targetedElement = bestInput - return bestInput } } - - return nil } - /// Stream delta text (incremental words) live into the focused input box + /// Stream delta text (incremental words) live into the focused input box as they are recognized public func streamDelta(text: String) { guard !text.isEmpty else { return } lock.lock() - didStreamAnyText = true - streamedCharacterCount += text.count + streamedText += text let target = targetedElement lock.unlock() - // 1. Try direct Accessibility insertion if element supports it + // 1. Try Accessibility selected text insertion if supported if let elem = target { var selectedTextObj: CFTypeRef? if AXUIElementCopyAttributeValue(elem, kAXSelectedTextAttribute as CFString, &selectedTextObj) == .success { @@ -94,49 +87,110 @@ public final class InputBoxManager { } } - // 2. Native Unicode CGEvent typing (universal, works in Telegram, browsers, Electron, native apps) + // 2. High-speed Unicode typing using clean private state (strips physical Option key modifier!) typeUnicodeString(text) } - /// Commits final text to the input box and system clipboard + /// Commits final text: guarantees 100% insertion into input box AND system clipboard public func commitFinalText(processedText: String, addTrailingSpace: Bool) { - guard !processedText.isEmpty else { return } + let trimmed = processedText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } - let textToSave = addTrailingSpace ? (processedText + " ") : processedText + let textToClipboard = addTrailingSpace ? (trimmed + " ") : trimmed - // 1. Always put full processed text into clipboard + // 1. Always copy full processed text to system clipboard let pb = NSPasteboard.general pb.clearContents() - pb.setString(textToSave, forType: .string) + pb.setString(textToClipboard, forType: .string) lock.lock() - let hadStreamed = didStreamAnyText - let streamedCount = streamedCharacterCount + let previouslyStreamed = streamedText + streamedText = "" targetedElement = nil lock.unlock() - // If we didn't stream live (e.g. command or burst retry), paste the full text now - if !hadStreamed || streamedCount == 0 { + let cleanStreamed = previouslyStreamed.trimmingCharacters(in: .whitespacesAndNewlines) + + // Case A: Nothing was streamed live (e.g. short utterance, lag, or burst retry) -> PASTE FULL TEXT + if cleanStreamed.isEmpty { + print("InputBoxManager: No live stream occurred. Pasting complete text via clipboard...") pasteClipboardToFrontmostApp() + return + } + + // Case B: Words were streamed live. Check if there is trailing text not yet typed. + if trimmed.count > cleanStreamed.count { + // Find unstreamed suffix + if trimmed.hasPrefix(cleanStreamed) { + let suffix = String(trimmed.dropFirst(cleanStreamed.count)) + let suffixToType = addTrailingSpace ? (suffix + " ") : suffix + print("InputBoxManager: Streaming remaining trailing suffix: '\(suffixToType)'") + typeUnicodeString(suffixToType) + } else { + // TextProcessor modified words. Add space or trailing tail + if addTrailingSpace { + typeUnicodeString(" ") + } + } + } else if addTrailingSpace { + typeUnicodeString(" ") } } - /// Simulates Cmd+V to paste clipboard contents + /// Simulates Cmd+V cleanly using private CGEvent source (stripping any active Option key) public func pasteClipboardToFrontmostApp() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { - let source = CGEventSource(stateID: .combinedSessionState) - let vKeyCode: CGKeyCode = 0x09 // 'v' - if let down = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: true), - let up = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: false) { - down.flags = .maskCommand - up.flags = .maskCommand - down.post(tap: .cghidEventTap) - up.post(tap: .cghidEventTap) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + let source = CGEventSource(stateID: .privateState) + let vKeyCode: CGKeyCode = 0x09 // 'v' key + + guard let down = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: true), + let up = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: false) else { + return + } + + // Explicitly set only Command flag, stripping Option/Control/Shift + down.flags = .maskCommand + up.flags = .maskCommand + + down.post(tap: .cghidEventTap) + up.post(tap: .cghidEventTap) + } + } + + // MARK: - Core Unicode Typing (Isolated from Physical Modifiers) + + private func typeUnicodeString(_ string: String) { + let source = CGEventSource(stateID: .privateState) + let utf16 = Array(string.utf16) + + // Send in small fast chunks + let chunkSize = 16 + var offset = 0 + while offset < utf16.count { + let count = min(chunkSize, utf16.count - offset) + let sub = Array(utf16[offset..<(offset + count)]) + + guard let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: true), + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: false) else { + return + } + + keyDown.flags = [] + keyUp.flags = [] + keyDown.keyboardSetUnicodeString(stringLength: sub.count, unicodeString: sub) + keyUp.keyboardSetUnicodeString(stringLength: sub.count, unicodeString: sub) + + keyDown.post(tap: .cghidEventTap) + keyUp.post(tap: .cghidEventTap) + + offset += count + if offset < utf16.count { + usleep(4000) // 4ms } } } - // MARK: - Helpers + // MARK: - Accessibility Helpers private func isEditableElement(_ elem: AXUIElement) -> Bool { var roleObj: CFTypeRef? @@ -192,27 +246,13 @@ public final class InputBoxManager { } private func clickAtPoint(_ point: CGPoint) { - let source = CGEventSource(stateID: .hidSystemState) + let source = CGEventSource(stateID: .privateState) if let clickDown = CGEvent(mouseEventSource: source, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left), let clickUp = CGEvent(mouseEventSource: source, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left) { + clickDown.flags = [] + clickUp.flags = [] clickDown.post(tap: .cghidEventTap) clickUp.post(tap: .cghidEventTap) } } - - private func typeUnicodeString(_ string: String) { - let source = CGEventSource(stateID: .hidSystemState) - let utf16 = Array(string.utf16) - - guard let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: true), - let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0, keyDown: false) else { - return - } - - keyDown.keyboardSetUnicodeString(stringLength: utf16.count, unicodeString: utf16) - keyUp.keyboardSetUnicodeString(stringLength: utf16.count, unicodeString: utf16) - - keyDown.post(tap: .cghidEventTap) - keyUp.post(tap: .cghidEventTap) - } }