Browse Source

fix: ensure 100% input box insertion with isolated private CGEvent, remove subtitle text under Siri orb, amplify audio reactive halo

main
Ali Alavi 2 weeks ago
parent
commit
08b313a7cc
  1. 34
      src/AppDelegate.swift
  2. 149
      src/HUDOverlay.swift
  3. 166
      src/InputBoxManager.swift

34
src/AppDelegate.swift

@ -8,7 +8,6 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
private var activeSession: SonioxLiveSession? private var activeSession: SonioxLiveSession?
private var isBusyFinalizing = false private var isBusyFinalizing = false
private var currentAudioLevel: Float = 0.0 private var currentAudioLevel: Float = 0.0
private var latestPartialText: String? = nil
private var pendingStopWorkItem: DispatchWorkItem? = nil private var pendingStopWorkItem: DispatchWorkItem? = nil
private var isNetworkDegraded: Bool = false private var isNetworkDegraded: Bool = false
private var lastRecordedAudio: Data = Data() private var lastRecordedAudio: Data = Data()
@ -62,7 +61,7 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
guard let self = self else { return } guard let self = self else { return }
self.currentAudioLevel = level self.currentAudioLevel = level
if self.audioRecorder.isRecording { 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() { public func startRecording() {
if audioRecorder.isRecording || isBusyFinalizing { return } if audioRecorder.isRecording || isBusyFinalizing { return }
latestPartialText = nil
isNetworkDegraded = false isNetworkDegraded = false
lastRecordedAudio.removeAll() 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() InputBoxManager.shared.ensureFocusedInputBox()
// 2. Acquire pre-warmed / fast-connected WebSocket session // 2. Acquire pre-warmed / fast-connected WebSocket session
@ -126,11 +124,11 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
guard let self = self else { return } guard let self = self else { return }
self.isNetworkDegraded = degraded self.isNetworkDegraded = degraded
if self.audioRecorder.isRecording { 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 session.onCommittedDelta = { delta in
// If it's an executive slash command, do NOT type it into the input box // If it's an executive slash command, do NOT type it into the input box
if SonioxSettings.shared.voiceCommands && VoiceCommandExecutor.shared.isSlashCommand(text: delta) { if SonioxSettings.shared.voiceCommands && VoiceCommandExecutor.shared.isSlashCommand(text: delta) {
@ -139,14 +137,6 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
InputBoxManager.shared.streamDelta(text: delta) 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 session.onFinalResult = { [weak self] result in
DispatchQueue.main.async { DispatchQueue.main.async {
guard let self = self else { return } guard let self = self else { return }
@ -159,12 +149,12 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
playSystemSound(name: "Tink") playSystemSound(name: "Tink")
statusBarController.updateIcon(state: .recording) statusBarController.updateIcon(state: .recording)
statusBarController.buildMenu(isRecording: true) 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 { } catch {
print("Failed to start audio recording:", error) print("Failed to start audio recording:", error)
activeSession?.cancel() activeSession?.cancel()
activeSession = nil 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 { if needsRetry {
print("AppDelegate: Network drop or truncated output detected. Triggering Smart Audio Recovery with \(self.lastRecordedAudio.count) bytes...") 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 audioCopy = self.lastRecordedAudio
let hints = SonioxSettings.shared.language.hints 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 } guard let self = self else { return }
self.isBusyFinalizing = false self.isBusyFinalizing = false
@ -244,18 +232,18 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
// 1. Check for Executive Voice Slash Commands // 1. Check for Executive Voice Slash Commands
if SonioxSettings.shared.voiceCommands && VoiceCommandExecutor.shared.isSlashCommand(text: text) { 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) VoiceCommandExecutor.shared.execute(rawText: text)
return // STRICT: Slash commands are executed, never pasted! 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) let processed = TextProcessor.shared.process(text: text)
if !processed.isEmpty { if !processed.isEmpty {
VoiceCommandLogger.shared.logDictation(rawSpeech: text, processed: processed) VoiceCommandLogger.shared.logDictation(rawSpeech: text, processed: processed)
HistoryManager.shared.addEntry(processed) HistoryManager.shared.addEntry(processed)
InputBoxManager.shared.commitFinalText(processedText: processed, addTrailingSpace: SonioxSettings.shared.addTrailingSpace) InputBoxManager.shared.commitFinalText(processedText: processed, addTrailingSpace: SonioxSettings.shared.addTrailingSpace)
HUDOverlayController.shared.show(state: .success(text: processed))
HUDOverlayController.shared.show(state: .success)
} else { } else {
HUDOverlayController.shared.hide() HUDOverlayController.shared.hide()
} }

149
src/HUDOverlay.swift

@ -3,11 +3,11 @@ import QuartzCore
public enum HUDState { public enum HUDState {
case hidden 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 transcribing
case success(text: String)
case error(message: String)
case success
case error
} }
public final class HUDOverlayController { public final class HUDOverlayController {
@ -15,7 +15,6 @@ public final class HUDOverlayController {
private var window: NSPanel? private var window: NSPanel?
private var orbView: SiriOrbView? private var orbView: SiriOrbView?
private var statusLabel: NSTextField?
private var isVisible = false private var isVisible = false
private init() { private init() {
@ -25,8 +24,9 @@ public final class HUDOverlayController {
} }
private func setupWindow() { private func setupWindow() {
let size: CGFloat = 84
let panel = NSPanel( 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], styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered, backing: .buffered,
defer: false defer: false
@ -42,25 +42,11 @@ public final class HUDOverlayController {
let container = NSView(frame: panel.contentView!.bounds) let container = NSView(frame: panel.contentView!.bounds)
container.wantsLayer = true 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) container.addSubview(orb)
self.orbView = 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 panel.contentView = container
self.window = panel self.window = panel
reposition() reposition()
@ -86,59 +72,35 @@ public final class HUDOverlayController {
switch state { switch state {
case .hidden: case .hidden:
self.hide() self.hide()
return
case .recording(let level, let liveText, let isLagging):
case .recording(let level, let isLagging):
if !self.isVisible { if !self.isVisible {
window.orderFrontRegardless() window.orderFrontRegardless()
window.animator().alphaValue = 1.0 window.animator().alphaValue = 1.0
self.isVisible = true self.isVisible = true
} }
orb.updateAudioLevel(level, isLagging: isLagging) 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 { if !self.isVisible {
window.orderFrontRegardless() window.orderFrontRegardless()
window.animator().alphaValue = 1.0 window.animator().alphaValue = 1.0
self.isVisible = true self.isVisible = true
} }
orb.showRetryState() orb.showRetryState()
self.statusLabel?.stringValue = message
self.statusLabel?.textColor = NSColor(red: 1.0, green: 0.65, blue: 0.1, alpha: 1.0)
case .transcribing: case .transcribing:
orb.showTranscribingState() orb.showTranscribingState()
self.statusLabel?.stringValue = "تبدیل به متن..."
self.statusLabel?.textColor = NSColor.white.withAlphaComponent(0.8)
case .success(let text):
case .success:
orb.showSuccessState() 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() self.hide()
} }
case .error(let msg):
case .error:
orb.showErrorState() 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() self.hide()
} }
} }
@ -149,7 +111,7 @@ public final class HUDOverlayController {
DispatchQueue.main.async { [weak self] in DispatchQueue.main.async { [weak self] in
guard let self = self, let window = self.window, self.isVisible else { return } guard let self = self, let window = self.window, self.isVisible else { return }
NSAnimationContext.runAnimationGroup({ context in NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.25
context.duration = 0.22
window.animator().alphaValue = 0.0 window.animator().alphaValue = 0.0
}, completionHandler: { }, completionHandler: {
window.orderOut(nil) 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 { final class SiriOrbView: NSView {
private let gradientLayer = CAGradientLayer() private let gradientLayer = CAGradientLayer()
private let glowLayer = CALayer() private let glowLayer = CALayer()
private let backdropLayer = CALayer() private let backdropLayer = CALayer()
private var baseRadius: CGFloat = 29
private var baseRadius: CGFloat = 30
override init(frame frameRect: NSRect) { override init(frame frameRect: NSRect) {
super.init(frame: frameRect) super.init(frame: frameRect)
@ -187,21 +149,21 @@ final class SiriOrbView: NSView {
let bounds = self.bounds let bounds = self.bounds
baseRadius = bounds.width / 2.0 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 glowLayer.shadowOffset = .zero
layer.addSublayer(glowLayer) layer.addSublayer(glowLayer)
// 2. Dark Translucent Backdrop
// 2. Dark Glass Backdrop
backdropLayer.frame = bounds backdropLayer.frame = bounds
backdropLayer.cornerRadius = baseRadius 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 backdropLayer.borderWidth = 1.0
layer.addSublayer(backdropLayer) layer.addSublayer(backdropLayer)
@ -209,15 +171,15 @@ final class SiriOrbView: NSView {
gradientLayer.frame = bounds.insetBy(dx: 4, dy: 4) gradientLayer.frame = bounds.insetBy(dx: 4, dy: 4)
gradientLayer.cornerRadius = (bounds.width - 8) / 2.0 gradientLayer.cornerRadius = (bounds.width - 8) / 2.0
gradientLayer.colors = [ 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.startPoint = CGPoint(x: 0.0, y: 0.0)
gradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0) gradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
layer.addSublayer(gradientLayer) layer.addSublayer(gradientLayer)
// Subtle constant breathing idle animation
// Continuous smooth rotation
startOrbRotation() startOrbRotation()
} }
@ -225,7 +187,7 @@ final class SiriOrbView: NSView {
let rotate = CABasicAnimation(keyPath: "transform.rotation.z") let rotate = CABasicAnimation(keyPath: "transform.rotation.z")
rotate.fromValue = 0.0 rotate.fromValue = 0.0
rotate.toValue = 2 * Double.pi rotate.toValue = 2 * Double.pi
rotate.duration = 4.0
rotate.duration = 3.5
rotate.repeatCount = .infinity rotate.repeatCount = .infinity
rotate.isRemovedOnCompletion = false rotate.isRemovedOnCompletion = false
gradientLayer.add(rotate, forKey: "siriRotate") gradientLayer.add(rotate, forKey: "siriRotate")
@ -235,31 +197,36 @@ final class SiriOrbView: NSView {
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(true) CATransaction.setDisableActions(true)
let clamped = CGFloat(min(max(level, 0.0), 1.0))
if isLagging { if isLagging {
// Amber warm warning colors
// Warm amber pulse for weak internet
gradientLayer.colors = [ 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 { } else {
// Standard vibrant Siri gradient // Standard vibrant Siri gradient
gradientLayer.colors = [ 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) 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() CATransaction.commit()
} }
@ -269,18 +236,19 @@ final class SiriOrbView: NSView {
CATransaction.setDisableActions(false) CATransaction.setDisableActions(false)
CATransaction.setAnimationDuration(0.3) CATransaction.setAnimationDuration(0.3)
gradientLayer.colors = [ 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: 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 NSColor(red: 0.95, green: 0.85, blue: 0.2, alpha: 0.9).cgColor
] ]
glowLayer.shadowColor = NSColor.orange.cgColor glowLayer.shadowColor = NSColor.orange.cgColor
glowLayer.shadowRadius = 28
CATransaction.commit() CATransaction.commit()
} }
func showTranscribingState() { func showTranscribingState() {
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(true) 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() CATransaction.commit()
} }
@ -289,10 +257,11 @@ final class SiriOrbView: NSView {
CATransaction.setDisableActions(false) CATransaction.setDisableActions(false)
CATransaction.setAnimationDuration(0.2) CATransaction.setAnimationDuration(0.2)
gradientLayer.colors = [ 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.shadowColor = NSColor.green.cgColor
glowLayer.shadowRadius = 24
CATransaction.commit() CATransaction.commit()
} }
@ -305,6 +274,7 @@ final class SiriOrbView: NSView {
NSColor(red: 0.8, green: 0.0, blue: 0.3, alpha: 0.95).cgColor NSColor(red: 0.8, green: 0.0, blue: 0.3, alpha: 0.95).cgColor
] ]
glowLayer.shadowColor = NSColor.red.cgColor glowLayer.shadowColor = NSColor.red.cgColor
glowLayer.shadowRadius = 24
CATransaction.commit() CATransaction.commit()
} }
@ -312,6 +282,7 @@ final class SiriOrbView: NSView {
CATransaction.begin() CATransaction.begin()
CATransaction.setDisableActions(true) CATransaction.setDisableActions(true)
self.layer?.sublayerTransform = CATransform3DIdentity self.layer?.sublayerTransform = CATransform3DIdentity
glowLayer.shadowRadius = 18
CATransaction.commit() CATransaction.commit()
} }
} }

166
src/InputBoxManager.swift

@ -5,28 +5,26 @@ public final class InputBoxManager {
public static let shared = InputBoxManager() public static let shared = InputBoxManager()
private var targetedElement: AXUIElement? private var targetedElement: AXUIElement?
private var streamedCharacterCount: Int = 0
private var didStreamAnyText: Bool = false
private var streamedText: String = ""
private let lock = NSLock() private let lock = NSLock()
private init() {} 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() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
streamedCharacterCount = 0
didStreamAnyText = false
streamedText = ""
targetedElement = nil targetedElement = nil
guard AXIsProcessTrusted() else { 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) let appElem = AXUIElementCreateApplication(frontApp.processIdentifier)
// 1. Check if an element is already focused in the frontmost application // 1. Check if an element is already focused in the frontmost application
@ -35,55 +33,50 @@ public final class InputBoxManager {
let focused = focusedObj { let focused = focusedObj {
let elem = (focused as! AXUIElement) let elem = (focused as! AXUIElement)
if isEditableElement(elem) { if isEditableElement(elem) {
print("InputBoxManager: User already has focused editable element in \(frontApp.localizedName ?? ""). Preserving focus.")
targetedElement = elem targetedElement = elem
return elem
return
} }
} }
// 2. Also check systemWide focus
// 2. Check systemWide focus
let sysWide = AXUIElementCreateSystemWide() let sysWide = AXUIElementCreateSystemWide()
if AXUIElementCopyAttributeValue(sysWide, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success, if AXUIElementCopyAttributeValue(sysWide, kAXFocusedUIElementAttribute as CFString, &focusedObj) == .success,
let focused = focusedObj { let focused = focusedObj {
let elem = (focused as! AXUIElement) let elem = (focused as! AXUIElement)
if isEditableElement(elem) { if isEditableElement(elem) {
print("InputBoxManager: System-wide focused editable element found. Preserving focus.")
targetedElement = elem 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? var windowObj: CFTypeRef?
if AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &windowObj) == .success, if AXUIElementCopyAttributeValue(appElem, kAXFocusedWindowAttribute as CFString, &windowObj) == .success,
let win = windowObj { let win = windowObj {
let winElem = (win as! AXUIElement) let winElem = (win as! AXUIElement)
if let bestInput = findBestInputElement(in: winElem) { 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) 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) { if let center = getElementCenter(bestInput) {
clickAtPoint(center) clickAtPoint(center)
} }
targetedElement = bestInput 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) { public func streamDelta(text: String) {
guard !text.isEmpty else { return } guard !text.isEmpty else { return }
lock.lock() lock.lock()
didStreamAnyText = true
streamedCharacterCount += text.count
streamedText += text
let target = targetedElement let target = targetedElement
lock.unlock() lock.unlock()
// 1. Try direct Accessibility insertion if element supports it
// 1. Try Accessibility selected text insertion if supported
if let elem = target { if let elem = target {
var selectedTextObj: CFTypeRef? var selectedTextObj: CFTypeRef?
if AXUIElementCopyAttributeValue(elem, kAXSelectedTextAttribute as CFString, &selectedTextObj) == .success { 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) 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) { 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 let pb = NSPasteboard.general
pb.clearContents() pb.clearContents()
pb.setString(textToSave, forType: .string)
pb.setString(textToClipboard, forType: .string)
lock.lock() lock.lock()
let hadStreamed = didStreamAnyText
let streamedCount = streamedCharacterCount
let previouslyStreamed = streamedText
streamedText = ""
targetedElement = nil targetedElement = nil
lock.unlock() 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() 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() { 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: - Helpers
// 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: - Accessibility Helpers
private func isEditableElement(_ elem: AXUIElement) -> Bool { private func isEditableElement(_ elem: AXUIElement) -> Bool {
var roleObj: CFTypeRef? var roleObj: CFTypeRef?
@ -192,27 +246,13 @@ public final class InputBoxManager {
} }
private func clickAtPoint(_ point: CGPoint) { 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), if let clickDown = CGEvent(mouseEventSource: source, mouseType: .leftMouseDown, mouseCursorPosition: point, mouseButton: .left),
let clickUp = CGEvent(mouseEventSource: source, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left) { let clickUp = CGEvent(mouseEventSource: source, mouseType: .leftMouseUp, mouseCursorPosition: point, mouseButton: .left) {
clickDown.flags = []
clickUp.flags = []
clickDown.post(tap: .cghidEventTap) clickDown.post(tap: .cghidEventTap)
clickUp.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)
}
} }
Loading…
Cancel
Save