Browse Source

fix: prevent double paste and completely remove HUD toast popup for instant headless insertion

main
Ali Alavi 17 hours ago
parent
commit
6ba31476bc
  1. 7
      package.sh
  2. 29
      src/AppDelegate.swift
  3. 176
      src/HUDOverlay.swift
  4. 36
      src/HotkeyManager.swift

7
package.sh

@ -1,7 +1,8 @@
#!/bin/zsh #!/bin/zsh
set -e set -e
PROJECT_DIR="/Users/alig/Develop/SonioxVoice"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="${PROJECT_DIR:-$SCRIPT_DIR}"
BUILD_DIR="$PROJECT_DIR/build" BUILD_DIR="$PROJECT_DIR/build"
APP_NAME="Soniox Voice" APP_NAME="Soniox Voice"
APP_BUNDLE="$BUILD_DIR/$APP_NAME.app" APP_BUNDLE="$BUILD_DIR/$APP_NAME.app"
@ -21,6 +22,10 @@ swiftc -O -target arm64-apple-macosx13.0 \
mkdir -p "$APP_BUNDLE/Contents/MacOS" mkdir -p "$APP_BUNDLE/Contents/MacOS"
mkdir -p "$APP_BUNDLE/Contents/Resources" mkdir -p "$APP_BUNDLE/Contents/Resources"
if [ ! -f "$PROJECT_DIR/resources/AppIcon.icns" ] && [ -d "$PROJECT_DIR/resources/AppIcon.iconset" ]; then
iconutil -c icns "$PROJECT_DIR/resources/AppIcon.iconset" -o "$PROJECT_DIR/resources/AppIcon.icns"
fi
cp "$BUILD_DIR/SonioxVoice" "$APP_BUNDLE/Contents/MacOS/SonioxVoice" cp "$BUILD_DIR/SonioxVoice" "$APP_BUNDLE/Contents/MacOS/SonioxVoice"
cp "$PROJECT_DIR/Info.plist" "$APP_BUNDLE/Contents/Info.plist" cp "$PROJECT_DIR/Info.plist" "$APP_BUNDLE/Contents/Info.plist"
cp "$PROJECT_DIR/resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/AppIcon.icns" cp "$PROJECT_DIR/resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/AppIcon.icns"

29
src/AppDelegate.swift

@ -187,29 +187,18 @@ public final class AppDelegate: NSObject, NSApplicationDelegate {
return return
} }
// 3. Instant paste via CGEvent with minimal delay
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
// 3. Instant single paste via CGEvent
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) {
let source = CGEventSource(stateID: .combinedSessionState) let source = CGEventSource(stateID: .combinedSessionState)
let vKeyCode: CGKeyCode = 0x09 // 'v' key let vKeyCode: CGKeyCode = 0x09 // 'v' key
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: true)
keyDown?.flags = .maskCommand
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: false)
keyUp?.flags = .maskCommand
keyDown?.post(tap: .cgAnnotatedSessionEventTap)
keyUp?.post(tap: .cgAnnotatedSessionEventTap)
keyDown?.post(tap: .cghidEventTap)
keyUp?.post(tap: .cghidEventTap)
// Backup via AppleScript
let script = NSAppleScript(source: """
tell application "System Events"
keystroke "v" using command down
end tell
""")
var errDict: NSDictionary?
script?.executeAndReturnError(&errDict)
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)
}
} }
} }

176
src/HUDOverlay.swift

@ -10,177 +10,7 @@ public enum HUDState {
public final class HUDOverlayController { public final class HUDOverlayController {
public static let shared = HUDOverlayController() public static let shared = HUDOverlayController()
private var window: NSPanel?
private var visualEffectView: NSVisualEffectView?
private var iconImageView: NSImageView?
private var titleLabel: NSTextField?
private var subtitleLabel: NSTextField?
private var hideTimer: Timer?
private init() {
setupWindow()
}
private func setupWindow() {
let width: CGFloat = 460
let height: CGFloat = 80
let panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: width, height: height),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.level = .floating
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.ignoresMouseEvents = true
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
let visualEffect = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: width, height: height))
visualEffect.material = .hudWindow
visualEffect.blendingMode = .behindWindow
visualEffect.state = .active
visualEffect.wantsLayer = true
visualEffect.layer?.cornerRadius = 24
visualEffect.layer?.masksToBounds = true
visualEffect.layer?.borderWidth = 1.2
visualEffect.layer?.borderColor = NSColor.white.withAlphaComponent(0.25).cgColor
// Icon Image View
let iconView = NSImageView(frame: NSRect(x: 18, y: (height - 42) / 2, width: 42, height: 42))
iconView.imageScaling = .scaleProportionallyUpOrDown
// Title Label
let tLabel = NSTextField(frame: NSRect(x: 72, y: 40, width: width - 90, height: 24))
tLabel.isBezeled = false
tLabel.drawsBackground = false
tLabel.isEditable = false
tLabel.isSelectable = false
tLabel.font = NSFont.systemFont(ofSize: 14, weight: .bold)
tLabel.textColor = .white
tLabel.alignment = .left
// Subtitle / Preview Label
let sLabel = NSTextField(frame: NSRect(x: 72, y: 14, width: width - 90, height: 22))
sLabel.isBezeled = false
sLabel.drawsBackground = false
sLabel.isEditable = false
sLabel.isSelectable = false
sLabel.font = NSFont.systemFont(ofSize: 13, weight: .medium)
sLabel.textColor = NSColor.white.withAlphaComponent(0.9)
sLabel.alignment = .left
visualEffect.addSubview(iconView)
visualEffect.addSubview(tLabel)
visualEffect.addSubview(sLabel)
panel.contentView = visualEffect
self.window = panel
self.visualEffectView = visualEffect
self.iconImageView = iconView
self.titleLabel = tLabel
self.subtitleLabel = sLabel
}
public func show(state: HUDState) {
hideTimer?.invalidate()
hideTimer = nil
guard let panel = self.window else { return }
// Position at bottom center of current active screen
if let screen = NSScreen.main {
let screenRect = screen.visibleFrame
let x = screenRect.origin.x + (screenRect.width - panel.frame.width) / 2
let y = screenRect.origin.y + 60
panel.setFrameOrigin(NSPoint(x: x, y: y))
}
switch state {
case .hidden:
hide(animated: true)
return
case .recording(let level, let liveText):
let micImage = NSImage(systemSymbolName: "mic.fill", accessibilityDescription: "Recording")
let scale: CGFloat = 22.0 + CGFloat(level) * 6.0
iconImageView?.image = micImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: scale, weight: .bold))
iconImageView?.contentTintColor = NSColor.systemRed
titleLabel?.stringValue = "🎙️ در حال تبدیل زنده صدا..."
if let live = liveText, !live.isEmpty {
let preview = live.count > 46 ? "..." + String(live.suffix(46)) : live
subtitleLabel?.stringValue = preview
subtitleLabel?.textColor = NSColor.systemGreen.withAlphaComponent(0.95)
} else {
let hotkey = HotkeyManager.shared.currentPreset.rawValue
let mode = HotkeyManager.shared.currentMode == .toggle ? "پایان: \(hotkey)" : "رها کردن کلید جهت درج"
subtitleLabel?.stringValue = mode
subtitleLabel?.textColor = NSColor.systemRed.withAlphaComponent(0.9)
}
case .transcribing:
let waveImage = NSImage(systemSymbolName: "waveform.badge.magnifyingglass", accessibilityDescription: "Transcribing")
iconImageView?.image = waveImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 22, weight: .bold))
iconImageView?.contentTintColor = NSColor.systemOrange
titleLabel?.stringValue = "⚡ درج آنی متن..."
subtitleLabel?.stringValue = "در حال نهایی‌سازی و درج متن..."
subtitleLabel?.textColor = NSColor.systemOrange.withAlphaComponent(0.9)
// Auto-dismiss watchdog: .transcribing state must NEVER hang on screen indefinitely
hideTimer = Timer.scheduledTimer(withTimeInterval: 1.8, repeats: false) { [weak self] _ in
self?.hide(animated: true)
}
case .success(let text):
let checkImage = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: "Done")
iconImageView?.image = checkImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 22, weight: .bold))
iconImageView?.contentTintColor = NSColor.systemGreen
titleLabel?.stringValue = "✨ متن درج شد"
let preview = text.count > 46 ? String(text.prefix(46)) + "..." : text
subtitleLabel?.stringValue = preview.isEmpty ? "کلیپ‌بورد به‌روز شد" : preview
subtitleLabel?.textColor = NSColor.white.withAlphaComponent(0.95)
hideTimer = Timer.scheduledTimer(withTimeInterval: 1.2, repeats: false) { [weak self] _ in
self?.hide(animated: true)
}
case .error(let msg):
let errImage = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Error")
iconImageView?.image = errImage?.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 22, weight: .bold))
iconImageView?.contentTintColor = NSColor.systemYellow
titleLabel?.stringValue = "⚠️ خطا در تبدیل صوت"
subtitleLabel?.stringValue = msg
subtitleLabel?.textColor = NSColor.systemYellow.withAlphaComponent(0.9)
hideTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
self?.hide(animated: true)
}
}
panel.alphaValue = 1.0
panel.orderFrontRegardless()
}
public func hide(animated: Bool) {
guard let panel = self.window, panel.isVisible else { return }
if animated {
NSAnimationContext.runAnimationGroup({ context in
context.duration = 0.15
panel.animator().alphaValue = 0.0
}, completionHandler: {
panel.orderOut(nil)
})
} else {
panel.alphaValue = 0.0
panel.orderOut(nil)
}
}
private init() {}
public func show(state: HUDState) {}
public func hide() {}
} }

36
src/HotkeyManager.swift

@ -17,13 +17,13 @@ public enum DictationMode: String, CaseIterable {
} }
public enum HotkeyPreset: String, CaseIterable { public enum HotkeyPreset: String, CaseIterable {
case controlSpace = "Control + Space"
case option = "Option (⌥ نگه‌داشتن)" case option = "Option (⌥ نگه‌داشتن)"
case f5 = "F5 (Fn + F5)"
case controlSpace = "Control + Space"
case capsLock = "Caps Lock" case capsLock = "Caps Lock"
case optionSpace = "Option + Space" case optionSpace = "Option + Space"
case cmdShiftSpace = "Cmd + Shift + Space" case cmdShiftSpace = "Cmd + Shift + Space"
case f8 = "F8" case f8 = "F8"
case f5 = "F5"
public var keyCode: UInt32 { public var keyCode: UInt32 {
switch self { switch self {
@ -68,12 +68,13 @@ public final class HotkeyManager {
private var isOptionPhysicallyDown = false private var isOptionPhysicallyDown = false
private var isCapsLockPhysicallyDown = false private var isCapsLockPhysicallyDown = false
private var isF5PhysicallyDown = false
private var lastTriggerTime: TimeInterval = 0 private var lastTriggerTime: TimeInterval = 0
public var currentPreset: HotkeyPreset { public var currentPreset: HotkeyPreset {
get { get {
let val = UserDefaults.standard.string(forKey: "SonioxHotkeyPreset") ?? HotkeyPreset.controlSpace.rawValue
return HotkeyPreset(rawValue: val) ?? .controlSpace
let val = UserDefaults.standard.string(forKey: "SonioxHotkeyPreset") ?? HotkeyPreset.option.rawValue
return HotkeyPreset(rawValue: val) ?? .option
} }
set { set {
UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxHotkeyPreset") UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxHotkeyPreset")
@ -83,8 +84,8 @@ public final class HotkeyManager {
public var currentMode: DictationMode { public var currentMode: DictationMode {
get { get {
let val = UserDefaults.standard.string(forKey: "SonioxDictationMode") ?? DictationMode.toggle.rawValue
return DictationMode(rawValue: val) ?? .toggle
let val = UserDefaults.standard.string(forKey: "SonioxDictationMode") ?? DictationMode.pushToTalk.rawValue
return DictationMode(rawValue: val) ?? .pushToTalk
} }
set { set {
UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxDictationMode") UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxDictationMode")
@ -101,7 +102,7 @@ public final class HotkeyManager {
print("HotkeyManager: Registering hotkey for '\(preset.rawValue)', mode: '\(currentMode.rawValue)'") print("HotkeyManager: Registering hotkey for '\(preset.rawValue)', mode: '\(currentMode.rawValue)'")
// 1. Carbon HotKey for multi-key combos (Control+Space, etc.) // 1. Carbon HotKey for multi-key combos (Control+Space, etc.)
if preset != .option && preset != .capsLock {
if preset != .option && preset != .capsLock && preset != .f5 {
var eventTypes = [ var eventTypes = [
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)), EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased)) EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
@ -211,6 +212,27 @@ public final class HotkeyManager {
} }
} }
// 4. F5 Key (Fn + F5)
else if manager.currentPreset == .f5 {
if keyCode == 96 {
if type == .keyDown {
if !manager.isF5PhysicallyDown {
manager.isF5PhysicallyDown = true
manager.triggerAction()
}
return nil
} else if type == .keyUp {
manager.isF5PhysicallyDown = false
if manager.currentMode == .pushToTalk {
DispatchQueue.main.async {
manager.onHotkeyReleased?()
}
}
return nil
}
}
}
return Unmanaged.passRetained(event) return Unmanaged.passRetained(event)
} }

Loading…
Cancel
Save