Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions TalkFlow/Features/History/HistoryStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
29 changes: 22 additions & 7 deletions TalkFlow/Features/Shortcut/ShortcutManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions TalkFlow/Services/KeychainService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ protocol KeychainServiceProtocol {
func getAPIKey() -> String?
func deleteAPIKey()
func hasAPIKey() -> Bool
func hasAPIKeyWithoutFetch() -> Bool
func migrateIfNeeded()
}

Expand Down Expand Up @@ -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
}
}
5 changes: 3 additions & 2 deletions TalkFlow/UI/Indicator/IndicatorState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
4 changes: 2 additions & 2 deletions TalkFlow/UI/MainWindow/MainWindowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions TalkFlow/UI/Settings/AppearanceSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand Down
2 changes: 2 additions & 0 deletions TalkFlow/UI/Settings/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 36 additions & 7 deletions TalkFlow/UI/Settings/TranscriptionSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -17,20 +19,25 @@ struct TranscriptionSettingsView: View {
apiKey: $apiKey,
isAPIKeyVisible: $isAPIKeyVisible,
showingSaveConfirmation: $showingSaveConfirmation,
hasStoredKey: $hasStoredKey,
hasLoadedActualKey: $hasLoadedActualKey,
keychainService: keychainService
)
.onAppear {
loadAPIKey()
checkForStoredKey()
}
} else {
Text("Configuration not available")
.foregroundColor(DesignConstants.secondaryText)
}
}

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)
}
}
}
Expand All @@ -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
Expand All @@ -65,6 +74,7 @@ private struct TranscriptionSettingsContent: View {
Spacer()
Toggle("", isOn: localModeBinding)
.labelsHidden()
.tint(DesignConstants.accentColor)
.disabled(modelManager.isDownloading)
}

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 15 additions & 0 deletions TalkFlowTests/HistoryStorageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
4 changes: 4 additions & 0 deletions TalkFlowTests/Mocks/MockKeychainService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ final class MockKeychainService: KeychainServiceProtocol {
return storedAPIKey != nil
}

func hasAPIKeyWithoutFetch() -> Bool {
return storedAPIKey != nil
}

func migrateIfNeeded() {
// No-op for mock
}
Expand Down