From 6ba31476bce76e2ac2245e5b826872b3d881f7f9 Mon Sep 17 00:00:00 2001 From: Ali Alavi Date: Tue, 15 Sep 2026 16:22:49 +0000 Subject: [PATCH] fix: prevent double paste and completely remove HUD toast popup for instant headless insertion --- package.sh | 7 +- src/AppDelegate.swift | 29 ++----- src/HUDOverlay.swift | 176 +--------------------------------------- src/HotkeyManager.swift | 36 ++++++-- 4 files changed, 47 insertions(+), 201 deletions(-) diff --git a/package.sh b/package.sh index 88c409c..9b64781 100644 --- a/package.sh +++ b/package.sh @@ -1,7 +1,8 @@ #!/bin/zsh 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" APP_NAME="Soniox Voice" 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/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 "$PROJECT_DIR/Info.plist" "$APP_BUNDLE/Contents/Info.plist" cp "$PROJECT_DIR/resources/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/AppIcon.icns" diff --git a/src/AppDelegate.swift b/src/AppDelegate.swift index b67bd19..32d1a64 100644 --- a/src/AppDelegate.swift +++ b/src/AppDelegate.swift @@ -187,29 +187,18 @@ public final class AppDelegate: NSObject, NSApplicationDelegate { 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 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) + } } } diff --git a/src/HUDOverlay.swift b/src/HUDOverlay.swift index 96083a7..eb38a09 100644 --- a/src/HUDOverlay.swift +++ b/src/HUDOverlay.swift @@ -10,177 +10,7 @@ public enum HUDState { public final class 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() {} } diff --git a/src/HotkeyManager.swift b/src/HotkeyManager.swift index 292e719..f39c266 100644 --- a/src/HotkeyManager.swift +++ b/src/HotkeyManager.swift @@ -17,13 +17,13 @@ public enum DictationMode: String, CaseIterable { } public enum HotkeyPreset: String, CaseIterable { - case controlSpace = "Control + Space" case option = "Option (⌥ نگه‌داشتن)" + case f5 = "F5 (Fn + F5)" + case controlSpace = "Control + Space" case capsLock = "Caps Lock" case optionSpace = "Option + Space" case cmdShiftSpace = "Cmd + Shift + Space" case f8 = "F8" - case f5 = "F5" public var keyCode: UInt32 { switch self { @@ -68,12 +68,13 @@ public final class HotkeyManager { private var isOptionPhysicallyDown = false private var isCapsLockPhysicallyDown = false + private var isF5PhysicallyDown = false private var lastTriggerTime: TimeInterval = 0 public var currentPreset: HotkeyPreset { 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 { UserDefaults.standard.set(newValue.rawValue, forKey: "SonioxHotkeyPreset") @@ -83,8 +84,8 @@ public final class HotkeyManager { public var currentMode: DictationMode { 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 { 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)'") // 1. Carbon HotKey for multi-key combos (Control+Space, etc.) - if preset != .option && preset != .capsLock { + if preset != .option && preset != .capsLock && preset != .f5 { var eventTypes = [ EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)), 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) }