diff --git a/TalkFlow/Features/History/HistoryStorage.swift b/TalkFlow/Features/History/HistoryStorage.swift index cc9b216..4dd7a26 100644 --- a/TalkFlow/Features/History/HistoryStorage.swift +++ b/TalkFlow/Features/History/HistoryStorage.swift @@ -131,10 +131,22 @@ final class HistoryStorage: HistoryStorageProtocol, @unchecked Sendable { Logger.shared.info("Database migration: added source, model, metadata columns", component: "HistoryStorage") } + // Clean up empty transcription records that were accidentally saved + migrator.registerMigration("cleanupEmptyRecords") { db in + try db.execute(sql: "DELETE FROM transcriptions WHERE TRIM(text) = ''") + Logger.shared.info("Database migration: cleaned up empty transcription records", component: "HistoryStorage") + } + try migrator.migrate(dbQueue!) } func save(_ record: TranscriptionRecord) async throws { + // Skip empty transcriptions + guard !record.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + Logger.shared.debug("Skipping save for empty transcription record", component: "HistoryStorage") + return + } + try await dbQueue?.write { db in try record.insert(db) } diff --git a/TalkFlow/Features/Shortcut/ShortcutManager.swift b/TalkFlow/Features/Shortcut/ShortcutManager.swift index 849b74f..80d4d1d 100644 --- a/TalkFlow/Features/Shortcut/ShortcutManager.swift +++ b/TalkFlow/Features/Shortcut/ShortcutManager.swift @@ -125,15 +125,20 @@ final class ShortcutManager: KeyEventMonitorDelegate { keyDownTime = Date() - // Start hold timer - let holdDuration = Double(configurationManager.configuration.minimumHoldDurationMs) / 1000.0 - holdTimer = Timer.scheduledTimer(withTimeInterval: holdDuration, repeats: false) { [weak self] _ in - Task { @MainActor in - self?.startRecording() + // Start recording immediately if instant mode, otherwise use hold timer + let holdDurationMs = configurationManager.configuration.minimumHoldDurationMs + if holdDurationMs == 0 { + startRecording() + Logger.shared.debug("Key down detected, instant recording started", component: "ShortcutManager") + } else { + let holdDuration = Double(holdDurationMs) / 1000.0 + holdTimer = Timer.scheduledTimer(withTimeInterval: holdDuration, repeats: false) { [weak self] _ in + Task { @MainActor in + self?.startRecording() + } } + Logger.shared.debug("Key down detected, starting hold timer", component: "ShortcutManager") } - - Logger.shared.debug("Key down detected, starting hold timer", component: "ShortcutManager") } func keyEventMonitor(_ monitor: KeyEventMonitor, didDetectKeyUp keyCode: UInt16, flags: CGEventFlags) { @@ -299,6 +304,16 @@ final class ShortcutManager: KeyEventMonitorDelegate { finalText = self.stripPunctuation(from: finalText) } + // Skip empty transcriptions + if finalText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + await MainActor.run { + indicator.showNoSpeech() + self.isProcessing = false + } + Logger.shared.info("Transcription returned empty text, skipping", component: "ShortcutManager") + return + } + await MainActor.run { // Output text outputManager.insert(finalText) diff --git a/TalkFlow/Services/KeychainService.swift b/TalkFlow/Services/KeychainService.swift index fbbbfd5..aed915c 100644 --- a/TalkFlow/Services/KeychainService.swift +++ b/TalkFlow/Services/KeychainService.swift @@ -6,6 +6,7 @@ protocol KeychainServiceProtocol { func getAPIKey() -> String? func deleteAPIKey() func hasAPIKey() -> Bool + func hasAPIKeyWithoutFetch() -> Bool func migrateIfNeeded() } @@ -82,4 +83,22 @@ final class KeychainService: KeychainServiceProtocol { func hasAPIKey() -> Bool { return getAPIKey() != nil } + + /// Check if an API key exists without retrieving its value. + /// This avoids triggering the Keychain permission dialog by only querying + /// for attributes rather than the actual data. + func hasAPIKeyWithoutFetch() -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: apiKeyAccount, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + return status == errSecSuccess + } } diff --git a/TalkFlow/UI/Indicator/IndicatorState.swift b/TalkFlow/UI/Indicator/IndicatorState.swift index 7cdf8de..7ba21fb 100644 --- a/TalkFlow/UI/Indicator/IndicatorState.swift +++ b/TalkFlow/UI/Indicator/IndicatorState.swift @@ -137,8 +137,9 @@ final class IndicatorStateManager: @unchecked Sendable { hideTimer?.invalidate() hideTimer = Timer.scheduledTimer(withTimeInterval: 2.5, repeats: false) { [weak self] _ in Task { @MainActor in - // Don't hide if we're in a persistent state - if self?.state.isPersistent == false { + // Only hide if we're still in a transient state (success, error, noSpeech) + // This prevents hiding if a new recording has started since the timer was scheduled + if self?.state.isTransient == true { self?.state = .idle } } diff --git a/TalkFlow/UI/MainWindow/MainWindowView.swift b/TalkFlow/UI/MainWindow/MainWindowView.swift index c494257..f37100f 100644 --- a/TalkFlow/UI/MainWindow/MainWindowView.swift +++ b/TalkFlow/UI/MainWindow/MainWindowView.swift @@ -60,7 +60,7 @@ struct MainWindowView: View { // Content area with inset styling contentAreaView } - .frame(minWidth: 600, minHeight: 400) + .frame(minWidth: 900, minHeight: 600) .background(DesignConstants.sidebarBackground) .environment(\.colorScheme, .light) .tint(DesignConstants.accentColor) // Apply brand accent to system controls @@ -617,7 +617,7 @@ struct MainWindowView_Previews: PreviewProvider { MainWindowView(onboardingManager: OnboardingManager()) .environment(\.historyStorage, HistoryStorage()) .environment(\.configurationManager, ConfigurationManager()) - .frame(width: 800, height: 600) + .frame(width: 900, height: 600) } } #endif diff --git a/TalkFlow/UI/Settings/AppearanceSettingsView.swift b/TalkFlow/UI/Settings/AppearanceSettingsView.swift index 7a1eedb..c4f8634 100644 --- a/TalkFlow/UI/Settings/AppearanceSettingsView.swift +++ b/TalkFlow/UI/Settings/AppearanceSettingsView.swift @@ -27,6 +27,7 @@ private struct AppearanceSettingsContent: View { Spacer() Toggle("", isOn: $manager.configuration.indicatorVisibleWhenIdle) .labelsHidden() + .tint(DesignConstants.accentColor) } Text("When enabled, the status indicator will always be visible. When disabled, it only appears during recording and processing.") diff --git a/TalkFlow/UI/Settings/GeneralSettingsView.swift b/TalkFlow/UI/Settings/GeneralSettingsView.swift index adaa4ec..9577f79 100644 --- a/TalkFlow/UI/Settings/GeneralSettingsView.swift +++ b/TalkFlow/UI/Settings/GeneralSettingsView.swift @@ -41,6 +41,8 @@ private struct GeneralSettingsContent: View { .foregroundColor(DesignConstants.primaryText) Spacer() Picker("", selection: $manager.configuration.minimumHoldDurationMs) { + Text("Instant").tag(0) + Text("50ms").tag(50) Text("100ms").tag(100) Text("200ms").tag(200) Text("300ms (Default)").tag(300) diff --git a/TalkFlow/UI/Settings/TranscriptionSettingsView.swift b/TalkFlow/UI/Settings/TranscriptionSettingsView.swift index 800fdef..cfbeadc 100644 --- a/TalkFlow/UI/Settings/TranscriptionSettingsView.swift +++ b/TalkFlow/UI/Settings/TranscriptionSettingsView.swift @@ -6,6 +6,8 @@ struct TranscriptionSettingsView: View { @State private var apiKey: String = "" @State private var isAPIKeyVisible = false @State private var showingSaveConfirmation = false + @State private var hasStoredKey = false + @State private var hasLoadedActualKey = false private let keychainService = KeychainService() @@ -17,10 +19,12 @@ struct TranscriptionSettingsView: View { apiKey: $apiKey, isAPIKeyVisible: $isAPIKeyVisible, showingSaveConfirmation: $showingSaveConfirmation, + hasStoredKey: $hasStoredKey, + hasLoadedActualKey: $hasLoadedActualKey, keychainService: keychainService ) .onAppear { - loadAPIKey() + checkForStoredKey() } } else { Text("Configuration not available") @@ -28,9 +32,12 @@ struct TranscriptionSettingsView: View { } } - private func loadAPIKey() { - if let key = keychainService.getAPIKey() { - apiKey = String(repeating: "*", count: min(key.count, 20)) + /// Check if an API key exists without triggering Keychain permission dialog + private func checkForStoredKey() { + hasStoredKey = keychainService.hasAPIKeyWithoutFetch() + if hasStoredKey && !hasLoadedActualKey { + // Show placeholder asterisks without actually loading the key + apiKey = String(repeating: "•", count: 20) } } } @@ -41,6 +48,8 @@ private struct TranscriptionSettingsContent: View { @Binding var apiKey: String @Binding var isAPIKeyVisible: Bool @Binding var showingSaveConfirmation: Bool + @Binding var hasStoredKey: Bool + @Binding var hasLoadedActualKey: Bool let keychainService: KeychainService @State private var showDownloadSuccessAlert = false @@ -65,6 +74,7 @@ private struct TranscriptionSettingsContent: View { Spacer() Toggle("", isOn: localModeBinding) .labelsHidden() + .tint(DesignConstants.accentColor) .disabled(modelManager.isDownloading) } @@ -100,7 +110,7 @@ private struct TranscriptionSettingsContent: View { .foregroundColor(DesignConstants.primaryText) } - Button(action: { isAPIKeyVisible.toggle() }) { + Button(action: { toggleAPIKeyVisibility() }) { Image(systemName: isAPIKeyVisible ? "eye.slash" : "eye") .foregroundColor(DesignConstants.secondaryText) } @@ -176,6 +186,7 @@ private struct TranscriptionSettingsContent: View { Spacer() Toggle("", isOn: $manager.configuration.stripPunctuation) .labelsHidden() + .tint(DesignConstants.accentColor) } Text("Remove periods, commas, and other punctuation from transcriptions. Capitalization is preserved.") @@ -354,14 +365,32 @@ private struct TranscriptionSettingsContent: View { // MARK: - Actions + private func toggleAPIKeyVisibility() { + if !isAPIKeyVisible { + // User wants to reveal the key - load from Keychain if not already loaded + if hasStoredKey && !hasLoadedActualKey { + if let key = keychainService.getAPIKey() { + apiKey = key + hasLoadedActualKey = true + } + } + } + isAPIKeyVisible.toggle() + } + private func saveAPIKey() { - if apiKey.allSatisfy({ $0 == "*" }) { + // Don't save if it's just placeholder dots + if apiKey.allSatisfy({ $0 == "•" || $0 == "*" }) { return } keychainService.setAPIKey(apiKey) showingSaveConfirmation = true - apiKey = String(repeating: "*", count: min(apiKey.count, 20)) + hasStoredKey = true + hasLoadedActualKey = true + // Mask the key after saving + apiKey = String(repeating: "•", count: min(apiKey.count, 20)) + isAPIKeyVisible = false } private func downloadModel(_ modelId: String) { diff --git a/TalkFlowTests/HistoryStorageTests.swift b/TalkFlowTests/HistoryStorageTests.swift index 87212e6..04c2e6a 100644 --- a/TalkFlowTests/HistoryStorageTests.swift +++ b/TalkFlowTests/HistoryStorageTests.swift @@ -88,4 +88,19 @@ final class HistoryStorageTests: XCTestCase { fetched = await storage.fetchAll() XCTAssertEqual(fetched.count, 0) } + + func testSaveEmptyRecordSkipped() async throws { + try await storage.save(TranscriptionRecord(text: "")) + + let fetched = await storage.fetchAll() + XCTAssertEqual(fetched.count, 0, "Empty records should not be saved") + } + + func testSaveWhitespaceOnlyRecordSkipped() async throws { + try await storage.save(TranscriptionRecord(text: " ")) + try await storage.save(TranscriptionRecord(text: "\n\t")) + + let fetched = await storage.fetchAll() + XCTAssertEqual(fetched.count, 0, "Whitespace-only records should not be saved") + } } diff --git a/TalkFlowTests/Mocks/MockKeychainService.swift b/TalkFlowTests/Mocks/MockKeychainService.swift index 57f3403..e7d6bb7 100644 --- a/TalkFlowTests/Mocks/MockKeychainService.swift +++ b/TalkFlowTests/Mocks/MockKeychainService.swift @@ -20,6 +20,10 @@ final class MockKeychainService: KeychainServiceProtocol { return storedAPIKey != nil } + func hasAPIKeyWithoutFetch() -> Bool { + return storedAPIKey != nil + } + func migrateIfNeeded() { // No-op for mock }