diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index 4f4037f..ce774e6 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -128,6 +128,15 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { } } + // Stream live finalized tokens directly into input box word-by-word + 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) { + return + } + InputBoxManager.shared.streamDelta(text: delta) + } + session.onFinalResult = { [weak self] result in DispatchQueue.main.async { guard let self = self else { return } @@ -227,51 +236,18 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { return // STRICT: Slash commands are executed, never pasted! } - // 2. Normal dictation processing & paste + // 2. Normal dictation processing & final commit let processed = TextProcessor.shared.process(text: text) if !processed.isEmpty { VoiceCommandLogger.shared.logDictation(rawSpeech: text, processed: processed) HistoryManager.shared.addEntry(processed) - self.pasteTextToFrontmostApp(text: processed) + InputBoxManager.shared.commitFinalText(fullText: processed, addTrailingSpace: SonioxSettings.shared.addTrailingSpace) HUDOverlayController.shared.show(state: .success) } else { HUDOverlayController.shared.hide() } } - private func pasteTextToFrontmostApp(text: String) { - guard !text.isEmpty else { return } - - // 1. Put into system pasteboard - let textToPaste = SonioxSettings.shared.addTrailingSpace ? (text + " ") : text - let pb = NSPasteboard.general - pb.clearContents() - pb.setString(textToPaste, forType: .string) - - // 2. Check Accessibility permission - if !AXIsProcessTrusted() { - print("Accessibility permission NOT granted for auto-paste!") - let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary - AXIsProcessTrustedWithOptions(options) - return - } - - // 3. Instant paste via CGEvent (Cmd+V) after 40ms to let physical key-release clear - DispatchQueue.main.asyncAfter(deadline: .now() + 0.04) { - let source = CGEventSource(stateID: .combinedSessionState) - let vKeyCode: CGKeyCode = 0x09 // 'v' key - - if let keyDown = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: true), - let keyUp = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: false) { - keyDown.flags = .maskCommand - keyUp.flags = .maskCommand - keyDown.post(tap: .cghidEventTap) - keyUp.post(tap: .cghidEventTap) - print("AppDelegate: Pasted \(textToPaste.count) characters via Cmd+V successfully.") - } - } - } - public func applicationWillTerminate(_ notification: Notification) { HotkeyManager.shared.unregisterHotkeys() } diff --git a/src/HUDOverlay.swift b/src/HUDOverlay.swift index c08d883..3aac2b6 100644 --- a/src/HUDOverlay.swift +++ b/src/HUDOverlay.swift @@ -14,11 +14,11 @@ public final class HUDOverlayController { public static let shared = HUDOverlayController() private var window: NSPanel? - private var orbView: SiriOrbView? + private var orbView: MinimalSiriOrbView? private var isVisible = false - // Large canvas so shadows/halos NEVER clip at window boundaries - private let canvasSize: CGFloat = 260 + // Transparent canvas with plenty of margin so halo never clips + private let canvasSize: CGFloat = 160 private init() { DispatchQueue.main.async { @@ -44,10 +44,10 @@ public final class HUDOverlayController { let container = NSView(frame: panel.contentView!.bounds) container.wantsLayer = true - // Siri-like Circular Orb centered with plenty of breathing room for glowing halos - let orbSize: CGFloat = 64 - let orbOrigin = (canvasSize - orbSize) / 2.0 - let orb = SiriOrbView(frame: NSRect(x: orbOrigin, y: orbOrigin, width: orbSize, height: orbSize)) + // Minimal Siri Orb centered in canvas + let orbDiameter: CGFloat = 52 + let orbOrigin = (canvasSize - orbDiameter) / 2.0 + let orb = MinimalSiriOrbView(frame: NSRect(x: orbOrigin, y: orbOrigin, width: orbDiameter, height: orbDiameter)) container.addSubview(orb) self.orbView = orb @@ -59,9 +59,8 @@ public final class HUDOverlayController { private func reposition() { guard let window = self.window, let screen = NSScreen.main else { return } let screenRect = screen.visibleFrame - // Center horizontally, position near bottom of screen let x = screenRect.midX - (canvasSize / 2.0) - let y = screenRect.minY + 12 + let y = screenRect.minY + 28 window.setFrameOrigin(NSPoint(x: x, y: y)) } @@ -99,13 +98,13 @@ public final class HUDOverlayController { case .success: orb.showSuccessState() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.45) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.40) { self.hide() } case .error: orb.showErrorState() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.75) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.70) { self.hide() } } @@ -116,7 +115,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.20 + context.duration = 0.18 window.animator().alphaValue = 0.0 }, completionHandler: { window.orderOut(nil) @@ -127,15 +126,16 @@ public final class HUDOverlayController { } } -// MARK: - Premium Apple Intelligence / Siri Multilayer Glowing Orb +// MARK: - Minimal Clean Siri Orb (Static, Non-Rotating, Audio-Reactive Stroke & Glow) -final class SiriOrbView: NSView { - private let ambientAuraLayer = CALayer() - private let haloLayer = CALayer() - private let acousticRippleLayer = CALayer() - private let glassBackdrop = CALayer() - private let siriGradientLayer = CAGradientLayer() - private let specularHighlight = CAGradientLayer() +final class MinimalSiriOrbView: NSView { + private let glowLayer = CALayer() + private let gradientCoreLayer = CAGradientLayer() + private let strokeBorderLayer = CALayer() + + private let baseDiameter: CGFloat = 52.0 + private let minStrokeWidth: CGFloat = 1.5 + private let maxStrokeWidth: CGFloat = 5.5 override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -156,80 +156,41 @@ final class SiriOrbView: NSView { let bounds = self.bounds let radius = bounds.width / 2.0 - // 1. Deep Ambient Soft Aura (Diffused outer glow) - ambientAuraLayer.frame = bounds.insetBy(dx: -32, dy: -32) - ambientAuraLayer.cornerRadius = (bounds.width + 64) / 2.0 - ambientAuraLayer.backgroundColor = NSColor(red: 0.0, green: 0.65, blue: 1.0, alpha: 0.22).cgColor - ambientAuraLayer.shadowColor = NSColor(red: 0.55, green: 0.0, blue: 1.0, alpha: 0.85).cgColor - ambientAuraLayer.shadowRadius = 38 - ambientAuraLayer.shadowOpacity = 0.95 - ambientAuraLayer.shadowOffset = .zero - layer.addSublayer(ambientAuraLayer) - - // 2. Focused Neon Halo - haloLayer.frame = bounds.insetBy(dx: -14, dy: -14) - haloLayer.cornerRadius = (bounds.width + 28) / 2.0 - haloLayer.backgroundColor = NSColor(red: 0.1, green: 0.8, blue: 1.0, alpha: 0.40).cgColor - haloLayer.shadowColor = NSColor(red: 0.0, green: 0.9, blue: 1.0, alpha: 0.85).cgColor - haloLayer.shadowRadius = 18 - haloLayer.shadowOpacity = 0.9 - haloLayer.shadowOffset = .zero - layer.addSublayer(haloLayer) - - // 3. Acoustic Ripple Ring - acousticRippleLayer.frame = bounds.insetBy(dx: -4, dy: -4) - acousticRippleLayer.cornerRadius = (bounds.width + 8) / 2.0 - acousticRippleLayer.borderColor = NSColor(red: 0.0, green: 0.95, blue: 1.0, alpha: 0.35).cgColor - acousticRippleLayer.borderWidth = 1.5 - layer.addSublayer(acousticRippleLayer) - - // 4. Dark Glass Spherical Backdrop - glassBackdrop.frame = bounds - glassBackdrop.cornerRadius = radius - glassBackdrop.backgroundColor = NSColor.black.withAlphaComponent(0.85).cgColor - glassBackdrop.borderColor = NSColor.white.withAlphaComponent(0.35).cgColor - glassBackdrop.borderWidth = 1.0 - layer.addSublayer(glassBackdrop) - - // 5. Multi-Color Liquid Siri Gradient Core - siriGradientLayer.frame = bounds.insetBy(dx: 3, dy: 3) - siriGradientLayer.cornerRadius = (bounds.width - 6) / 2.0 - siriGradientLayer.type = .radial - siriGradientLayer.colors = [ - NSColor(red: 0.0, green: 0.95, blue: 1.0, alpha: 1.0).cgColor, // Electric Cyan Center - NSColor(red: 0.50, green: 0.12, blue: 1.0, alpha: 0.95).cgColor, // Royal Violet - NSColor(red: 0.98, green: 0.08, blue: 0.65, alpha: 0.90).cgColor, // Hot Magenta - NSColor(red: 0.10, green: 0.20, blue: 0.95, alpha: 0.85).cgColor // Deep Blue - ] - siriGradientLayer.startPoint = CGPoint(x: 0.35, y: 0.35) - siriGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0) - layer.addSublayer(siriGradientLayer) + // 1. Soft Outer Atmospheric Glow (Breathes with voice volume) + glowLayer.frame = bounds.insetBy(dx: -10, dy: -10) + glowLayer.cornerRadius = (bounds.width + 20) / 2.0 + glowLayer.backgroundColor = NSColor(red: 0.05, green: 0.65, blue: 1.0, alpha: 0.35).cgColor + glowLayer.shadowColor = NSColor(red: 0.50, green: 0.10, blue: 0.95, alpha: 0.85).cgColor + glowLayer.shadowRadius = 14 + glowLayer.shadowOpacity = 0.60 + glowLayer.shadowOffset = .zero + layer.addSublayer(glowLayer) - // 6. 3D Curved Specular Glass Crescent Highlight (Apple Intelligence style) - specularHighlight.frame = NSRect(x: bounds.midX - 18, y: bounds.maxY - 18, width: 36, height: 12) - specularHighlight.cornerRadius = 6 - specularHighlight.colors = [ - NSColor.white.withAlphaComponent(0.65).cgColor, - NSColor.white.withAlphaComponent(0.0).cgColor + // 2. Clean, Minimal, Non-Rotating Siri Gradient Sphere + gradientCoreLayer.frame = bounds + gradientCoreLayer.cornerRadius = radius + gradientCoreLayer.type = .radial + gradientCoreLayer.colors = [ + NSColor(red: 0.0, green: 0.92, blue: 1.0, alpha: 1.0).cgColor, // Electric Cyan Core + NSColor(red: 0.45, green: 0.12, blue: 0.96, alpha: 0.98).cgColor, // Rich Royal Violet + NSColor(red: 0.90, green: 0.08, blue: 0.58, alpha: 0.92).cgColor, // Deep Magenta + NSColor(red: 0.08, green: 0.10, blue: 0.35, alpha: 0.88).cgColor // Deep Navy Edge ] - specularHighlight.startPoint = CGPoint(x: 0.5, y: 1.0) - specularHighlight.endPoint = CGPoint(x: 0.5, y: 0.0) - layer.addSublayer(specularHighlight) + gradientCoreLayer.startPoint = CGPoint(x: 0.40, y: 0.40) + gradientCoreLayer.endPoint = CGPoint(x: 1.0, y: 1.0) + layer.addSublayer(gradientCoreLayer) - // Start fluid rotation - startOrbRotation() - } - - private func startOrbRotation() { - let rotate = CABasicAnimation(keyPath: "transform.rotation.z") - rotate.fromValue = 0.0 - rotate.toValue = 2 * Double.pi - rotate.duration = 4.0 - rotate.repeatCount = .infinity - rotate.isRemovedOnCompletion = false - siriGradientLayer.add(rotate, forKey: "siriFluidRotate") + // 3. Dynamic Audio-Reactive Stroke Ring (Widening & brightening with speech) + strokeBorderLayer.frame = bounds + strokeBorderLayer.cornerRadius = radius + strokeBorderLayer.borderWidth = minStrokeWidth + strokeBorderLayer.borderColor = NSColor.white.withAlphaComponent(0.35).cgColor + strokeBorderLayer.backgroundColor = NSColor.clear.cgColor + layer.addSublayer(strokeBorderLayer) } + /// Updates audio response: The orb remains steady (no jiggling/scaling), + /// while the stroke border thickness and outer glow bloom dynamically with voice. func updateAudioLevel(_ level: Float, isLagging: Bool) { CATransaction.begin() CATransaction.setDisableActions(true) @@ -237,41 +198,40 @@ final class SiriOrbView: NSView { let clamped = CGFloat(min(max(level, 0.0), 1.0)) if isLagging { - // Warm amber pulse for weak internet - siriGradientLayer.colors = [ - NSColor(red: 1.0, green: 0.85, blue: 0.2, alpha: 1.0).cgColor, - NSColor(red: 1.0, green: 0.55, blue: 0.0, alpha: 0.95).cgColor, - NSColor(red: 0.95, green: 0.25, blue: 0.0, alpha: 0.90).cgColor + // Warm amber indicator for network degradation + gradientCoreLayer.colors = [ + NSColor(red: 1.0, green: 0.80, blue: 0.20, alpha: 1.0).cgColor, + NSColor(red: 1.0, green: 0.45, blue: 0.0, alpha: 0.95).cgColor, + NSColor(red: 0.85, green: 0.15, blue: 0.0, alpha: 0.90).cgColor ] - haloLayer.backgroundColor = NSColor(red: 1.0, green: 0.5, blue: 0.0, alpha: 0.5).cgColor - haloLayer.shadowColor = NSColor(red: 1.0, green: 0.4, blue: 0.0, alpha: 0.9).cgColor - ambientAuraLayer.shadowColor = NSColor(red: 1.0, green: 0.3, blue: 0.0, alpha: 0.7).cgColor + let strokeColor = NSColor(red: 1.0, green: 0.70, blue: 0.20, alpha: 0.6 + (0.4 * clamped)) + strokeBorderLayer.borderColor = strokeColor.cgColor + glowLayer.shadowColor = NSColor.orange.cgColor } else { - // High-fidelity Siri electric gradient - siriGradientLayer.colors = [ - NSColor(red: 0.0, green: 0.95, blue: 1.0, alpha: 1.0).cgColor, - NSColor(red: 0.50, green: 0.12, blue: 1.0, alpha: 0.95).cgColor, - NSColor(red: 0.98, green: 0.08, blue: 0.65, alpha: 0.90).cgColor, - NSColor(red: 0.10, green: 0.20, blue: 0.95, alpha: 0.85).cgColor + // Standard crisp Siri palette + gradientCoreLayer.colors = [ + NSColor(red: 0.0, green: 0.92, blue: 1.0, alpha: 1.0).cgColor, + NSColor(red: 0.45, green: 0.12, blue: 0.96, alpha: 0.98).cgColor, + NSColor(red: 0.90, green: 0.08, blue: 0.58, alpha: 0.92).cgColor, + NSColor(red: 0.08, green: 0.10, blue: 0.35, alpha: 0.88).cgColor ] - haloLayer.backgroundColor = NSColor(red: 0.1, green: 0.8, blue: 1.0, alpha: 0.40).cgColor - haloLayer.shadowColor = NSColor(red: 0.0, green: 0.9, blue: 1.0, alpha: 0.85).cgColor - ambientAuraLayer.shadowColor = NSColor(red: 0.55, green: 0.0, blue: 1.0, alpha: 0.85).cgColor + let strokeColor = NSColor( + red: 0.3 + (0.7 * clamped), + green: 0.95, + blue: 1.0, + alpha: 0.35 + (0.65 * clamped) + ) + strokeBorderLayer.borderColor = strokeColor.cgColor + glowLayer.shadowColor = NSColor(red: 0.45, green: 0.10, blue: 0.95, alpha: 0.85).cgColor } - // Massive Audio Dynamic Scaling (Orb expands up to 1.65x with voice) - let scale = 1.0 + (clamped * 0.65) - self.layer?.sublayerTransform = CATransform3DMakeScale(scale, scale, 1.0) - - // Dynamic Halo bloom (Expands smoothly up to 55px radius) - haloLayer.shadowRadius = 18 + (clamped * 36) - haloLayer.shadowOpacity = Float(0.75 + (clamped * 0.25)) - ambientAuraLayer.shadowRadius = 38 + (clamped * 42) + // 1. Dynamic Stroke Width: from 1.5px up to 5.5px based on voice volume + let dynamicWidth = minStrokeWidth + (clamped * (maxStrokeWidth - minStrokeWidth)) + strokeBorderLayer.borderWidth = dynamicWidth - // Acoustic ripple expands outward - let rippleScale = 1.0 + (clamped * 0.35) - acousticRippleLayer.transform = CATransform3DMakeScale(rippleScale, rippleScale, 1.0) - acousticRippleLayer.opacity = Float(0.3 + (clamped * 0.7)) + // 2. Dynamic Outer Glow: blooms softly with speech + glowLayer.shadowRadius = 14 + (clamped * 24) + glowLayer.shadowOpacity = Float(0.60 + (clamped * 0.35)) CATransaction.commit() } @@ -279,58 +239,52 @@ final class SiriOrbView: NSView { func showRetryState() { CATransaction.begin() CATransaction.setDisableActions(false) - CATransaction.setAnimationDuration(0.3) - siriGradientLayer.colors = [ - NSColor(red: 1.0, green: 0.85, blue: 0.2, alpha: 1.0).cgColor, - NSColor(red: 1.0, green: 0.50, blue: 0.0, alpha: 0.95).cgColor, - NSColor(red: 0.95, green: 0.20, blue: 0.0, alpha: 0.90).cgColor - ] - haloLayer.shadowColor = NSColor.orange.cgColor - ambientAuraLayer.shadowColor = NSColor.orange.cgColor + CATransaction.setAnimationDuration(0.25) + strokeBorderLayer.borderWidth = 3.5 + strokeBorderLayer.borderColor = NSColor.orange.cgColor + glowLayer.shadowColor = NSColor.orange.cgColor + glowLayer.shadowRadius = 22 CATransaction.commit() } func showTranscribingState() { CATransaction.begin() CATransaction.setDisableActions(true) - self.layer?.sublayerTransform = CATransform3DMakeScale(1.08, 1.08, 1.0) + strokeBorderLayer.borderWidth = 2.5 + strokeBorderLayer.borderColor = NSColor.cyan.cgColor + glowLayer.shadowRadius = 18 CATransaction.commit() } func showSuccessState() { CATransaction.begin() CATransaction.setDisableActions(false) - CATransaction.setAnimationDuration(0.2) - siriGradientLayer.colors = [ - NSColor(red: 0.2, green: 1.0, blue: 0.5, alpha: 1.0).cgColor, - NSColor(red: 0.0, green: 0.85, blue: 0.8, alpha: 0.95).cgColor - ] - haloLayer.shadowColor = NSColor.green.cgColor - ambientAuraLayer.shadowColor = NSColor.green.cgColor + CATransaction.setAnimationDuration(0.18) + strokeBorderLayer.borderWidth = 3.0 + strokeBorderLayer.borderColor = NSColor(red: 0.1, green: 0.95, blue: 0.5, alpha: 1.0).cgColor + glowLayer.shadowColor = NSColor.green.cgColor + glowLayer.shadowRadius = 20 CATransaction.commit() } func showErrorState() { CATransaction.begin() CATransaction.setDisableActions(false) - CATransaction.setAnimationDuration(0.2) - siriGradientLayer.colors = [ - NSColor(red: 1.0, green: 0.2, blue: 0.2, alpha: 1.0).cgColor, - NSColor(red: 0.8, green: 0.0, blue: 0.4, alpha: 0.95).cgColor - ] - haloLayer.shadowColor = NSColor.red.cgColor - ambientAuraLayer.shadowColor = NSColor.red.cgColor + CATransaction.setAnimationDuration(0.18) + strokeBorderLayer.borderWidth = 3.0 + strokeBorderLayer.borderColor = NSColor.red.cgColor + glowLayer.shadowColor = NSColor.red.cgColor + glowLayer.shadowRadius = 20 CATransaction.commit() } func reset() { CATransaction.begin() CATransaction.setDisableActions(true) - self.layer?.sublayerTransform = CATransform3DIdentity - haloLayer.shadowRadius = 18 - ambientAuraLayer.shadowRadius = 38 - acousticRippleLayer.transform = CATransform3DIdentity - acousticRippleLayer.opacity = 0.3 + strokeBorderLayer.borderWidth = minStrokeWidth + strokeBorderLayer.borderColor = NSColor.white.withAlphaComponent(0.35).cgColor + glowLayer.shadowRadius = 14 + glowLayer.shadowOpacity = 0.60 CATransaction.commit() } } diff --git a/src/InputBoxManager.swift b/src/InputBoxManager.swift index 605d328..0df29df 100644 --- a/src/InputBoxManager.swift +++ b/src/InputBoxManager.swift @@ -4,11 +4,24 @@ import ApplicationServices public final class InputBoxManager { public static let shared = InputBoxManager() + private let lock = NSLock() + private var accumulatedStreamedText: String = "" + private let pasteQueue = DispatchQueue(label: "com.soniox.pasteQueue", qos: .userInteractive) + private init() {} - /// Prepares and guarantees that an input box is focused before or at the start of recording. + /// Reset session state at the beginning of speech + public func resetSession() { + lock.lock() + accumulatedStreamedText = "" + lock.unlock() + } + + /// Prepares and guarantees that an input box is focused before recording starts. /// STRICT RULE: If an input box is already focused, NEVER click or steal focus! public func ensureFocusedInputBox() { + resetSession() + guard AXIsProcessTrusted() else { print("InputBoxManager: Accessibility not trusted.") return @@ -21,7 +34,6 @@ public final class InputBoxManager { var focusedObj: CFTypeRef? if AXUIElementCopyAttributeValue(appElem, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success, focusedObj != nil { - // Already focused, preserve user focus untouched! return } @@ -44,6 +56,92 @@ public final class InputBoxManager { } } + /// Streams word-by-word delta text directly into the focused input box in real time + public func streamDelta(text: String) { + guard !text.isEmpty else { return } + + lock.lock() + accumulatedStreamedText += text + lock.unlock() + + pasteQueue.async { [weak self] in + guard let self = self else { return } + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(text, forType: .string) + self.postCleanCmdV() + usleep(25000) // 25ms pacing between chunks + } + } + + /// Commits the final speech result: ensures 100% complete text in input box & clipboard + public func commitFinalText(fullText: String, addTrailingSpace: Bool) { + let trimmed = fullText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + + let textWithTrailing = addTrailingSpace ? (trimmed + " ") : trimmed + + // Always place the full processed text on the system clipboard + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(textWithTrailing, forType: .string) + + lock.lock() + let streamed = accumulatedStreamedText + accumulatedStreamedText = "" + lock.unlock() + + let cleanStreamed = streamed.trimmingCharacters(in: .whitespacesAndNewlines) + + pasteQueue.async { [weak self] in + guard let self = self else { return } + + // Case 1: Nothing was streamed live -> Paste entire text + if cleanStreamed.isEmpty { + self.postCleanCmdV() + return + } + + // Case 2: Text was streamed live + // Check if there are trailing words recognized only at the very end + if trimmed.count > cleanStreamed.count { + if trimmed.hasPrefix(cleanStreamed) { + let suffix = String(trimmed.dropFirst(cleanStreamed.count)) + let suffixToPaste = addTrailingSpace ? (suffix + " ") : suffix + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(suffixToPaste, forType: .string) + self.postCleanCmdV() + } else if addTrailingSpace { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(" ", forType: .string) + self.postCleanCmdV() + } + } else if addTrailingSpace { + let pb = NSPasteboard.general + pb.clearContents() + pb.setString(" ", forType: .string) + self.postCleanCmdV() + } + } + } + + /// Posts Cmd+V using privateState event source to cgAnnotatedSessionEventTap + /// This strips physical Option ⌥ / Shift / Control flags and pastes cleanly across all apps + private func postCleanCmdV() { + let src = CGEventSource(stateID: .privateState) + let vKeyCode: CGKeyCode = 0x09 // 'v' + + if let down = CGEvent(keyboardEventSource: src, virtualKey: vKeyCode, keyDown: true), + let up = CGEvent(keyboardEventSource: src, virtualKey: vKeyCode, keyDown: false) { + down.flags = .maskCommand + up.flags = .maskCommand + down.post(tap: .cgAnnotatedSessionEventTap) + up.post(tap: .cgAnnotatedSessionEventTap) + } + } + private func findBestInputElement(in parent: AXUIElement, maxDepth: Int = 8, depth: Int = 0) -> AXUIElement? { if depth > maxDepth { return nil }