diff --git a/Package.resolved b/Package.resolved index f0a5512..7d86bfd 100644 --- a/Package.resolved +++ b/Package.resolved @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "cdd0ef3755280949551dc26dee5de9ddeda89f54", - "version" : "1.6.2" + "revision" : "c8ed701b513cf5177118a175d85fbbbcd707ab41", + "version" : "1.3.0" } }, { @@ -41,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/huggingface/swift-jinja.git", "state" : { - "revision" : "06a511d5adab5a812852ff972e65702a24b8ce30", - "version" : "2.2.0" + "revision" : "ba2364165002abe8f4f3992d74a7b547c635638e", + "version" : "2.2.1" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/huggingface/swift-transformers.git", "state" : { - "revision" : "18ccbd247eb4bf35a0ea98aab3b95ca255f09376", - "version" : "1.1.3" + "revision" : "573e5c9036c2f136b3a8a071da8e8907322403d0", + "version" : "1.1.6" } }, { diff --git a/Sources/App/AppDelegate+Hotkeys.swift b/Sources/App/AppDelegate+Hotkeys.swift index fe05edd..3eabab1 100644 --- a/Sources/App/AppDelegate+Hotkeys.swift +++ b/Sources/App/AppDelegate+Hotkeys.swift @@ -55,6 +55,12 @@ internal extension AppDelegate { return } + // Store the current frontmost app for paste functionality in silent mode + let silentExpressMode = UserDefaults.standard.bool(forKey: "silentExpressMode") + if silentExpressMode { + storePreviousAppForPaste() + } + if recorder.startRecording() { isHoldRecordingActive = true updateMenuBarIcon(isRecording: true) @@ -73,7 +79,9 @@ internal extension AppDelegate { } private func stopRecordingFromPressAndHold() { - guard isHoldRecordingActive else { return } + guard isHoldRecordingActive else { + return + } guard let recorder = audioRecorder, recorder.isRecording else { isHoldRecordingActive = false return @@ -82,9 +90,23 @@ internal extension AppDelegate { isHoldRecordingActive = false updateMenuBarIcon(isRecording: false) - showRecordingWindowForProcessing { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - NotificationCenter.default.post(name: .spaceKeyPressed, object: nil) + let silentExpressMode = UserDefaults.standard.bool(forKey: "silentExpressMode") + + if silentExpressMode { + // Silent mode: use the direct transcription service without UI + let targetApp = WindowController.storedTargetApp + Task { + await SilentTranscriptionService.shared.performSilentTranscription( + audioRecorder: recorder, + targetApp: targetApp + ) + } + } else { + // Normal mode: show the window for visual feedback + showRecordingWindowForProcessing { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + NotificationCenter.default.post(name: .spaceKeyPressed, object: nil) + } } } } @@ -95,6 +117,7 @@ internal extension AppDelegate { } let immediateRecording = UserDefaults.standard.bool(forKey: "immediateRecording") + let silentExpressMode = UserDefaults.standard.bool(forKey: "silentExpressMode") if immediateRecording { guard let recorder = audioRecorder else { @@ -105,12 +128,24 @@ internal extension AppDelegate { if recorder.isRecording { updateMenuBarIcon(isRecording: false) - if recordingWindow == nil || recordingWindow?.isVisible == false { - toggleRecordWindow() - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - NotificationCenter.default.post(name: .spaceKeyPressed, object: nil) + if silentExpressMode { + // Silent mode: use the direct transcription service without UI + let targetApp = WindowController.storedTargetApp + Task { + await SilentTranscriptionService.shared.performSilentTranscription( + audioRecorder: recorder, + targetApp: targetApp + ) + } + } else { + // Normal mode: show the window for visual feedback + if recordingWindow == nil || recordingWindow?.isVisible == false { + toggleRecordWindow() + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + NotificationCenter.default.post(name: .spaceKeyPressed, object: nil) + } } } else { if !recorder.hasPermission { @@ -118,6 +153,12 @@ internal extension AppDelegate { return } + // Store the current frontmost app before starting recording + // This is needed for paste functionality in silent mode + if silentExpressMode { + storePreviousAppForPaste() + } + if recorder.startRecording() { updateMenuBarIcon(isRecording: true) SoundManager().playRecordingStartSound() @@ -134,6 +175,14 @@ internal extension AppDelegate { } } + private func storePreviousAppForPaste() { + if let frontmostApp = NSWorkspace.shared.frontmostApplication, + frontmostApp.bundleIdentifier != Bundle.main.bundleIdentifier { + WindowController.storedTargetApp = frontmostApp + NotificationCenter.default.post(name: .targetAppStored, object: frontmostApp) + } + } + private func updateMenuBarIcon(isRecording: Bool) { guard let button = statusItem?.button else { return } diff --git a/Sources/App/AppDelegate+Lifecycle.swift b/Sources/App/AppDelegate+Lifecycle.swift index f32b982..31c850e 100644 --- a/Sources/App/AppDelegate+Lifecycle.swift +++ b/Sources/App/AppDelegate+Lifecycle.swift @@ -24,9 +24,11 @@ internal extension AppDelegate { audioRecorder = AudioRecorder() - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) if let button = statusItem?.button { - button.image = AppSetupHelper.createMenuBarIcon() + let icon = AppSetupHelper.createMenuBarIcon() + button.image = icon + button.imagePosition = .imageOnly button.action = #selector(toggleRecordWindow) button.target = self } diff --git a/Sources/App/AppDelegate+Menu.swift b/Sources/App/AppDelegate+Menu.swift index 165022d..6665764 100644 --- a/Sources/App/AppDelegate+Menu.swift +++ b/Sources/App/AppDelegate+Menu.swift @@ -5,6 +5,13 @@ import UniformTypeIdentifiers internal extension AppDelegate { func makeStatusMenu() -> NSMenu { let menu = NSMenu() + + // Version indicator at top for debugging + let versionItem = NSMenuItem(title: "AudioWhisper \(VersionInfo.displayVersion)", action: nil, keyEquivalent: "") + versionItem.isEnabled = false + menu.addItem(versionItem) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: LocalizedStrings.Menu.record, action: #selector(toggleRecordWindow), keyEquivalent: "")) menu.addItem(NSMenuItem(title: "Transcribe Audio File...", action: #selector(transcribeAudioFile), keyEquivalent: "")) menu.addItem(NSMenuItem.separator()) diff --git a/Sources/App/AppSetupHelper.swift b/Sources/App/AppSetupHelper.swift index e70c539..ed8277d 100644 --- a/Sources/App/AppSetupHelper.swift +++ b/Sources/App/AppSetupHelper.swift @@ -32,9 +32,45 @@ internal class AppSetupHelper { static func createMenuBarIcon() -> NSImage { let iconSize = getAdaptiveMenuBarIconSize() let config = NSImage.SymbolConfiguration(pointSize: iconSize, weight: .medium) - let image = NSImage(systemSymbolName: "microphone.circle", accessibilityDescription: LocalizedStrings.Accessibility.microphoneIcon)?.withSymbolConfiguration(config) - image?.isTemplate = true // This makes it adapt to menu bar appearance - return image ?? NSImage() + + // Try SF Symbol first + if let image = NSImage(systemSymbolName: "microphone.circle", accessibilityDescription: LocalizedStrings.Accessibility.microphoneIcon)?.withSymbolConfiguration(config) { + image.isTemplate = true + return image + } + + // Fallback: draw a simple microphone icon + let fallbackSize = NSSize(width: iconSize, height: iconSize) + let fallbackImage = NSImage(size: fallbackSize, flipped: false) { rect in + NSColor.black.setFill() + + // Draw a simple microphone shape + let micWidth = rect.width * 0.4 + let micHeight = rect.height * 0.5 + let micX = (rect.width - micWidth) / 2 + let micY = rect.height * 0.35 + + // Microphone body (rounded rect) + let micRect = NSRect(x: micX, y: micY, width: micWidth, height: micHeight) + let micPath = NSBezierPath(roundedRect: micRect, xRadius: micWidth / 2, yRadius: micWidth / 2) + micPath.fill() + + // Stand + let standWidth: CGFloat = 2 + let standX = (rect.width - standWidth) / 2 + let standPath = NSBezierPath(rect: NSRect(x: standX, y: rect.height * 0.15, width: standWidth, height: rect.height * 0.2)) + standPath.fill() + + // Base + let baseWidth = rect.width * 0.4 + let baseX = (rect.width - baseWidth) / 2 + let basePath = NSBezierPath(rect: NSRect(x: baseX, y: rect.height * 0.1, width: baseWidth, height: 2)) + basePath.fill() + + return true + } + fallbackImage.isTemplate = true + return fallbackImage } // MARK: - Menu Bar Icon Constants diff --git a/Sources/Managers/PasteManager.swift b/Sources/Managers/PasteManager.swift index 22e24c2..f52b36f 100644 --- a/Sources/Managers/PasteManager.swift +++ b/Sources/Managers/PasteManager.swift @@ -3,6 +3,143 @@ import AppKit import ApplicationServices import Carbon import Observation +import os.log + +// MARK: - Keyboard Layout Helper + +/// Modifier flags needed to type a character +private struct KeyModifiers: OptionSet { + let rawValue: UInt8 + static let shift = KeyModifiers(rawValue: 1 << 0) + static let option = KeyModifiers(rawValue: 1 << 1) +} + +/// Helper to find the correct key code for a character based on the current keyboard layout. +/// This makes character-by-character typing work correctly with non-US layouts (e.g., Hungarian QWERTZ). +private final class KeyboardLayoutHelper { + + /// Cached mapping from character to (keyCode, modifiers) for current keyboard layout + private var charToKeyCodeCache: [Character: (CGKeyCode, KeyModifiers)] = [:] + private var cachedLayoutID: String? + + /// Shared instance + static let shared = KeyboardLayoutHelper() + + private init() { + rebuildCacheIfNeeded() + } + + /// Find the key code and modifiers needed to type a character + func keyCodeForCharacter(_ char: Character) -> (keyCode: CGKeyCode, modifiers: KeyModifiers)? { + rebuildCacheIfNeeded() + return charToKeyCodeCache[char] + } + + /// Rebuild the cache if the keyboard layout has changed + private func rebuildCacheIfNeeded() { + guard let inputSource = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { + return + } + + // Get layout identifier + guard let layoutIDPtr = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID) else { + return + } + let layoutID = Unmanaged.fromOpaque(layoutIDPtr).takeUnretainedValue() as String + + // Skip rebuild if layout hasn't changed + if layoutID == cachedLayoutID && !charToKeyCodeCache.isEmpty { + return + } + + Logger.app.info("KeyboardLayoutHelper: Rebuilding cache for layout '\(layoutID)'") + cachedLayoutID = layoutID + charToKeyCodeCache.removeAll() + + // Get the keyboard layout data + guard let layoutDataPtr = TISGetInputSourceProperty(inputSource, kTISPropertyUnicodeKeyLayoutData) else { + Logger.app.warning("KeyboardLayoutHelper: Could not get layout data, falling back to US layout") + return + } + + let layoutData = Unmanaged.fromOpaque(layoutDataPtr).takeUnretainedValue() as Data + + layoutData.withUnsafeBytes { rawPtr in + guard let ptr = rawPtr.baseAddress?.assumingMemoryBound(to: UCKeyboardLayout.self) else { + return + } + + var deadKeyState: UInt32 = 0 + var chars = [UniChar](repeating: 0, count: 4) + var actualLength: Int = 0 + + // Modifier combinations to scan (in priority order - prefer simpler modifiers) + // Carbon modifier bits: shiftKey=0x200, optionKey=0x800 + // UCKeyTranslate expects these shifted right by 8 + let modifierCombinations: [(carbonMod: UInt32, keyMod: KeyModifiers)] = [ + (0, []), // No modifiers + (UInt32(shiftKey >> 8), .shift), // Shift only + (UInt32(optionKey >> 8), .option), // Option only (AltGr) + (UInt32((shiftKey | optionKey) >> 8), [.shift, .option]) // Shift+Option + ] + + // Scan all key codes (0-127) with all modifier combinations + for keyCode: UInt16 in 0..<128 { + for (carbonMod, keyMod) in modifierCombinations { + deadKeyState = 0 + let status = UCKeyTranslate( + ptr, + keyCode, + UInt16(kUCKeyActionDown), + carbonMod, + UInt32(LMGetKbdType()), + UInt32(kUCKeyTranslateNoDeadKeysBit), + &deadKeyState, + chars.count, + &actualLength, + &chars + ) + + if status == noErr && actualLength > 0 { + if let scalar = Unicode.Scalar(chars[0]) { + let char = Character(scalar) + // Only add if we don't already have a simpler way to type this character + if charToKeyCodeCache[char] == nil { + charToKeyCodeCache[char] = (CGKeyCode(keyCode), keyMod) + } + } + } + } + } + } + + // Always add whitespace keys (these are layout-independent) + charToKeyCodeCache[" "] = (CGKeyCode(kVK_Space), []) + charToKeyCodeCache["\t"] = (CGKeyCode(kVK_Tab), []) + charToKeyCodeCache["\n"] = (CGKeyCode(kVK_Return), []) + charToKeyCodeCache["\r"] = (CGKeyCode(kVK_Return), []) + + Logger.app.info("KeyboardLayoutHelper: Cached \(self.charToKeyCodeCache.count) characters") + + // Log mappings for common problematic characters to help diagnose issues + let debugChars: [Character] = ["'", "\"", "@", "#", "&", "[", "]", "{", "}", "|", "\\", "/", "?", "!"] + for char in debugChars { + if let (keyCode, mods) = charToKeyCodeCache[char] { + var modStr = "none" + if mods.contains(.shift) && mods.contains(.option) { + modStr = "shift+option" + } else if mods.contains(.shift) { + modStr = "shift" + } else if mods.contains(.option) { + modStr = "option" + } + Logger.app.debug("KeyboardLayoutHelper: '\(char)' -> keyCode=\(keyCode), mods=\(modStr)") + } else { + Logger.app.debug("KeyboardLayoutHelper: '\(char)' -> NOT FOUND") + } + } + } +} // Helper class to safely capture observer in closure // Uses a lock to ensure thread-safe access to the mutable observer property @@ -49,19 +186,80 @@ internal enum PasteError: LocalizedError { @Observable @MainActor internal class PasteManager { - + + // MARK: - Timing Constants + + /// Delay between modifier registration and key press (for CGEvent paste) + private static let modifierRegisterDelay: UInt32 = 50_000 // 50ms + /// Delay between keyDown and keyUp (for CGEvent paste) + private static let keyUpDelay: UInt32 = 20_000 // 20ms + /// Delay between characters when typing directly (slower for RustDesk network capture) + private static let interCharacterDelay: UInt32 = 30_000 // 30ms + /// Delay between key down and up for direct typing + private static let directTypeKeyDelay: UInt32 = 15_000 // 15ms + private let accessibilityManager: AccessibilityPermissionManager - + + /// UserDefaults key for SmartPaste excluded apps - shared with preferences UI + internal static let smartPasteExcludedAppsKey = "smartPasteExcludedApps" + + /// Cached set of excluded bundle IDs - invalidated when UserDefaults changes + private static var _cachedExcludedBundleIDs: Set? + private static var _userDefaultsObserver: NSObjectProtocol? + + /// Apps where SmartPaste doesn't work well and should be skipped + /// (text remains in clipboard for manual paste) + /// Users can manage this list in Preferences -> Smart Paste -> Excluded Apps + private static var smartPasteExcludedBundleIDs: Set { + if let cached = _cachedExcludedBundleIDs { + return cached + } + + let ids = Set(UserDefaults.standard.stringArray(forKey: smartPasteExcludedAppsKey) ?? []) + _cachedExcludedBundleIDs = ids + + // Observe UserDefaults changes to invalidate cache + if _userDefaultsObserver == nil { + _userDefaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { _ in + _cachedExcludedBundleIDs = nil + } + } + return ids + } + init(accessibilityManager: AccessibilityPermissionManager = AccessibilityPermissionManager()) { self.accessibilityManager = accessibilityManager } - + + /// Check if the current frontmost app should be excluded from SmartPaste + private func shouldSkipSmartPaste() -> Bool { + guard let frontApp = NSWorkspace.shared.frontmostApplication, + let bundleID = frontApp.bundleIdentifier else { + return false + } + + if Self.smartPasteExcludedBundleIDs.contains(bundleID) { + Logger.app.info("PasteManager: Skipping SmartPaste for excluded app: \(bundleID)") + return true + } + return false + } + /// Attempts to paste text to the currently active application - /// Uses CGEvent to simulate ⌘V + /// Uses CGEvent to simulate ⌘V func pasteToActiveApp() { let enableSmartPaste = UserDefaults.standard.bool(forKey: "enableSmartPaste") - + if enableSmartPaste { + // Skip SmartPaste for excluded apps (like RustDesk) + if shouldSkipSmartPaste() { + Logger.app.info("PasteManager: Text copied to clipboard (SmartPaste skipped for this app)") + return + } // Use CGEvent to simulate ⌘V performCGEventPaste() } else { @@ -85,7 +283,15 @@ internal class PasteManager { handlePasteResult(.failure(PasteError.targetAppNotAvailable)) return } - + + // Skip SmartPaste for excluded apps (like RustDesk) - text is already in clipboard + if let bundleID = targetApp?.bundleIdentifier, + Self.smartPasteExcludedBundleIDs.contains(bundleID) { + Logger.app.info("PasteManager: SmartPaste skipped for excluded app: \(bundleID). Text in clipboard.") + handlePasteResult(.success(())) // Report success since text is in clipboard + return + } + // CRITICAL: Check accessibility permission without prompting - never bypass this check // If this fails, we must NOT attempt to proceed with CGEvent operations guard accessibilityManager.checkPermission() else { @@ -169,7 +375,15 @@ internal class PasteManager { completion?(.failure(PasteError.accessibilityPermissionDenied)) return } - + + // Skip SmartPaste for excluded apps (like RustDesk) - text stays in clipboard + if shouldSkipSmartPaste() { + Logger.app.info("PasteManager: Text in clipboard (SmartPaste skipped for this app)") + handlePasteResult(.success(())) + completion?(.success(())) + return + } + // CRITICAL SECURITY CHECK: Always verify accessibility permission before any CGEvent operations // This method should NEVER execute without proper permission - no exceptions guard accessibilityManager.checkPermission() else { @@ -199,48 +413,214 @@ internal class PasteManager { // Removed - using AccessibilityPermissionManager instead + /// Main paste simulation function. + /// + /// ## Direct Typing Mode (for Remote Desktop apps like RustDesk) + /// When `useDirectTypingForPaste` is enabled, types text character-by-character. + /// This bypasses Cmd+V entirely because RustDesk's `CGEventSourceKeyState` only sees + /// physical keyboard state, making synthetic modifier keys invisible. + /// Uses layout-aware key code detection for correct typing on any keyboard layout. + /// **Note**: Blocks main thread during typing (~45ms per character). + /// + /// ## Normal Mode + /// 1. **AppleScript**: `keystroke "v" using command down` - works well with most apps. + /// 2. **CGEvent**: FlagsChanged + key events at HID level. + /// 3. **Character typing**: Fallback if other methods fail. private func simulateCmdVPaste() throws { // CRITICAL: Prevent any paste operations during tests if NSClassFromString("XCTestCase") != nil { throw PasteError.accessibilityPermissionDenied } - + // Final permission check before creating any CGEvents // This is our last line of defense against unauthorized paste operations guard accessibilityManager.checkPermission() else { + Logger.app.error("PasteManager: Accessibility permission denied") throw PasteError.accessibilityPermissionDenied } - - // Create event source with proper session state - guard let source = CGEventSource(stateID: .combinedSessionState) else { + + Logger.app.info("PasteManager: Starting paste operation") + + // Check if user prefers typing directly (for remote desktop apps like RustDesk) + // This mode types character-by-character because Cmd+V cannot work with RustDesk. + // See Research/rustdesk_paste_attempts.md for full explanation. + let useDirectTyping = UserDefaults.standard.bool(forKey: "useDirectTypingForPaste") + if useDirectTyping { + Logger.app.info("PasteManager: Direct Typing Mode - typing character-by-character") + if !typeClipboardContents() { + throw PasteError.keyboardEventCreationFailed + } + return + } + + // Try AppleScript approach first - this works better with some apps + if tryAppleScriptPaste() { + Logger.app.info("PasteManager: AppleScript paste succeeded") + return + } + + Logger.app.info("PasteManager: AppleScript failed, trying CGEvent approach") + + // Try CGEvent approach + do { + try simulateCGEventPaste() + return + } catch { + Logger.app.warning("PasteManager: CGEvent paste failed: \(error)") + } + + // Final fallback: type the text directly (works with remote desktop apps) + Logger.app.info("PasteManager: All paste methods failed, falling back to direct typing") + if !typeClipboardContents() { + throw PasteError.keyboardEventCreationFailed + } + } + + /// Get clipboard contents and type them directly + /// - Returns: true if text was typed, false if clipboard was empty + @discardableResult + private func typeClipboardContents() -> Bool { + guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { + Logger.app.error("PasteManager: No text in clipboard") + return false + } + typeTextDirectly(text) + return true + } + + /// Try pasting using AppleScript - works better with some apps like RustDesk + private func tryAppleScriptPaste() -> Bool { + let script = """ + tell application "System Events" + keystroke "v" using command down + end tell + """ + + var error: NSDictionary? + if let scriptObject = NSAppleScript(source: script) { + scriptObject.executeAndReturnError(&error) + if let error = error { + Logger.app.warning("PasteManager: AppleScript error: \(error)") + return false + } + return true + } + return false + } + + /// Simulate Cmd+V using CGEvent with flagsChanged events for modifiers + private func simulateCGEventPaste() throws { + // Create event source + guard let source = CGEventSource(stateID: .hidSystemState) else { + Logger.app.error("PasteManager: Failed to create event source") throw PasteError.eventSourceCreationFailed } - - // Configure event source to suppress local events during paste operation - // This prevents interference from local keyboard input - source.setLocalEventsFilterDuringSuppressionState( - [.permitLocalMouseEvents, .permitSystemDefinedEvents], - state: .eventSuppressionStateSuppressionInterval - ) - - // Create ⌘V key events for paste operation + + let vKeyCode = CGKeyCode(kVK_ANSI_V) let cmdFlag = CGEventFlags([.maskCommand]) - let vKeyCode = CGKeyCode(kVK_ANSI_V) // V key code - - // Create both key down and key up events for complete key press simulation + let noFlag = CGEventFlags([]) + + Logger.app.info("PasteManager: Creating flagsChanged + key events") + + // Create flagsChanged event for Command key down (this is what the system generates for modifier presses) + guard let flagsChangedDown = CGEvent(source: source) else { + Logger.app.error("PasteManager: Failed to create flagsChanged event") + throw PasteError.keyboardEventCreationFailed + } + flagsChangedDown.type = .flagsChanged + flagsChangedDown.flags = cmdFlag + + // Create V key events guard let keyVDown = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: true), let keyVUp = CGEvent(keyboardEventSource: source, virtualKey: vKeyCode, keyDown: false) else { + Logger.app.error("PasteManager: Failed to create keyboard events") throw PasteError.keyboardEventCreationFailed } - - // Apply Command modifier flag to both events + + // Set Command flag on V key events keyVDown.flags = cmdFlag keyVUp.flags = cmdFlag - - // Post the key events to the system - // This simulates pressing and releasing ⌘V - keyVDown.post(tap: .cgSessionEventTap) - keyVUp.post(tap: .cgSessionEventTap) + + // Create flagsChanged event for Command key up + guard let flagsChangedUp = CGEvent(source: source) else { + Logger.app.error("PasteManager: Failed to create flagsChanged up event") + throw PasteError.keyboardEventCreationFailed + } + flagsChangedUp.type = .flagsChanged + flagsChangedUp.flags = noFlag + + // Post the sequence using HID tap (lowest level) + let tap: CGEventTapLocation = .cghidEventTap + Logger.app.info("PasteManager: Posting flagsChanged sequence to HID tap") + + // Send: flagsChanged(cmd down) -> keyDown(v) -> keyUp(v) -> flagsChanged(cmd up) + flagsChangedDown.post(tap: tap) + usleep(Self.modifierRegisterDelay) + keyVDown.post(tap: tap) + usleep(Self.keyUpDelay) + keyVUp.post(tap: tap) + usleep(Self.modifierRegisterDelay) + flagsChangedUp.post(tap: tap) + + Logger.app.info("PasteManager: CGEvent paste completed") + } + + /// Type text character by character - fallback for apps that don't handle Cmd+V well. + /// Uses layout-aware key code detection for correct typing on any keyboard layout. + private func typeTextDirectly(_ text: String) { + Logger.app.info("PasteManager: Typing text directly (\(text.count) characters)") + + guard let source = CGEventSource(stateID: .hidSystemState) else { + Logger.app.error("PasteManager: Failed to create event source for typing") + return + } + + let layoutHelper = KeyboardLayoutHelper.shared + + for char in text { + // Try layout-aware lookup first (works with any keyboard layout) + if let (keyCode, modifiers) = layoutHelper.keyCodeForCharacter(char) { + // Build CGEventFlags from KeyModifiers + var flags = CGEventFlags([]) + if modifiers.contains(.shift) { + flags.insert(.maskShift) + } + if modifiers.contains(.option) { + flags.insert(.maskAlternate) + } + + if let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true), + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false) { + keyDown.flags = flags + keyUp.flags = flags + + keyDown.post(tap: .cghidEventTap) + usleep(Self.directTypeKeyDelay) + keyUp.post(tap: .cghidEventTap) + } + } else { + // Fallback: use unicode string for unmapped characters (e.g., accented chars not on current layout) + // This works for local apps but may not work with RustDesk for special characters + let uniChars = Array(String(char).utf16) + let neutralKeyCode = CGKeyCode(0x72) // kVK_Help - doesn't produce visible chars + + if let keyDown = CGEvent(keyboardEventSource: source, virtualKey: neutralKeyCode, keyDown: true) { + keyDown.keyboardSetUnicodeString(stringLength: uniChars.count, unicodeString: uniChars) + keyDown.post(tap: .cghidEventTap) + usleep(Self.directTypeKeyDelay) + } + if let keyUp = CGEvent(keyboardEventSource: source, virtualKey: neutralKeyCode, keyDown: false) { + keyUp.keyboardSetUnicodeString(stringLength: uniChars.count, unicodeString: uniChars) + keyUp.post(tap: .cghidEventTap) + } + Logger.app.debug("PasteManager: Using unicode fallback for character not on current layout: '\(char)'") + } + + // Small delay between characters + usleep(Self.interCharacterDelay) + } + + Logger.app.info("PasteManager: Finished typing text") } private func handlePasteResult(_ result: Result) { diff --git a/Sources/Services/SilentTranscriptionService.swift b/Sources/Services/SilentTranscriptionService.swift new file mode 100644 index 0000000..8f07fa9 --- /dev/null +++ b/Sources/Services/SilentTranscriptionService.swift @@ -0,0 +1,246 @@ +import Foundation +import AppKit +import os.log + +/// Handles transcription flow in silent mode without requiring the UI window. +/// This service bypasses the ContentView notification system and directly manages +/// the transcription process, clipboard operations, and optional smart paste. +@MainActor +internal class SilentTranscriptionService { + static let shared = SilentTranscriptionService() + + private let speechService: SpeechToTextServiceProtocol + private let semanticCorrectionService: SemanticCorrectionServiceProtocol + private let soundManager: SoundManagerProtocol + private let pasteManager: PasteManagerProtocol + private let dataManager: DataManagerForSilentServiceProtocol + private let usageMetricsStore: UsageMetricsStoreProtocol + private let notificationCenter: NotificationCenter + private let pasteboard: NSPasteboard + + /// Current transcription task, allowing cancellation + private var currentTask: Task? + + // MARK: - Timing Constants + internal static let clipboardReadyDelay: Duration = .milliseconds(100) + internal static let appActivationDelay: Duration = .milliseconds(200) + + /// Default initializer using production dependencies + private convenience init() { + self.init( + speechService: SpeechToTextService(), + semanticCorrectionService: SemanticCorrectionService(), + soundManager: SoundManager(), + pasteManager: PasteManager(), + dataManager: DataManager.sharedInstance, + usageMetricsStore: UsageMetricsStore.shared, + notificationCenter: .default, + pasteboard: .general + ) + } + + /// Testable initializer with injectable dependencies + internal init( + speechService: SpeechToTextServiceProtocol, + semanticCorrectionService: SemanticCorrectionServiceProtocol, + soundManager: SoundManagerProtocol, + pasteManager: PasteManagerProtocol, + dataManager: DataManagerForSilentServiceProtocol, + usageMetricsStore: UsageMetricsStoreProtocol, + notificationCenter: NotificationCenter = .default, + pasteboard: NSPasteboard = .general + ) { + self.speechService = speechService + self.semanticCorrectionService = semanticCorrectionService + self.soundManager = soundManager + self.pasteManager = pasteManager + self.dataManager = dataManager + self.usageMetricsStore = usageMetricsStore + self.notificationCenter = notificationCenter + self.pasteboard = pasteboard + } + + /// Cancels any in-progress transcription + func cancelCurrentTranscription() { + currentTask?.cancel() + currentTask = nil + } + + /// Performs silent transcription: stops recording, transcribes, copies to clipboard, + /// plays completion sound, and optionally pastes to target app. + func performSilentTranscription( + audioRecorder: AudioRecorder, + targetApp: NSRunningApplication? + ) async { + // Cancel any existing transcription + cancelCurrentTranscription() + + // Store task reference for cancellation support + let task = Task { @MainActor in + await executeTranscription(audioRecorder: audioRecorder, targetApp: targetApp) + } + currentTask = task + await task.value + // Only clear if this is still our task (avoid race with newer invocation) + if currentTask === task { + currentTask = nil + } + } + + private func executeTranscription( + audioRecorder: AudioRecorder, + targetApp: NSRunningApplication? + ) async { + // Stop recording and get the audio URL FIRST + let audioURL = audioRecorder.stopRecording() + guard let audioURL = audioURL else { + Logger.app.error("SilentTranscriptionService: Failed to get recording URL") + NSSound.beep() + // Notify that recording stopped (even on failure) + notificationCenter.post(name: .recordingStopped, object: nil) + return + } + + // NOW notify that recording has stopped (after it actually stopped) + notificationCenter.post(name: .recordingStopped, object: nil) + + let sessionDuration = audioRecorder.lastRecordingDuration + + // Get user preferences + let providerRaw = UserDefaults.standard.string(forKey: "transcriptionProvider") ?? TranscriptionProvider.openai.rawValue + let transcriptionProvider = TranscriptionProvider(rawValue: providerRaw) ?? .openai + let selectedModelRaw = UserDefaults.standard.string(forKey: "selectedWhisperModel") ?? WhisperModel.base.rawValue + let selectedWhisperModel = WhisperModel(rawValue: selectedModelRaw) ?? .base + + do { + // Check for cancellation before starting transcription + try Task.checkCancellation() + + // Transcribe the audio + let model: WhisperModel? = (transcriptionProvider == .local) ? selectedWhisperModel : nil + let text = try await speechService.transcribeRaw(audioURL: audioURL, provider: transcriptionProvider, model: model) + + try Task.checkCancellation() + + // Apply semantic correction if enabled + let modeRaw = UserDefaults.standard.string(forKey: "semanticCorrectionMode") ?? SemanticCorrectionMode.off.rawValue + let mode = SemanticCorrectionMode(rawValue: modeRaw) ?? .off + + var finalText = text + if mode != .off { + let corrected = await semanticCorrectionService.correct(text: text, providerUsed: transcriptionProvider, sourceAppBundleId: targetApp?.bundleIdentifier) + let trimmed = corrected.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + finalText = corrected + } + } + + try Task.checkCancellation() + + // Copy to clipboard + pasteboard.clearContents() + pasteboard.setString(finalText, forType: .string) + + // Calculate metrics (always, regardless of history setting) + let wordCount = UsageMetricsStore.estimatedWordCount(for: finalText) + let characterCount = finalText.count + + // Save to history if enabled + if dataManager.isHistoryEnabled { + let modelUsed: String? = (transcriptionProvider == .local) ? selectedWhisperModel.rawValue : nil + + let sourceInfo: SourceAppInfo + if let app = targetApp, let info = SourceAppInfo.from(app: app) { + sourceInfo = info + } else { + sourceInfo = .unknown + } + + let record = TranscriptionRecord( + text: finalText, + provider: transcriptionProvider, + duration: sessionDuration, + modelUsed: modelUsed, + wordCount: wordCount, + characterCount: characterCount, + sourceAppBundleId: sourceInfo.bundleIdentifier, + sourceAppName: sourceInfo.displayName, + sourceAppIconData: sourceInfo.iconData + ) + await dataManager.saveTranscriptionQuietly(record) + } + + // Record usage metrics ALWAYS (outside history conditional) + usageMetricsStore.recordSession( + duration: sessionDuration, + wordCount: wordCount, + characterCount: characterCount + ) + + // Play completion sound + soundManager.playCompletionSound() + + // Handle paste or focus restore + let enableSmartPaste = UserDefaults.standard.bool(forKey: "enableSmartPaste") + if enableSmartPaste { + if let app = targetApp { + await performSmartPaste(to: app) + } else { + // Smart paste enabled but no target app - restore focus to frontmost + restoreFocusToPreviousApp() + } + } else { + // No smart paste - restore focus to previous app + restoreFocusToPreviousApp() + } + + Logger.app.info("SilentTranscriptionService: Transcription completed successfully") + + } catch is CancellationError { + Logger.app.info("SilentTranscriptionService: Transcription cancelled") + // Don't beep on cancellation - user initiated it + } catch { + Logger.app.error("SilentTranscriptionService: Transcription failed: \(error)") + NSSound.beep() + + // Still restore focus on error + restoreFocusToPreviousApp() + } + } + + private func performSmartPaste(to targetApp: NSRunningApplication) async { + // Small delay to ensure clipboard is ready + try? await Task.sleep(for: Self.clipboardReadyDelay) + + // Activate target app + let activated = targetApp.activate(options: []) + + if !activated { + // Try opening the app if simple activation fails + if let bundleURL = targetApp.bundleURL { + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + + do { + try await NSWorkspace.shared.openApplication(at: bundleURL, configuration: configuration) + } catch { + Logger.app.error("SilentTranscriptionService: Failed to activate target app: \(error)") + return + } + } else { + Logger.app.error("SilentTranscriptionService: Failed to activate target app") + return + } + } + + // Wait for app activation + try? await Task.sleep(for: Self.appActivationDelay) + + // Perform paste + await pasteManager.pasteWithCompletionHandler() + } + + private func restoreFocusToPreviousApp() { + notificationCenter.post(name: .restoreFocusToPreviousApp, object: nil) + } +} diff --git a/Sources/Stores/DataManager.swift b/Sources/Stores/DataManager.swift index 7f83fd1..f4bd3f4 100644 --- a/Sources/Stores/DataManager.swift +++ b/Sources/Stores/DataManager.swift @@ -84,9 +84,15 @@ internal protocol DataManagerProtocol { @MainActor internal final class DataManager: DataManagerProtocol { + /// Shared instance typed as protocol for general use nonisolated(unsafe) static let shared: DataManagerProtocol = MainActor.assumeIsolated { DataManager() } + + /// Shared instance typed as concrete class for dependency injection + nonisolated(unsafe) static let sharedInstance: DataManager = MainActor.assumeIsolated { + shared as! DataManager + } private var modelContainer: ModelContainer? diff --git a/Sources/Utilities/VersionInfo.swift b/Sources/Utilities/VersionInfo.swift index 3b580e7..72b58f4 100644 --- a/Sources/Utilities/VersionInfo.swift +++ b/Sources/Utilities/VersionInfo.swift @@ -2,8 +2,8 @@ import Foundation struct VersionInfo { static let version = "2.0.0" - static let gitHash = "1685be4285f7164cc93452d2431b46c5e3f7c996" - static let buildDate = "2025-12-17" + static let gitHash = "4264e08a5f7df1c7c9eb634964390127a63ac9bb" + static let buildDate = "2026-01-11" static var displayVersion: String { if gitHash != "dev-build" && gitHash != "unknown" && !gitHash.isEmpty { diff --git a/Sources/Views/Dashboard/DashboardPreferencesView.swift b/Sources/Views/Dashboard/DashboardPreferencesView.swift index 455a5bb..7a892ee 100644 --- a/Sources/Views/Dashboard/DashboardPreferencesView.swift +++ b/Sources/Views/Dashboard/DashboardPreferencesView.swift @@ -1,19 +1,25 @@ import SwiftUI import ServiceManagement import AppKit +import UniformTypeIdentifiers import os.log internal struct DashboardPreferencesView: View { @AppStorage("startAtLogin") private var startAtLogin = true @AppStorage("immediateRecording") private var immediateRecording = false + @AppStorage("silentExpressMode") private var silentExpressMode = false @AppStorage("autoBoostMicrophoneVolume") private var autoBoostMicrophoneVolume = false @AppStorage("enableSmartPaste") private var enableSmartPaste = false + @AppStorage("useDirectTypingForPaste") private var useDirectTypingForPaste = false @AppStorage("playCompletionSound") private var playCompletionSound = true @AppStorage("transcriptionHistoryEnabled") private var transcriptionHistoryEnabled = false @AppStorage("transcriptionRetentionPeriod") private var transcriptionRetentionPeriodRaw = RetentionPeriod.oneMonth.rawValue @AppStorage("maxModelStorageGB") private var maxModelStorageGB = 5.0 @State private var loginItemError: String? + @State private var excludedApps: [String] = [] + @State private var showAppSelectionError = false + @State private var smartPasteAdvancedExpanded = false private let storageOptions: [Double] = [1, 2, 5, 10, 20] @@ -36,6 +42,14 @@ internal struct DashboardPreferencesView: View { .padding(DashboardTheme.Spacing.xl) } .background(DashboardTheme.pageBg) + .onAppear { + loadExcludedApps() + } + .alert("Cannot Exclude App", isPresented: $showAppSelectionError) { + Button("OK", role: .cancel) { } + } message: { + Text("The selected file is not a valid application or doesn't have a bundle identifier.") + } } // MARK: - Header @@ -73,6 +87,22 @@ internal struct DashboardPreferencesView: View { subtitle: "Hotkey immediately starts and stops recording", isOn: $immediateRecording ) + .onChange(of: immediateRecording) { _, newValue in + // Reset silent mode when Express Mode is disabled + if !newValue { + silentExpressMode = false + } + } + + if immediateRecording { + Divider().background(DashboardTheme.rule) + + SettingsToggleRow( + title: "Silent Express Mode", + subtitle: "No popup window during transcription (prevents focus stealing)", + isOn: $silentExpressMode + ) + } Divider().background(DashboardTheme.rule) @@ -89,6 +119,74 @@ internal struct DashboardPreferencesView: View { subtitle: "Automatically paste finished transcripts", isOn: $enableSmartPaste ) + .onChange(of: enableSmartPaste) { _, newValue in + // Reset direct typing when Smart Paste is disabled + if !newValue { + useDirectTypingForPaste = false + } + } + + if enableSmartPaste { + Divider().background(DashboardTheme.rule) + + // Advanced Settings disclosure + DisclosureGroup(isExpanded: $smartPasteAdvancedExpanded) { + VStack(alignment: .leading, spacing: 0) { + SettingsToggleRow( + title: "Direct Typing Mode", + subtitle: "For RustDesk and remote desktops. Types text character-by-character.", + isOn: $useDirectTypingForPaste + ) + + Divider().background(DashboardTheme.rule) + + // Excluded Apps section + HStack { + Text("Excluded Apps") + .font(DashboardTheme.Fonts.sans(11, weight: .semibold)) + .foregroundStyle(DashboardTheme.inkMuted) + .tracking(0.5) + Spacer() + } + .padding(.horizontal, DashboardTheme.Spacing.md) + .padding(.top, DashboardTheme.Spacing.md) + .padding(.bottom, DashboardTheme.Spacing.sm) + + if excludedApps.isEmpty { + SettingsInfoRow(text: "No apps excluded.") + } else { + ForEach(excludedApps, id: \.self) { bundleID in + Divider().background(DashboardTheme.rule) + ExcludedAppRow(bundleID: bundleID) { + removeExcludedApp(bundleID: bundleID) + } + } + } + + Divider().background(DashboardTheme.rule) + + SettingsButtonRow( + title: "Add App...", + subtitle: "Exclude an app from SmartPaste", + icon: "plus.circle" + ) { + showAddAppPicker() + } + } + } label: { + HStack { + Text("Advanced Settings") + .font(DashboardTheme.Fonts.sans(14, weight: .medium)) + .foregroundStyle(DashboardTheme.ink) + Spacer() + Text(smartPasteAdvancedSummary) + .font(DashboardTheme.Fonts.sans(12, weight: .regular)) + .foregroundStyle(DashboardTheme.inkMuted) + } + } + .disclosureGroupStyle(SettingsDisclosureStyle()) + .padding(DashboardTheme.Spacing.md) + } Divider().background(DashboardTheme.rule) @@ -263,6 +361,61 @@ internal struct DashboardPreferencesView: View { ?? value.formatted(.number.precision(.fractionLength(1))) return "\(formattedValue) GB" } + + // MARK: - SmartPaste Summary + + private var smartPasteAdvancedSummary: String { + var parts: [String] = [] + if useDirectTypingForPaste { + parts.append("Direct typing") + } + if !excludedApps.isEmpty { + parts.append("\(excludedApps.count) excluded") + } + return parts.isEmpty ? "" : parts.joined(separator: " · ") + } + + // MARK: - Excluded Apps Management + + private func loadExcludedApps() { + excludedApps = UserDefaults.standard.stringArray(forKey: PasteManager.smartPasteExcludedAppsKey) ?? [] + } + + private func saveExcludedApps() { + UserDefaults.standard.set(excludedApps, forKey: PasteManager.smartPasteExcludedAppsKey) + } + + private func addExcludedApp(bundleID: String) { + guard !excludedApps.contains(bundleID) else { return } + excludedApps.append(bundleID) + saveExcludedApps() + } + + private func removeExcludedApp(bundleID: String) { + excludedApps.removeAll { $0 == bundleID } + saveExcludedApps() + } + + private func showAddAppPicker() { + let panel = NSOpenPanel() + panel.title = "Select Application to Exclude" + panel.message = "Choose an app that doesn't work well with SmartPaste" + panel.prompt = "Exclude" + panel.allowedContentTypes = [.application] + panel.directoryURL = URL(fileURLWithPath: "/Applications") + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + + if panel.runModal() == .OK, let appURL = panel.url { + if let bundle = Bundle(url: appURL), + let bundleID = bundle.bundleIdentifier { + addExcludedApp(bundleID: bundleID) + } else { + showAppSelectionError = true + } + } + } } // MARK: - Card Style @@ -280,3 +433,34 @@ private extension View { ) } } + +// MARK: - Settings Disclosure Style +private struct SettingsDisclosureStyle: DisclosureGroupStyle { + func makeBody(configuration: Configuration) -> some View { + VStack(alignment: .leading, spacing: 0) { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + configuration.isExpanded.toggle() + } + } label: { + HStack { + configuration.label + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(DashboardTheme.inkMuted) + .rotationEffect(.degrees(configuration.isExpanded ? 90 : 0)) + } + } + .buttonStyle(.plain) + + if configuration.isExpanded { + Divider() + .background(DashboardTheme.rule) + .padding(.top, DashboardTheme.Spacing.sm) + + configuration.content + .padding(.top, DashboardTheme.Spacing.xs) + } + } + } +} diff --git a/Sources/Views/Dashboard/DashboardSettingsCards.swift b/Sources/Views/Dashboard/DashboardSettingsCards.swift index da09a5d..6743b26 100644 --- a/Sources/Views/Dashboard/DashboardSettingsCards.swift +++ b/Sources/Views/Dashboard/DashboardSettingsCards.swift @@ -204,13 +204,13 @@ internal struct SettingsButtonRow: View { // MARK: - Info Row internal struct SettingsInfoRow: View { let text: String - + var body: some View { HStack(spacing: DashboardTheme.Spacing.sm) { Image(systemName: "info.circle") .font(.system(size: 12)) .foregroundStyle(DashboardTheme.inkFaint) - + Text(text) .font(DashboardTheme.Fonts.sans(12, weight: .regular)) .foregroundStyle(DashboardTheme.inkMuted) @@ -219,6 +219,81 @@ internal struct SettingsInfoRow: View { } } +// MARK: - Excluded App Row +internal struct ExcludedAppRow: View { + let bundleID: String + let onRemove: () -> Void + + // Cached values computed once at init + private let appName: String + private let appIcon: NSImage + + init(bundleID: String, onRemove: @escaping () -> Void) { + self.bundleID = bundleID + self.onRemove = onRemove + + // Compute app info once at initialization + if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { + let icon = NSWorkspace.shared.icon(forFile: appURL.path) + icon.size = NSSize(width: 32, height: 32) + self.appIcon = icon + + // Try to get app name from bundle + if let bundle = Bundle(url: appURL) { + if let bundleName = bundle.infoDictionary?["CFBundleName"] as? String { + self.appName = bundleName + } else if let displayName = bundle.infoDictionary?["CFBundleDisplayName"] as? String { + self.appName = displayName + } else { + self.appName = appURL.deletingPathExtension().lastPathComponent + } + } else { + self.appName = appURL.deletingPathExtension().lastPathComponent + } + } else { + // Fallback: generic app icon and bundle ID as name + let genericIcon = NSWorkspace.shared.icon(for: .application) + genericIcon.size = NSSize(width: 32, height: 32) + self.appIcon = genericIcon + self.appName = bundleID + } + } + + var body: some View { + HStack(alignment: .center, spacing: DashboardTheme.Spacing.md) { + // App icon + Image(nsImage: appIcon) + .resizable() + .frame(width: 32, height: 32) + + // App name and bundle ID + VStack(alignment: .leading, spacing: 2) { + Text(appName) + .font(DashboardTheme.Fonts.sans(14, weight: .medium)) + .foregroundStyle(DashboardTheme.ink) + .lineLimit(1) + + Text(bundleID) + .font(DashboardTheme.Fonts.mono(11, weight: .regular)) + .foregroundStyle(DashboardTheme.inkMuted) + .lineLimit(1) + } + + Spacer() + + // Remove button + Button(action: onRemove) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(DashboardTheme.inkMuted) + } + .buttonStyle(.plain) + .help("Remove from excluded apps") + } + .padding(DashboardTheme.Spacing.md) + } +} + // MARK: - Text Field Row internal struct SettingsTextFieldRow: View { let title: String diff --git a/Sources/Views/Dashboard/DashboardView.swift b/Sources/Views/Dashboard/DashboardView.swift index 7edec5d..e2583b6 100644 --- a/Sources/Views/Dashboard/DashboardView.swift +++ b/Sources/Views/Dashboard/DashboardView.swift @@ -224,15 +224,16 @@ internal struct DashboardView: View { .font(.system(size: 13, weight: .regular)) .frame(width: 18) .foregroundStyle(selectedNav == item ? DashboardTheme.sidebarLight : DashboardTheme.sidebarTextMuted) - + Text(item.rawValue) .font(DashboardTheme.Fonts.sans(13, weight: selectedNav == item ? .medium : .regular)) .foregroundStyle(selectedNav == item ? DashboardTheme.sidebarText : DashboardTheme.sidebarTextMuted) - + Spacer() } .padding(.horizontal, DashboardTheme.Spacing.sm) .padding(.vertical, DashboardTheme.Spacing.sm + 2) + .contentShape(Rectangle()) // Makes entire row tappable, not just the text .background( RoundedRectangle(cornerRadius: 6) .fill(selectedNav == item ? DashboardTheme.sidebarAccentSubtle : Color.clear) diff --git a/Tests/SilentTranscriptionServiceTests.swift b/Tests/SilentTranscriptionServiceTests.swift new file mode 100644 index 0000000..ab91cf1 --- /dev/null +++ b/Tests/SilentTranscriptionServiceTests.swift @@ -0,0 +1,175 @@ +import XCTest +import AVFoundation +@testable import AudioWhisper + +@MainActor +final class SilentTranscriptionServiceTests: XCTestCase { + + override func setUp() { + super.setUp() + // Clear relevant UserDefaults before each test + UserDefaults.standard.removeObject(forKey: "transcriptionProvider") + UserDefaults.standard.removeObject(forKey: "selectedWhisperModel") + UserDefaults.standard.removeObject(forKey: "semanticCorrectionMode") + UserDefaults.standard.removeObject(forKey: "enableSmartPaste") + UserDefaults.standard.removeObject(forKey: "silentExpressMode") + } + + override func tearDown() { + // Clean up UserDefaults after each test + UserDefaults.standard.removeObject(forKey: "transcriptionProvider") + UserDefaults.standard.removeObject(forKey: "selectedWhisperModel") + UserDefaults.standard.removeObject(forKey: "semanticCorrectionMode") + UserDefaults.standard.removeObject(forKey: "enableSmartPaste") + UserDefaults.standard.removeObject(forKey: "silentExpressMode") + super.tearDown() + } + + // MARK: - Singleton Tests + + func testSharedInstanceIsSingleton() { + let instance1 = SilentTranscriptionService.shared + let instance2 = SilentTranscriptionService.shared + XCTAssertTrue(instance1 === instance2, "Shared instance should be a singleton") + } + + // MARK: - Cancellation Tests + + func testCancelCurrentTranscriptionDoesNotCrashWhenNoTaskRunning() { + // Should not crash when called with no active task + SilentTranscriptionService.shared.cancelCurrentTranscription() + // If we get here without crashing, the test passes + XCTAssertTrue(true) + } + + func testCancelCurrentTranscriptionCanBeCalledMultipleTimes() { + // Should be safe to call multiple times + SilentTranscriptionService.shared.cancelCurrentTranscription() + SilentTranscriptionService.shared.cancelCurrentTranscription() + SilentTranscriptionService.shared.cancelCurrentTranscription() + XCTAssertTrue(true) + } + + // MARK: - UserDefaults Configuration Tests + + func testDefaultTranscriptionProviderIsOpenAI() { + UserDefaults.standard.removeObject(forKey: "transcriptionProvider") + let providerRaw = UserDefaults.standard.string(forKey: "transcriptionProvider") ?? TranscriptionProvider.openai.rawValue + let provider = TranscriptionProvider(rawValue: providerRaw) + XCTAssertEqual(provider, .openai) + } + + func testDefaultWhisperModelIsBase() { + UserDefaults.standard.removeObject(forKey: "selectedWhisperModel") + let modelRaw = UserDefaults.standard.string(forKey: "selectedWhisperModel") ?? WhisperModel.base.rawValue + let model = WhisperModel(rawValue: modelRaw) + XCTAssertEqual(model, .base) + } + + func testDefaultSemanticCorrectionModeIsOff() { + UserDefaults.standard.removeObject(forKey: "semanticCorrectionMode") + let modeRaw = UserDefaults.standard.string(forKey: "semanticCorrectionMode") ?? SemanticCorrectionMode.off.rawValue + let mode = SemanticCorrectionMode(rawValue: modeRaw) + XCTAssertEqual(mode, .off) + } + + func testSmartPasteDefaultsToFalse() { + UserDefaults.standard.removeObject(forKey: "enableSmartPaste") + let enableSmartPaste = UserDefaults.standard.bool(forKey: "enableSmartPaste") + XCTAssertFalse(enableSmartPaste) + } + + func testSilentExpressModeDefaultsToFalse() { + UserDefaults.standard.removeObject(forKey: "silentExpressMode") + let silentExpressMode = UserDefaults.standard.bool(forKey: "silentExpressMode") + XCTAssertFalse(silentExpressMode) + } + + // MARK: - Settings Persistence Tests + + func testSilentExpressModeCanBeEnabled() { + UserDefaults.standard.set(true, forKey: "silentExpressMode") + let silentExpressMode = UserDefaults.standard.bool(forKey: "silentExpressMode") + XCTAssertTrue(silentExpressMode) + } + + func testSmartPasteCanBeEnabled() { + UserDefaults.standard.set(true, forKey: "enableSmartPaste") + let enableSmartPaste = UserDefaults.standard.bool(forKey: "enableSmartPaste") + XCTAssertTrue(enableSmartPaste) + } + + func testTranscriptionProviderCanBeSetToLocal() { + UserDefaults.standard.set(TranscriptionProvider.local.rawValue, forKey: "transcriptionProvider") + let providerRaw = UserDefaults.standard.string(forKey: "transcriptionProvider") + let provider = TranscriptionProvider(rawValue: providerRaw ?? "") + XCTAssertEqual(provider, .local) + } + + func testTranscriptionProviderCanBeSetToParakeet() { + UserDefaults.standard.set(TranscriptionProvider.parakeet.rawValue, forKey: "transcriptionProvider") + let providerRaw = UserDefaults.standard.string(forKey: "transcriptionProvider") + let provider = TranscriptionProvider(rawValue: providerRaw ?? "") + XCTAssertEqual(provider, .parakeet) + } + + // MARK: - Notification Tests + + func testRecordingStoppedNotificationName() { + // Verify the notification name exists and is not empty + let notificationName = Notification.Name.recordingStopped + XCTAssertFalse(notificationName.rawValue.isEmpty) + } + + func testRestoreFocusNotificationName() { + // Verify the notification name exists + let notificationName = Notification.Name.restoreFocusToPreviousApp + XCTAssertFalse(notificationName.rawValue.isEmpty) + } + + // MARK: - Integration Readiness Tests + + func testServiceCanAccessSpeechService() { + // This test verifies that the service has proper access to SpeechToTextService + // The actual transcription would require audio data, but we verify the path exists + let service = SilentTranscriptionService.shared + XCTAssertNotNil(service, "Service should be instantiated") + } + + // MARK: - Timing Constants Tests + + func testTimingConstantsAreReasonable() { + // These tests verify the timing constants are within reasonable bounds + // We can't access private constants directly, but we document expected behavior + + // Clipboard ready delay should be short (< 500ms) + // App activation delay should be short (< 500ms) + // These are implementation details, but this test documents the expectations + XCTAssertTrue(true, "Timing constants are implementation details") + } +} + +// MARK: - Mock AudioRecorder for Testing + +@MainActor +final class MockAudioRecorderForSilentService: AudioRecorder { + var mockRecordingURL: URL? + var mockIsRecording: Bool = false + var mockDuration: TimeInterval = 5.0 + + override func stopRecording() -> URL? { + isRecording = false + return mockRecordingURL + } +} + +// MARK: - Notification Name Verification + +extension Notification.Name { + // Verify these notification names are accessible + static func verifyNotificationNamesExist() -> Bool { + _ = Notification.Name.recordingStopped + _ = Notification.Name.restoreFocusToPreviousApp + return true + } +} diff --git a/scripts/build.sh b/scripts/build.sh index f1f14fb..c8f4b47 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -163,11 +163,19 @@ if [ -f "Sources/Resources/bin/uv" ]; then chmod +x AudioWhisper.app/Contents/Resources/bin/uv echo "Bundled uv binary (from repo)" else + UV_PATH="" if command -v uv >/dev/null 2>&1; then UV_PATH=$(command -v uv) + elif [ -f "$HOME/.local/bin/uv" ]; then + UV_PATH="$HOME/.local/bin/uv" + elif [ -f "/usr/local/bin/uv" ]; then + UV_PATH="/usr/local/bin/uv" + fi + + if [ -n "$UV_PATH" ]; then cp "$UV_PATH" AudioWhisper.app/Contents/Resources/bin/uv chmod +x AudioWhisper.app/Contents/Resources/bin/uv - echo "Bundled uv binary (from system: $UV_PATH)" + echo "Bundled uv binary (from: $UV_PATH)" else echo "ℹ️ No bundled uv found and no system uv available; runtime will try PATH" fi @@ -321,8 +329,24 @@ fi if [ -n "$SIGNING_IDENTITY" ]; then sign_app "$SIGNING_IDENTITY" "$SIGNING_NAME" else - echo "💡 No Developer ID found. App will be unsigned." - echo "💡 To sign the app, get a Developer ID certificate from Apple Developer Portal." + echo "💡 No Developer ID found. Using adhoc signing with stable identifier." + echo "💡 For distribution, get a Developer ID certificate from Apple Developer Portal." + + # Sign uv binary if present (nested executable) + if [ -f "AudioWhisper.app/Contents/Resources/bin/uv" ]; then + codesign --force --sign - --identifier "com.audiowhisper.uv" --options runtime --entitlements AudioWhisper.entitlements AudioWhisper.app/Contents/Resources/bin/uv + fi + + # Adhoc sign with stable identifier - critical for TCC permission persistence + codesign --force --deep --sign - --identifier "com.audiowhisper.app" --options runtime --entitlements AudioWhisper.entitlements AudioWhisper.app + + if [ $? -eq 0 ]; then + echo "🔍 Verifying adhoc signature..." + codesign --verify --verbose AudioWhisper.app + echo "✅ App signed with adhoc signature (com.audiowhisper.app)" + else + echo "⚠️ Adhoc signing failed - permissions may not persist correctly" + fi fi # Clean up entitlements file