From 3bf61bd3987206d84732757f5fa6492c074d5d2b Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 17:13:43 +0700 Subject: [PATCH 01/42] Harden privacy and updater paths --- RELEASE.md | 3 +- scripts/lib/common.sh | 11 +- speaktype/Models/AIModel.swift | 14 +- .../Services/AudioRecordingService.swift | 168 ------------------ speaktype/Services/ClipboardService.swift | 4 +- speaktype/Services/HistoryService.swift | 2 + speaktype/Services/ModelDownloadService.swift | 23 ++- .../Transcription/ParakeetEngine.swift | 4 + .../Transcription/TranscriptionManager.swift | 8 +- speaktype/Services/UpdateService.swift | 82 +++++++-- speaktype/Services/WhisperService.swift | 20 ++- speaktype/Views/Components/UpdateSheet.swift | 2 +- .../Views/Overlays/MiniRecorderView.swift | 20 +-- .../AudioRecordingServiceTests.swift | 17 ++ speaktypeTests/ClipboardServiceTests.swift | 15 ++ speaktypeTests/HistoryServiceTests.swift | 31 ++++ .../ModelDownloadServiceTests.swift | 6 + .../UpdateServiceSecurityTests.swift | 60 +++++++ speaktypeTests/WhisperServiceTests.swift | 34 ++++ 19 files changed, 301 insertions(+), 223 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index b4570cf..aaa8289 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -110,8 +110,7 @@ Error: HTTP status code: 401. Unable to authenticate. ```bash xcrun notarytool store-credentials "AC_PASSWORD" \ --apple-id "mail2048labs@gmail.com" \ - --team-id "PCV4UMSRZX" \ - --password "NEW_PASSWORD_HERE" + --team-id "PCV4UMSRZX" ``` ### Notarization Rejected diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 3b5e4e3..04675ea 100644 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -67,8 +67,9 @@ resolve_version() { fi } -# Ensure notarization credentials exist in the keychain, prompting once for an -# app-specific password if the profile is missing. +# Ensure notarization credentials exist in the keychain. When the profile is +# missing, notarytool prompts for the app-specific password itself so the secret +# never appears in this script's process arguments. ensure_notary_credentials() { if xcrun notarytool history --keychain-profile "$NOTARY_PROFILE" &>/dev/null; then return 0 @@ -81,14 +82,12 @@ ensure_notary_credentials() { echo " 2. Sign in with: $APPLE_ID" echo " 3. Security β†’ App-Specific Passwords β†’ Generate" echo "" - read -rp "Enter app-specific password: " -s APP_PASSWORD + echo "notarytool will prompt for the app-specific password securely." echo "" - [ -z "$APP_PASSWORD" ] && { echo "❌ Password required"; exit 1; } xcrun notarytool store-credentials "$NOTARY_PROFILE" \ --apple-id "$APPLE_ID" \ - --team-id "$APPLE_TEAM_ID" \ - --password "$APP_PASSWORD" + --team-id "$APPLE_TEAM_ID" echo "" echo "βœ… Credentials stored. Continuing..." echo "" diff --git a/speaktype/Models/AIModel.swift b/speaktype/Models/AIModel.swift index 2d073d0..d7b2849 100644 --- a/speaktype/Models/AIModel.swift +++ b/speaktype/Models/AIModel.swift @@ -162,15 +162,17 @@ struct AIModel: Identifiable, Equatable { ] /// Returns the expected minimum size for a given model variant - static func expectedSize(for variant: String) -> Int64 { - return availableModels.first(where: { $0.variant == variant })?.expectedSizeBytes - ?? 50_000_000 + static func expectedSize(for variant: String) -> Int64? { + return model(for: variant)?.expectedSizeBytes } /// Returns which backend owns a given model variant. - /// Defaults to `.whisper` for unknown variants so existing behavior is preserved. - static func engineKind(for variant: String) -> TranscriptionEngineKind { - return availableModels.first(where: { $0.variant == variant })?.engine ?? .whisper + static func engineKind(for variant: String) -> TranscriptionEngineKind? { + return model(for: variant)?.engine + } + + static func model(for variant: String) -> AIModel? { + availableModels.first { $0.variant == variant } } /// All models for a given engine. diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index d04d5e4..dfd0107 100644 --- a/speaktype/Services/AudioRecordingService.swift +++ b/speaktype/Services/AudioRecordingService.swift @@ -6,10 +6,6 @@ import Foundation class AudioRecordingService: NSObject, ObservableObject { static let shared = AudioRecordingService() // Shared instance for settings/dashboard sync - // Chunk publisher: emits the URL of each completed ~4-second audio chunk while recording - let chunkPublisher = PassthroughSubject() - private static let chunkDuration: TimeInterval = 4.0 - @Published var isRecording = false @Published var audioLevel: Float = 0.0 @Published var audioFrequency: Float = 0.0 // Normalized 0...1 representation of pitch @@ -41,13 +37,6 @@ class AudioRecordingService: NSObject, ObservableObject { private var isStopping = false // Flag to prevent appending during stop private var idleSessionStopWorkItem: DispatchWorkItem? - // MARK: - Chunking state - private var chunkAssetWriter: AVAssetWriter? - private var chunkAssetWriterInput: AVAssetWriterInput? - private var chunkIsSessionStarted = false - private var chunkStartTime: Date? - private var chunkFileURL: URL? - private var isRotatingChunk = false // Prevents concurrent rotations private var shouldDiscardCurrentRecordingOutput = false private var smoothedAudioLevel: Float = 0.0 private var smoothedAudioFrequency: Float = 0.0 @@ -109,15 +98,6 @@ class AudioRecordingService: NSObject, ObservableObject { smoothedAudioFrequency = 0.0 } - private func resetChunkWriterState() { - chunkAssetWriter = nil - chunkAssetWriterInput = nil - chunkIsSessionStarted = false - chunkStartTime = nil - chunkFileURL = nil - isRotatingChunk = false - } - override init() { super.init() // Restore the persisted device before discovery completes; AVCaptureDevice(uniqueID:) @@ -255,7 +235,6 @@ class AudioRecordingService: NSObject, ObservableObject { shouldDiscardCurrentRecordingOutput = false liveWaveSamples = [] resetMainWriterState() - resetChunkWriterState() isRecording = true recordingStartTime = Date() // Hop onto audioQueue: idleSessionStopWorkItem is only ever touched there, @@ -363,40 +342,10 @@ class AudioRecordingService: NSObject, ObservableObject { return await withCheckedContinuation { continuation in audioQueue.async { - // --- Finalize the last in-flight chunk --- let finishGroup = DispatchGroup() var finalizedRecordingURL: URL? let discardOutput = self.shouldDiscardCurrentRecordingOutput - if let lastChunkInput = self.chunkAssetWriterInput, - let lastChunkWriter = self.chunkAssetWriter, - let lastChunkURL = self.chunkFileURL, - self.chunkIsSessionStarted - { - self.resetChunkWriterState() - - finishGroup.enter() - lastChunkInput.markAsFinished() - lastChunkWriter.finishWriting { - self.audioQueue.async { - if discardOutput { - try? FileManager.default.removeItem(at: lastChunkURL) - } else if let validChunkURL = self.validatedAudioFileURL( - at: lastChunkURL, - writer: lastChunkWriter, - label: "Final chunk" - ) { - print("πŸ”ͺ Final chunk saved: \(validChunkURL.lastPathComponent)") - self.chunkPublisher.send(validChunkURL) - } else { - try? FileManager.default.removeItem(at: lastChunkURL) - } - finishGroup.leave() - } - } - } - - // --- Finalize main (full) recording --- let writer = self.assetWriter let writerInput = self.assetWriterInput self.resetMainWriterState() @@ -468,25 +417,6 @@ class AudioRecordingService: NSObject, ObservableObject { return recordingsDir } - private func getChunksDirectory() -> URL { - let appSupport = FileManager.default.urls( - for: .applicationSupportDirectory, - in: .userDomainMask - )[0] - - let chunksDir = - appSupport - .appendingPathComponent("SpeakType") - .appendingPathComponent("Chunks") - - try? FileManager.default.createDirectory( - at: chunksDir, - withIntermediateDirectories: true - ) - - return chunksDir - } - private func scheduleIdleSessionStop(delay: TimeInterval = 8) { cancelIdleSessionStop() @@ -538,104 +468,6 @@ extension AudioRecordingService: AVCaptureAudioDataOutputSampleBufferDelegate { } } - // --- Chunk writer (background segments) --- - appendToChunk(sampleBuffer: sampleBuffer, pts: pts) - } - - // MARK: - Chunk Writer Helpers (audioQueue) - - private func appendToChunk(sampleBuffer: CMSampleBuffer, pts: CMTime) { - guard !isStopping else { return } - - // Initialize first chunk on first buffer - if chunkAssetWriter == nil { - startNewChunkWriter(startingAt: pts) - } - - guard let cw = chunkAssetWriter, let ci = chunkAssetWriterInput, - cw.status == .writing - else { return } - - if !chunkIsSessionStarted { - cw.startSession(atSourceTime: pts) - chunkIsSessionStarted = true - chunkStartTime = Date() - } - - if ci.isReadyForMoreMediaData { - guard !isStopping else { return } - ci.append(sampleBuffer) - } - - // Rotate chunk after chunkDuration seconds - guard !isRotatingChunk, - let start = chunkStartTime, - Date().timeIntervalSince(start) >= Self.chunkDuration - else { return } - - rotateChunk(nextStartPTS: pts) - } - - private func startNewChunkWriter(startingAt pts: CMTime) { - let url = getChunksDirectory().appendingPathComponent( - "chunk-\(Date().timeIntervalSince1970).wav") - chunkFileURL = url - - guard let cw = try? AVAssetWriter(outputURL: url, fileType: .wav) else { return } - - let settings: [String: Any] = [ - AVFormatIDKey: kAudioFormatLinearPCM, - AVSampleRateKey: 16000.0, - AVNumberOfChannelsKey: 1, - AVLinearPCMBitDepthKey: 16, - AVLinearPCMIsFloatKey: false, - AVLinearPCMIsBigEndianKey: false, - AVLinearPCMIsNonInterleaved: false, - ] - let ci = AVAssetWriterInput(mediaType: .audio, outputSettings: settings) - ci.expectsMediaDataInRealTime = true - - if cw.canAdd(ci) { cw.add(ci) } - cw.startWriting() - - chunkAssetWriter = cw - chunkAssetWriterInput = ci - chunkIsSessionStarted = false - } - - private func rotateChunk(nextStartPTS: CMTime) { - isRotatingChunk = true - - guard let oldWriter = chunkAssetWriter, - let oldInput = chunkAssetWriterInput, - let finishedURL = chunkFileURL - else { - isRotatingChunk = false - return - } - - // Detach before finishing so new samples go to the fresh writer - chunkAssetWriter = nil - chunkAssetWriterInput = nil - chunkIsSessionStarted = false - chunkStartTime = nil - chunkFileURL = nil - - // Spin up the next chunk immediately so no audio is lost - startNewChunkWriter(startingAt: nextStartPTS) - isRotatingChunk = false - - // Finish the old writer asynchronously - oldInput.markAsFinished() - oldWriter.finishWriting { [weak self] in - guard let self = self else { return } - if self.shouldDiscardCurrentRecordingOutput { - try? FileManager.default.removeItem(at: finishedURL) - } else { - print("πŸ”ͺ Chunk saved: \(finishedURL.lastPathComponent)") - self.chunkPublisher.send(finishedURL) - } - } } private func processAudioLevel(from sampleBuffer: CMSampleBuffer) { diff --git a/speaktype/Services/ClipboardService.swift b/speaktype/Services/ClipboardService.swift index 7f9c500..517c9c5 100644 --- a/speaktype/Services/ClipboardService.swift +++ b/speaktype/Services/ClipboardService.swift @@ -29,9 +29,9 @@ class ClipboardService { // Verify write if let check = pasteboard.string(forType: .string), check == finalText { - print("βœ… Clipboard Write Verified: '\(check.prefix(20))...'") + AppLogger.success("Clipboard write verified", category: AppLogger.clipboard) } else { - print("❌ Clipboard Write FAILED!") + AppLogger.error("Clipboard write failed", category: AppLogger.clipboard) } } diff --git a/speaktype/Services/HistoryService.swift b/speaktype/Services/HistoryService.swift index 08be90c..ca52317 100644 --- a/speaktype/Services/HistoryService.swift +++ b/speaktype/Services/HistoryService.swift @@ -90,7 +90,9 @@ class HistoryService: ObservableObject { } func clearAll() { + let itemsToDelete = items items.removeAll() + itemsToDelete.forEach(removeAudioFileIfNeeded(for:)) saveHistory() } diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift index 4b3b358..0780783 100644 --- a/speaktype/Services/ModelDownloadService.swift +++ b/speaktype/Services/ModelDownloadService.swift @@ -222,7 +222,10 @@ class ModelDownloadService: ObservableObject { // Calculate total directory size let directorySize = Self.calculateDirectorySize(at: item) - let expectedSize = AIModel.expectedSize(for: modelName) + guard let expectedSize = AIModel.expectedSize(for: modelName) else { + print("⚠️ Ignoring unknown model directory: \(modelName)") + continue + } // Model is complete if it's at least 80% of expected size let minAcceptableSize = Int64(Double(expectedSize) * 0.8) @@ -239,9 +242,14 @@ class ModelDownloadService: ObservableObject { // Asynchronous download using WhisperKit func downloadModel(variant: String) { guard isDownloading[variant] != true else { return } + guard let engineKind = AIModel.engineKind(for: variant) else { + downloadProgress[variant] = 0.0 + downloadError[variant] = "Unknown model variant." + return + } // Route Parakeet variants to FluidAudio. - if AIModel.engineKind(for: variant) == .parakeet { + if engineKind == .parakeet { downloadParakeetModel(variant: variant) return } @@ -404,8 +412,17 @@ class ModelDownloadService: ObservableObject { // Aggressively deletes any potential cache for this variant func deleteModel(variant: String) async -> String { + guard let engineKind = AIModel.engineKind(for: variant) else { + await MainActor.run { + self.downloadProgress[variant] = 0.0 + self.isDownloading[variant] = false + self.downloadError[variant] = "Unknown model variant." + } + return "Unknown model variant" + } + // Parakeet models are managed by FluidAudio in its own cache directory. - if AIModel.engineKind(for: variant) == .parakeet { + if engineKind == .parakeet { let version = ParakeetCatalog.version(for: variant) let cacheDir = AsrModels.defaultCacheDirectory(for: version) try? FileManager.default.removeItem(at: cacheDir) diff --git a/speaktype/Services/Transcription/ParakeetEngine.swift b/speaktype/Services/Transcription/ParakeetEngine.swift index 7111142..b462b60 100644 --- a/speaktype/Services/Transcription/ParakeetEngine.swift +++ b/speaktype/Services/Transcription/ParakeetEngine.swift @@ -46,6 +46,10 @@ class ParakeetEngine: SpeechToTextEngine { private init() {} func loadModel(variant: String) async throws { + guard ParakeetCatalog.variants.contains(variant) else { + throw WhisperService.TranscriptionError.unsupportedModelVariant + } + // Already loaded this exact model. if isInitialized, currentModelVariant == variant, manager != nil { return } diff --git a/speaktype/Services/Transcription/TranscriptionManager.swift b/speaktype/Services/Transcription/TranscriptionManager.swift index 84a0a68..fa908f2 100644 --- a/speaktype/Services/Transcription/TranscriptionManager.swift +++ b/speaktype/Services/Transcription/TranscriptionManager.swift @@ -83,14 +83,18 @@ class TranscriptionManager { /// Load a specific model variant, routing to its owning engine. func loadModel(variant: String) async throws { - let kind = AIModel.engineKind(for: variant) + guard let kind = AIModel.engineKind(for: variant) else { + throw WhisperService.TranscriptionError.unsupportedModelVariant + } try await engine(for: kind).loadModel(variant: variant) activeKind = kind } /// Transcribe an audio file with the currently active engine. func transcribe(audioFile: URL, language: String = "auto") async throws -> String { - let kind = AIModel.engineKind(for: currentModelVariant) + guard let kind = AIModel.engineKind(for: currentModelVariant) else { + throw WhisperService.TranscriptionError.unsupportedModelVariant + } return try await engine(for: kind).transcribe(audioFile: audioFile, language: language) } } diff --git a/speaktype/Services/UpdateService.swift b/speaktype/Services/UpdateService.swift index fc11ee9..8dc7ec4 100644 --- a/speaktype/Services/UpdateService.swift +++ b/speaktype/Services/UpdateService.swift @@ -132,7 +132,19 @@ class UpdateService: NSObject, ObservableObject { // MARK: - Update Installation /// Download the DMG, mount it, copy the .app over the running installation, and relaunch. - func installUpdate(url downloadURLString: String) { + func installUpdate(_ update: AppVersion) { + installUpdate( + url: update.downloadURL, + expectedVersion: update.version, + expectedBuild: Self.normalizedExpectedBuild(update.buildNumber) + ) + } + + private func installUpdate( + url downloadURLString: String, + expectedVersion: String, + expectedBuild: String? + ) { guard let downloadURL = URL(string: downloadURLString) else { setError("Invalid download URL.") return @@ -181,8 +193,12 @@ class UpdateService: NSObject, ObservableObject { } let appInDMG = try findApp(in: mountPoint) - // 5. Verify the mounted app is signed by the expected developer - try verifyCandidateApp(at: appInDMG) + // 5. Verify the mounted app is the expected version and signed by the expected developer + try verifyCandidateApp( + at: appInDMG, + expectedVersion: expectedVersion, + expectedBuild: expectedBuild + ) // 6. Replace the running app try replaceCurrentApp(with: appInDMG) @@ -283,7 +299,11 @@ class UpdateService: NSObject, ObservableObject { return appURL } - private func verifyCandidateApp(at appURL: URL) throws { + private func verifyCandidateApp( + at appURL: URL, + expectedVersion: String, + expectedBuild: String? + ) throws { guard let bundle = Bundle(url: appURL), let bundleIdentifier = bundle.bundleIdentifier @@ -299,6 +319,14 @@ class UpdateService: NSObject, ObservableObject { ) } + try Self.validateCandidateVersion( + candidateVersion: bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") + as? String, + candidateBuild: bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String, + expectedVersion: expectedVersion, + expectedBuild: expectedBuild + ) + let staticCode = try Self.loadStaticCode(at: appURL) let requirement = try Self.makeTrustedUpdateRequirement( bundleIdentifier: Self.trustedUpdateBundleIdentifier, @@ -358,18 +386,15 @@ class UpdateService: NSObject, ObservableObject { } private func relaunch() { - // Use a shell to wait for the current process to exit, then reopen the app + // Use a static shell script to wait for the current process to exit, then + // reopen the app. Dynamic values are passed as positional parameters so + // bundle path metacharacters never become shell source. let bundlePath = Bundle.main.bundlePath let pid = ProcessInfo.processInfo.processIdentifier - let script = """ - while kill -0 \(pid) 2>/dev/null; do sleep 0.2; done - open "\(bundlePath)" - """ - let proc = Process() proc.executableURL = URL(fileURLWithPath: "/bin/sh") - proc.arguments = ["-c", script] + proc.arguments = Self.relaunchShellArguments(pid: pid, bundlePath: bundlePath) try? proc.run() DispatchQueue.main.async { @@ -447,6 +472,41 @@ class UpdateService: NSObject, ObservableObject { } } + static func validateCandidateVersion( + candidateVersion: String?, + candidateBuild: String?, + expectedVersion: String, + expectedBuild: String? + ) throws { + guard candidateVersion == expectedVersion else { + throw UpdateError.invalidCandidateApp( + "Downloaded update version does not match the advertised release." + ) + } + + guard let expectedBuild else { return } + + guard candidateBuild == expectedBuild else { + throw UpdateError.invalidCandidateApp( + "Downloaded update build does not match the advertised release." + ) + } + } + + static func normalizedExpectedBuild(_ buildNumber: String) -> String? { + let trimmed = buildNumber.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty || trimmed == "0" ? nil : trimmed + } + + static func relaunchShellArguments(pid: Int32, bundlePath: String) -> [String] { + let script = """ + while kill -0 "$1" 2>/dev/null; do sleep 0.2; done + exec /usr/bin/open "$2" + """ + + return ["-c", script, "speaktype-relaunch", String(pid), bundlePath] + } + private static func loadStaticCode(at appURL: URL) throws -> SecStaticCode { var staticCode: SecStaticCode? let status = SecStaticCodeCreateWithPath(appURL as CFURL, SecCSFlags(), &staticCode) diff --git a/speaktype/Services/WhisperService.swift b/speaktype/Services/WhisperService.swift index 3e8ce44..dde6ea4 100644 --- a/speaktype/Services/WhisperService.swift +++ b/speaktype/Services/WhisperService.swift @@ -74,6 +74,7 @@ class WhisperService { case fileNotFound case alreadyLoading case loadingTimeout + case unsupportedModelVariant var errorDescription: String? { switch self { @@ -82,6 +83,8 @@ class WhisperService { case .alreadyLoading: return "Model loading already in progress" case .loadingTimeout: return "Model loading timed out β€” your Mac may not have enough RAM for this model" + case .unsupportedModelVariant: + return "The selected model is not supported." } } } @@ -98,6 +101,10 @@ class WhisperService { // Dynamic model loading with optimized WhisperKitConfig @MainActor func loadModel(variant: String) async throws { + guard let model = AIModel.model(for: variant), model.engine == .whisper else { + throw TranscriptionError.unsupportedModelVariant + } + // Already loaded this exact model if isInitialized && variant == currentModelVariant && pipe != nil { print("βœ… Model \(variant) already loaded, skipping") @@ -133,7 +140,7 @@ class WhisperService { let token = UUID() let task = Task { @MainActor in - try await self.performModelLoad(variant: variant) + try await self.performModelLoad(model: model) } activeLoadTask = task activeLoadVariant = variant @@ -151,14 +158,13 @@ class WhisperService { } @MainActor - private func performModelLoad(variant: String) async throws { + private func performModelLoad(model: AIModel) async throws { + let variant = model.variant let ramGB = Self.deviceRAMGB print("πŸ”„ Initializing WhisperKit with model: \(variant)...") print("πŸ’» Device RAM: \(ramGB) GB") - if let model = AIModel.availableModels.first(where: { $0.variant == variant }), - ramGB < model.minimumRAMGB - { + if ramGB < model.minimumRAMGB { print( "⚠️ WARNING: Model \(variant) recommends \(model.minimumRAMGB)GB+ RAM, device has \(ramGB)GB. Loading may fail or be very slow." ) @@ -258,7 +264,7 @@ class WhisperService { let text = Self.normalizedTranscription( from: results.map { $0.text }.joined(separator: " ")) - print("Transcription complete: \(text.prefix(50))...") + AppLogger.success("Transcription complete", category: AppLogger.transcription) return text } catch { print("Transcription failed: \(error.localizedDescription)") @@ -286,7 +292,7 @@ class WhisperService { ) let text = Self.normalizedTranscription(from: results.map { $0.text }.joined(separator: " ")) - print("πŸ”ͺ Chunk done: \(text.prefix(40))...") + AppLogger.success("Chunk transcription complete", category: AppLogger.transcription) // Clean up temp chunk file after transcription try? FileManager.default.removeItem(at: audioFile) return text diff --git a/speaktype/Views/Components/UpdateSheet.swift b/speaktype/Views/Components/UpdateSheet.swift index b72e861..58e9f92 100644 --- a/speaktype/Views/Components/UpdateSheet.swift +++ b/speaktype/Views/Components/UpdateSheet.swift @@ -144,7 +144,7 @@ struct UpdateSheet: View { .buttonStyle(SecondaryButtonStyle()) Button("Install Update") { - updateService.installUpdate(url: update.downloadURL) + updateService.installUpdate(update) } .buttonStyle(PrimaryButtonStyle()) .disabled(updateService.isInstalling) diff --git a/speaktype/Views/Overlays/MiniRecorderView.swift b/speaktype/Views/Overlays/MiniRecorderView.swift index ed97c4a..0f63691 100644 --- a/speaktype/Views/Overlays/MiniRecorderView.swift +++ b/speaktype/Views/Overlays/MiniRecorderView.swift @@ -868,20 +868,10 @@ struct MiniRecorderView: View { } } - private func debugLog(_ message: String) { - let logPath = "/tmp/speaktype_debug.log" - let logEntry = "[\(Date())] \(message)\n" - if let data = logEntry.data(using: .utf8) { - if FileManager.default.fileExists(atPath: logPath) { - if let handle = FileHandle(forWritingAtPath: logPath) { - handle.seekToEndOfFile() - handle.write(data) - handle.closeFile() - } - } else { - FileManager.default.createFile(atPath: logPath, contents: data) - } - } + private func debugLog(_ message: @autoclosure () -> String) { + #if DEBUG + AppLogger.debug(message(), category: AppLogger.ui) + #endif } private func processRecording(url: URL) async { @@ -915,7 +905,7 @@ struct MiniRecorderView: View { await MainActor.run { statusMessage = "Transcribing..." } } let text = try await transcription.transcribe(audioFile: url, language: transcriptionLanguage) - debugLog("Transcription result: \(text.prefix(50))...") + debugLog("Transcription completed") guard !text.isEmpty else { debugLog("Empty text, cancelling") diff --git a/speaktypeTests/AudioRecordingServiceTests.swift b/speaktypeTests/AudioRecordingServiceTests.swift index b00607b..3d8b8b6 100644 --- a/speaktypeTests/AudioRecordingServiceTests.swift +++ b/speaktypeTests/AudioRecordingServiceTests.swift @@ -23,7 +23,24 @@ final class AudioRecordingServiceTests: XCTestCase { let url = await service.stopRecording() XCTAssertNil(url, "Should return nil url when not recording") } + + func testRecorderSourceDoesNotCreateBackgroundChunkFiles() throws { + let source = try repositorySourceFile("speaktype/Services/AudioRecordingService.swift") + + XCTAssertFalse(source.contains("getChunksDirectory")) + XCTAssertFalse(source.contains("chunkPublisher")) + XCTAssertFalse(source.contains("chunkAssetWriter")) + XCTAssertFalse(source.contains("SpeakType\").appendingPathComponent(\"Chunks")) + } // Note: Testing startRecording requires AVFoundation mocking or integration tests // due to hardware dependencies. + + private func repositorySourceFile(_ relativePath: String) throws -> String { + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let url = repoRoot.appendingPathComponent(relativePath) + return try String(contentsOf: url, encoding: .utf8) + } } diff --git a/speaktypeTests/ClipboardServiceTests.swift b/speaktypeTests/ClipboardServiceTests.swift index 4708fe5..3ae81b7 100644 --- a/speaktypeTests/ClipboardServiceTests.swift +++ b/speaktypeTests/ClipboardServiceTests.swift @@ -38,7 +38,22 @@ final class ClipboardServiceTests: XCTestCase { ClipboardService.shared.restore(snapshot, ifCurrentStringMatches: "Dictated text") XCTAssertEqual(pasteboard.string(forType: .string), "User copied something else") } + + func testClipboardVerificationDoesNotLogCopiedText() throws { + let source = try repositorySourceFile("speaktype/Services/ClipboardService.swift") + + XCTAssertFalse(source.contains("check.prefix")) + XCTAssertFalse(source.contains("Clipboard Write Verified: '")) + } // Testing paste() is difficult in unit tests as it requires active application focus and AX permissions. // We primarily verify the write operation here. + + private func repositorySourceFile(_ relativePath: String) throws -> String { + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let url = repoRoot.appendingPathComponent(relativePath) + return try String(contentsOf: url, encoding: .utf8) + } } diff --git a/speaktypeTests/HistoryServiceTests.swift b/speaktypeTests/HistoryServiceTests.swift index ff0fa6e..6b09035 100644 --- a/speaktypeTests/HistoryServiceTests.swift +++ b/speaktypeTests/HistoryServiceTests.swift @@ -81,6 +81,37 @@ final class HistoryServiceTests: XCTestCase { XCTAssertTrue(service.items.isEmpty) } + func testClearAllRemovesAudioFilesWhenPresent() throws { + let firstAudioURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("m4a") + let secondAudioURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("m4a") + try Data("first-audio".utf8).write(to: firstAudioURL) + try Data("second-audio".utf8).write(to: secondAudioURL) + + service.addItem( + transcript: "First item with audio", + duration: 1.0, + audioFileURL: firstAudioURL + ) + service.addItem( + transcript: "Second item with audio", + duration: 2.0, + audioFileURL: secondAudioURL + ) + + XCTAssertTrue(FileManager.default.fileExists(atPath: firstAudioURL.path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: secondAudioURL.path)) + + service.clearAll() + + XCTAssertTrue(service.items.isEmpty) + XCTAssertFalse(FileManager.default.fileExists(atPath: firstAudioURL.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: secondAudioURL.path)) + } + func testClearAllPreservesStatsHistory() { service.addItem(transcript: "One short note", duration: 10.0) service.addItem(transcript: "Another slightly longer note", duration: 20.0) diff --git a/speaktypeTests/ModelDownloadServiceTests.swift b/speaktypeTests/ModelDownloadServiceTests.swift index e10d60f..cfbdfc3 100644 --- a/speaktypeTests/ModelDownloadServiceTests.swift +++ b/speaktypeTests/ModelDownloadServiceTests.swift @@ -27,6 +27,12 @@ final class ModelDownloadServiceTests: XCTestCase { XCTAssertNotNil(service.isDownloading) } + func testUnknownModelVariantsAreNotRoutedToAnEngine() { + XCTAssertNil(AIModel.model(for: "../../outside-model")) + XCTAssertNil(AIModel.engineKind(for: "../../outside-model")) + XCTAssertNil(AIModel.expectedSize(for: "../../outside-model")) + } + func testCandidatePathsStayWithinRepoOwnedRoots() throws { // Simulate the two repo-owned WhisperKit model roots (current App Support + // legacy Documents), matching ModelStorage's `.../models/argmaxinc/whisperkit-coreml`. diff --git a/speaktypeTests/UpdateServiceSecurityTests.swift b/speaktypeTests/UpdateServiceSecurityTests.swift index d0d74bf..a130efa 100644 --- a/speaktypeTests/UpdateServiceSecurityTests.swift +++ b/speaktypeTests/UpdateServiceSecurityTests.swift @@ -66,4 +66,64 @@ final class UpdateServiceSecurityTests: XCTestCase { XCTAssertEqual(error as? UpdateError, .untrustedDeveloper) } } + + func testValidateCandidateVersionAcceptsMatchingVersionWithoutBuildBinding() { + XCTAssertNoThrow( + try UpdateService.validateCandidateVersion( + candidateVersion: "1.2.3", + candidateBuild: "26", + expectedVersion: "1.2.3", + expectedBuild: nil + ) + ) + } + + func testValidateCandidateVersionRejectsOlderSignedCandidate() { + XCTAssertThrowsError( + try UpdateService.validateCandidateVersion( + candidateVersion: "1.2.2", + candidateBuild: "25", + expectedVersion: "1.2.3", + expectedBuild: nil + ) + ) { error in + guard case UpdateError.invalidCandidateApp = error else { + return XCTFail("Expected invalidCandidateApp, got \(error)") + } + } + } + + func testValidateCandidateVersionRejectsMismatchedBuildWhenExpected() { + XCTAssertThrowsError( + try UpdateService.validateCandidateVersion( + candidateVersion: "1.2.3", + candidateBuild: "25", + expectedVersion: "1.2.3", + expectedBuild: "26" + ) + ) { error in + guard case UpdateError.invalidCandidateApp = error else { + return XCTFail("Expected invalidCandidateApp, got \(error)") + } + } + } + + func testNormalizedExpectedBuildIgnoresPlaceholderBuild() { + XCTAssertNil(UpdateService.normalizedExpectedBuild("0")) + XCTAssertNil(UpdateService.normalizedExpectedBuild(" ")) + XCTAssertEqual(UpdateService.normalizedExpectedBuild("26"), "26") + } + + func testRelaunchShellArgumentsKeepBundlePathOutOfShellSource() { + let bundlePath = "/Applications/SpeakType $(touch /tmp/pwned).app" + let arguments = UpdateService.relaunchShellArguments(pid: 1234, bundlePath: bundlePath) + + XCTAssertEqual(arguments.count, 5) + XCTAssertEqual(arguments[0], "-c") + XCTAssertFalse(arguments[1].contains(bundlePath)) + XCTAssertFalse(arguments[1].contains("$(touch")) + XCTAssertTrue(arguments[1].contains("\"$2\"")) + XCTAssertEqual(arguments[3], "1234") + XCTAssertEqual(arguments[4], bundlePath) + } } diff --git a/speaktypeTests/WhisperServiceTests.swift b/speaktypeTests/WhisperServiceTests.swift index 0bc2e77..14484ba 100644 --- a/speaktypeTests/WhisperServiceTests.swift +++ b/speaktypeTests/WhisperServiceTests.swift @@ -31,6 +31,19 @@ final class WhisperServiceTests: XCTestCase { XCTAssertTrue(service.isTranscribing) } + func testLoadModelRejectsUnknownVariantBeforePathResolution() async { + guard let service = service else { return XCTFail("Service should be initialized") } + + do { + try await service.loadModel(variant: "../../outside-model") + XCTFail("Expected unknown model variant to be rejected") + } catch WhisperService.TranscriptionError.unsupportedModelVariant { + // Expected. + } catch { + XCTFail("Expected unsupportedModelVariant, got \(error)") + } + } + func testNormalizedTranscriptionRemovesBlankAudioPlaceholders() { let normalized = WhisperService.normalizedTranscription( from: " [BLANK_AUDIO] hello <|nospeech|> [SILENCE] " @@ -54,4 +67,25 @@ final class WhisperServiceTests: XCTestCase { XCTAssertEqual(normalized, "") } + + func testTranscriptionServicesDoNotLogTranscriptPrefixes() throws { + let whisperSource = try repositorySourceFile("speaktype/Services/WhisperService.swift") + let miniRecorderSource = try repositorySourceFile( + "speaktype/Views/Overlays/MiniRecorderView.swift") + + XCTAssertFalse(whisperSource.contains("text.prefix")) + XCTAssertFalse(whisperSource.contains("Transcription complete:")) + XCTAssertFalse(whisperSource.contains("Chunk done:")) + XCTAssertFalse(miniRecorderSource.contains("/tmp/speaktype_debug.log")) + XCTAssertFalse(miniRecorderSource.contains("text.prefix")) + XCTAssertFalse(miniRecorderSource.contains("Transcription result")) + } + + private func repositorySourceFile(_ relativePath: String) throws -> String { + let repoRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let url = repoRoot.appendingPathComponent(relativePath) + return try String(contentsOf: url, encoding: .utf8) + } } From c662239b2db9da00a565b4e3e5225fff18072915 Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 17:42:16 +0700 Subject: [PATCH 02/42] Fix SwiftLint Makefile detection --- Makefile | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 947cb12..91a9076 100644 --- a/Makefile +++ b/Makefile @@ -106,7 +106,11 @@ test-ui: # Run SwiftLint lint: @echo "Running SwiftLint..." - @which swiftlint > /dev/null && swiftlint || echo "⚠️ SwiftLint not installed" + @if ! command -v swiftlint > /dev/null; then \ + echo "⚠️ SwiftLint not installed. Install with: brew install swiftlint"; \ + exit 1; \ + fi + swiftlint # Reproduce the GitHub Actions CI checks locally (build + unit tests + lint) ci: @@ -114,12 +118,20 @@ ci: xcodebuild -scheme speaktype -configuration Debug build CODE_SIGNING_ALLOWED=NO xcodebuild test -scheme speaktype -destination 'platform=macOS' -only-testing:speaktypeTests CODE_SIGNING_ALLOWED=NO @echo "--- SwiftLint (advisory) ---" - @which swiftlint > /dev/null && swiftlint || echo "⚠️ SwiftLint not installed" + @if command -v swiftlint > /dev/null; then \ + swiftlint || echo "⚠️ SwiftLint reported violations"; \ + else \ + echo "⚠️ SwiftLint not installed. Install with: brew install swiftlint"; \ + fi # Auto-fix SwiftLint issues format: @echo "Formatting code with SwiftLint..." - @which swiftlint > /dev/null && swiftlint --fix || echo "⚠️ SwiftLint not installed" + @if ! command -v swiftlint > /dev/null; then \ + echo "⚠️ SwiftLint not installed. Install with: brew install swiftlint"; \ + exit 1; \ + fi + swiftlint --fix # Clean build artifacts clean: From 5c63a0f40eeea69b8c2df6ae724a8173b3f41a3f Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 17:53:03 +0700 Subject: [PATCH 03/42] Fix updater defaults and feedback --- speaktype/App/AppDelegate.swift | 32 +++++++++++---- speaktype/Models/AppVersion.swift | 9 ++++- speaktype/Services/UpdateService.swift | 40 ++++++++++++++----- speaktype/Views/Components/UpdateSheet.swift | 4 +- .../Views/Screens/Settings/SettingsView.swift | 25 +++++++++++- .../UpdateServiceSecurityTests.swift | 25 ++++++++++++ 6 files changed, 114 insertions(+), 21 deletions(-) diff --git a/speaktype/App/AppDelegate.swift b/speaktype/App/AppDelegate.swift index fe76309..019d791 100644 --- a/speaktype/App/AppDelegate.swift +++ b/speaktype/App/AppDelegate.swift @@ -14,8 +14,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var lastHandledHotkeyPressedState = false private var globalKeyDownMonitor: Any? private var localKeyDownMonitor: Any? + private let updateCheckScheduler = NSBackgroundActivityScheduler( + identifier: "com.2048labs.speaktype.update-check") func applicationDidFinishLaunching(_ notification: Notification) { + UpdateService.registerDefaults() + miniRecorderController = MiniRecorderWindowController() // Show the always-present resting pill so the recorder lives on screen. miniRecorderController?.showIdleRecorder() @@ -27,6 +31,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Setup dynamic hotkey monitoring based on user selection setupHotkeyMonitoring() + schedulePeriodicUpdateChecks() checkForUpdatesOnLaunch() UpdateService.shared.showUpdateWindowPublisher @@ -353,18 +358,31 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Update Checking private func checkForUpdatesOnLaunch() { - let updateService = UpdateService.shared - let autoUpdate = UserDefaults.standard.bool(forKey: "autoUpdate") - guard autoUpdate && updateService.shouldCheckForUpdates() else { return } + Task { await performUpdateCheckIfNeeded() } + } - Task { - await updateService.checkForUpdates(silent: true) - if updateService.availableUpdate != nil && updateService.shouldShowReminder() { - await MainActor.run { self.showUpdateWindow() } + private func schedulePeriodicUpdateChecks() { + updateCheckScheduler.repeats = true + updateCheckScheduler.interval = 24 * 60 * 60 + updateCheckScheduler.tolerance = 60 * 60 + updateCheckScheduler.schedule { [weak self] completion in + Task { + await self?.performUpdateCheckIfNeeded() + completion(.finished) } } } + private func performUpdateCheckIfNeeded() async { + let updateService = UpdateService.shared + guard updateService.isAutoUpdateEnabled && updateService.shouldCheckForUpdates() else { return } + + await updateService.checkForUpdates(silent: true) + if updateService.availableUpdate != nil && updateService.shouldShowReminder() { + await MainActor.run { self.showUpdateWindow() } + } + } + private func showUpdateWindow() { guard let update = UpdateService.shared.availableUpdate else { return } diff --git a/speaktype/Models/AppVersion.swift b/speaktype/Models/AppVersion.swift index ffebd90..28fe146 100644 --- a/speaktype/Models/AppVersion.swift +++ b/speaktype/Models/AppVersion.swift @@ -28,7 +28,7 @@ struct AppVersion: Codable, Equatable { extension AppVersion { init(from release: GitHubRelease) { // Remove 'v' prefix if present (e.g. "v1.0.1" -> "1.0.1") - let cleanVersion = release.tagName.replacingOccurrences(of: "v", with: "") + let cleanVersion = Self.normalizedReleaseVersion(from: release.tagName) self.version = cleanVersion self.buildNumber = "0" @@ -44,6 +44,13 @@ extension AppVersion { let formatter = ISO8601DateFormatter() self.releaseDate = formatter.date(from: release.publishedAt) ?? Date() } + + static func normalizedReleaseVersion(from tagName: String) -> String { + guard let first = tagName.first, first == "v" || first == "V" else { + return tagName + } + return String(tagName.dropFirst()) + } } /// A single asset attached to a GitHub release diff --git a/speaktype/Services/UpdateService.swift b/speaktype/Services/UpdateService.swift index 8dc7ec4..34e778c 100644 --- a/speaktype/Services/UpdateService.swift +++ b/speaktype/Services/UpdateService.swift @@ -8,10 +8,12 @@ class UpdateService: NSObject, ObservableObject { static let shared = UpdateService() static let trustedUpdateBundleIdentifier = "com.2048labs.speaktype" static let trustedUpdateTeamIdentifier = "PCV4UMSRZX" + static let autoUpdateDefaultsKey = "autoUpdate" @Published var availableUpdate: AppVersion? @Published var isCheckingForUpdates = false @Published var lastCheckDate: Date? + @Published var lastCheckError: String? // Install progress state @Published var isInstalling = false @@ -26,7 +28,6 @@ class UpdateService: NSObject, ObservableObject { // User Defaults keys private let lastCheckDateKey = "lastUpdateCheckDate" private let skippedVersionKey = "skippedVersion" - private let autoUpdateKey = "autoUpdate" private let lastReminderDateKey = "lastUpdateReminderDate" private var activeDownloadSession: URLSession? @@ -41,11 +42,18 @@ class UpdateService: NSObject, ObservableObject { // MARK: - Update Checking + static func registerDefaults(in defaults: UserDefaults = .standard) { + defaults.register(defaults: [autoUpdateDefaultsKey: true]) + } + /// Check for updates from server func checkForUpdates(silent: Bool = false) async { guard !isCheckingForUpdates else { return } - await MainActor.run { isCheckingForUpdates = true } + await MainActor.run { + isCheckingForUpdates = true + lastCheckError = nil + } do { let url = URL( @@ -73,7 +81,10 @@ class UpdateService: NSObject, ObservableObject { } } catch { print("Failed to check for updates: \(error)") - await MainActor.run { self.isCheckingForUpdates = false } + await MainActor.run { + self.lastCheckError = error.localizedDescription + self.isCheckingForUpdates = false + } } } @@ -125,8 +136,8 @@ class UpdateService: NSObject, ObservableObject { // MARK: - Auto Update var isAutoUpdateEnabled: Bool { - get { UserDefaults.standard.bool(forKey: autoUpdateKey) } - set { UserDefaults.standard.set(newValue, forKey: autoUpdateKey) } + get { UserDefaults.standard.bool(forKey: Self.autoUpdateDefaultsKey) } + set { UserDefaults.standard.set(newValue, forKey: Self.autoUpdateDefaultsKey) } } // MARK: - Update Installation @@ -353,13 +364,22 @@ class UpdateService: NSObject, ObservableObject { let runningPath = Bundle.main.bundlePath let destURL = URL(fileURLWithPath: runningPath) let fm = FileManager.default + let tempURL = destURL + .deletingLastPathComponent() + .appendingPathComponent(".\(destURL.lastPathComponent).updating-\(UUID().uuidString)") - // Remove old app - if fm.fileExists(atPath: destURL.path) { - try fm.removeItem(at: destURL) + do { + try fm.copyItem(at: sourceApp, to: tempURL) + + if fm.fileExists(atPath: destURL.path) { + _ = try fm.replaceItemAt(destURL, withItemAt: tempURL) + } else { + try fm.moveItem(at: tempURL, to: destURL) + } + } catch { + try? fm.removeItem(at: tempURL) + throw error } - // Copy new app - try fm.copyItem(at: sourceApp, to: destURL) } private func verifyGatekeeperAcceptance(of appURL: URL) throws { diff --git a/speaktype/Views/Components/UpdateSheet.swift b/speaktype/Views/Components/UpdateSheet.swift index 58e9f92..d3675c3 100644 --- a/speaktype/Views/Components/UpdateSheet.swift +++ b/speaktype/Views/Components/UpdateSheet.swift @@ -4,7 +4,7 @@ import SwiftUI struct UpdateSheet: View { @Environment(\.dismiss) var dismiss @ObservedObject var updateService = UpdateService.shared - @AppStorage("autoUpdate") private var autoUpdate = false + @AppStorage(UpdateService.autoUpdateDefaultsKey) private var autoUpdate = true let update: AppVersion let appName = "SpeakType" @@ -112,7 +112,7 @@ struct UpdateSheet: View { if !updateService.isInstalling { HStack(spacing: 8) { Toggle(isOn: $autoUpdate) { - Text("Automatically download and install updates in the future") + Text("Automatically check for updates in the future") .font(Typography.bodySmall) .foregroundStyle(.secondary) } diff --git a/speaktype/Views/Screens/Settings/SettingsView.swift b/speaktype/Views/Screens/Settings/SettingsView.swift index 0885567..bda33ff 100644 --- a/speaktype/Views/Screens/Settings/SettingsView.swift +++ b/speaktype/Views/Screens/Settings/SettingsView.swift @@ -86,7 +86,7 @@ struct SettingsTabButton: View { struct GeneralSettingsTab: View { @AppStorage("appTheme") private var appTheme: AppTheme = .system - @AppStorage("autoUpdate") private var autoUpdate = true + @AppStorage(UpdateService.autoUpdateDefaultsKey) private var autoUpdate = true @AppStorage("selectedHotkey") private var selectedHotkey: HotkeyOption = .fn @AppStorage("recordingMode") private var recordingMode: Int = 0 // 0: Hold to record, 1: Toggle @AppStorage("restoreClipboardAfterAutoPaste") private var restoreClipboardAfterAutoPaste = @@ -405,6 +405,8 @@ struct GeneralSettingsTab: View { } .buttonStyle(.plain) .disabled(updateService.isCheckingForUpdates) + + updateCheckStatus } } @@ -455,6 +457,26 @@ struct GeneralSettingsTab: View { return Self.whisperLanguages.first(where: { $0.code == code })?.name ?? code } + @ViewBuilder + private var updateCheckStatus: some View { + if let error = updateService.lastCheckError { + Text("Update check failed: \(error)") + .font(Typography.captionSmall) + .foregroundStyle(Color.accentError) + .frame(maxWidth: .infinity, alignment: .leading) + } else if updateService.availableUpdate != nil { + Text("An update is available.") + .font(Typography.captionSmall) + .foregroundStyle(Color.brandAccent) + .frame(maxWidth: .infinity, alignment: .leading) + } else if let lastCheckDate = updateService.lastCheckDate { + Text("You're up to date. Last checked \(lastCheckDate.formatted(date: .abbreviated, time: .shortened)).") + .font(Typography.captionSmall) + .foregroundStyle(Color.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + // All languages supported by Whisper, sorted alphabetically static let whisperLanguages: [(code: String, name: String)] = [ ("af", "Afrikaans"), ("sq", "Albanian"), ("am", "Amharic"), ("ar", "Arabic"), @@ -609,6 +631,7 @@ struct PermissionsSettingsTab: View { NSWorkspace.shared.open(url) } } + } // MARK: - Supporting Components diff --git a/speaktypeTests/UpdateServiceSecurityTests.swift b/speaktypeTests/UpdateServiceSecurityTests.swift index a130efa..97554ac 100644 --- a/speaktypeTests/UpdateServiceSecurityTests.swift +++ b/speaktypeTests/UpdateServiceSecurityTests.swift @@ -4,6 +4,31 @@ import XCTest final class UpdateServiceSecurityTests: XCTestCase { + func testRegisterDefaultsEnablesAutoUpdateForFreshDefaults() { + let suiteName = "UpdateServiceSecurityTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + return XCTFail("Could not create isolated defaults suite") + } + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(false, forKey: UpdateService.autoUpdateDefaultsKey) + + UpdateService.registerDefaults(in: defaults) + + XCTAssertFalse(defaults.bool(forKey: UpdateService.autoUpdateDefaultsKey)) + + defaults.removeObject(forKey: UpdateService.autoUpdateDefaultsKey) + UpdateService.registerDefaults(in: defaults) + + XCTAssertTrue(defaults.bool(forKey: UpdateService.autoUpdateDefaultsKey)) + } + + func testReleaseVersionNormalizationOnlyStripsLeadingV() { + XCTAssertEqual(AppVersion.normalizedReleaseVersion(from: "v1.2.3-dev"), "1.2.3-dev") + XCTAssertEqual(AppVersion.normalizedReleaseVersion(from: "V1.2.3"), "1.2.3") + XCTAssertEqual(AppVersion.normalizedReleaseVersion(from: "1.2.3-dev"), "1.2.3-dev") + } + func testTrustedUpdateRequirementStringPinsBundleAndTeam() { let requirement = UpdateService.trustedUpdateRequirementString( bundleIdentifier: "com.example.app", From 12e52f29e0afd4a3426bd9c4a8396bd2b062a956 Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 17:53:09 +0700 Subject: [PATCH 04/42] Improve model download and selection UX --- speaktype/Services/ModelDownloadService.swift | 12 ++++-- .../Transcription/ParakeetEngine.swift | 14 +++++++ .../Transcription/TranscriptionManager.swift | 12 ++++++ speaktype/Services/WhisperService.swift | 18 +++++++++ speaktype/Views/Components/ModelRow.swift | 20 ++++++++-- .../Views/Screens/Settings/AIModelsView.swift | 40 ++++++++++++++++++- 6 files changed, 108 insertions(+), 8 deletions(-) diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift index 0780783..eaf8a5a 100644 --- a/speaktype/Services/ModelDownloadService.swift +++ b/speaktype/Services/ModelDownloadService.swift @@ -164,8 +164,10 @@ class ModelDownloadService: ObservableObject { } await MainActor.run { - // Clear all previous progress - self.downloadProgress.removeAll() + // Keep live download rows stable while refreshing disk state. + self.downloadProgress = self.downloadProgress.filter { + self.isDownloading[$0.key] == true + } // Only mark models that actually exist for variant in foundModels { @@ -346,7 +348,8 @@ class ModelDownloadService: ObservableObject { DispatchQueue.main.async { self.isDownloading[variant] = false self.downloadProgress[variant] = 0.0 - self.downloadError[variant] = "Error: \(error.localizedDescription)\n\nTry clicking the trash icon to manually clean cache." + self.downloadError[variant] = + "Download failed: \(error.localizedDescription). Try downloading again." self.activeTasks[variant] = nil } } @@ -356,7 +359,8 @@ class ModelDownloadService: ObservableObject { DispatchQueue.main.async { self.isDownloading[variant] = false self.downloadProgress[variant] = 0.0 - self.downloadError[variant] = error.localizedDescription + "\n\n(Try Trash icon to clean cache)" + self.downloadError[variant] = + "Download failed: \(error.localizedDescription). Try downloading again." self.activeTasks[variant] = nil } } diff --git a/speaktype/Services/Transcription/ParakeetEngine.swift b/speaktype/Services/Transcription/ParakeetEngine.swift index b462b60..7a7f400 100644 --- a/speaktype/Services/Transcription/ParakeetEngine.swift +++ b/speaktype/Services/Transcription/ParakeetEngine.swift @@ -78,6 +78,20 @@ class ParakeetEngine: SpeechToTextEngine { loadingStage = "" } + func unloadModelIfCurrent(variant: String) async { + guard currentModelVariant == variant else { return } + + if let manager { + await manager.cleanup() + } + manager = nil + currentModelVariant = "" + isInitialized = false + isLoading = false + isTranscribing = false + loadingStage = "" + } + func transcribe(audioFile: URL, language: String) async throws -> String { guard let manager else { throw ASRError.notInitialized } diff --git a/speaktype/Services/Transcription/TranscriptionManager.swift b/speaktype/Services/Transcription/TranscriptionManager.swift index fa908f2..bdfb1e2 100644 --- a/speaktype/Services/Transcription/TranscriptionManager.swift +++ b/speaktype/Services/Transcription/TranscriptionManager.swift @@ -90,6 +90,18 @@ class TranscriptionManager { activeKind = kind } + /// Release the active in-memory model if its downloaded files were removed. + func unloadModelIfCurrent(variant: String) async { + let wasActive = currentModelVariant == variant + + await whisper.unloadModelIfCurrent(variant: variant) + await parakeet.unloadModelIfCurrent(variant: variant) + + if wasActive { + activeKind = .whisper + } + } + /// Transcribe an audio file with the currently active engine. func transcribe(audioFile: URL, language: String = "auto") async throws -> String { guard let kind = AIModel.engineKind(for: currentModelVariant) else { diff --git a/speaktype/Services/WhisperService.swift b/speaktype/Services/WhisperService.swift index dde6ea4..43471f7 100644 --- a/speaktype/Services/WhisperService.swift +++ b/speaktype/Services/WhisperService.swift @@ -157,6 +157,24 @@ class WhisperService { try await task.value } + @MainActor + func unloadModelIfCurrent(variant: String) { + guard currentModelVariant == variant else { return } + + activeLoadTask?.cancel() + activeLoadTask = nil + activeLoadVariant = "" + activeLoadToken = nil + pipe = nil + currentModelVariant = "" + isInitialized = false + isLoading = false + isTranscribing = false + loadingStage = "" + loadingModelVariant = "" + loadingStartedAt = nil + } + @MainActor private func performModelLoad(model: AIModel) async throws { let variant = model.variant diff --git a/speaktype/Views/Components/ModelRow.swift b/speaktype/Views/Components/ModelRow.swift index 0c6c55c..65f142c 100644 --- a/speaktype/Views/Components/ModelRow.swift +++ b/speaktype/Views/Components/ModelRow.swift @@ -18,6 +18,7 @@ struct ModelRow: View { @State private var loadingTimer: Timer? @State private var isHovered = false @State private var appeared = false + @State private var showDeleteConfirmation = false // MARK: - Derived state @@ -25,6 +26,7 @@ struct ModelRow: View { var isDownloading: Bool { downloadService.isDownloading[model.variant] ?? false } var isDownloaded: Bool { progress >= 1.0 } var isActive: Bool { selectedModel == model.variant } + var downloadError: String? { downloadService.downloadError[model.variant] } // MARK: - Body @@ -45,6 +47,9 @@ struct ModelRow: View { if let loadError { note(icon: "xmark.circle.fill", text: loadError, tint: .accentError) } + if let downloadError { + note(icon: "xmark.circle.fill", text: downloadError, tint: .accentError) + } } Spacer(minLength: 8) @@ -73,6 +78,12 @@ struct ModelRow: View { .animation(.easeOut(duration: 0.16), value: isActive) .onHover { isHovered = $0 } .onAppear { withAnimation(.easeOut(duration: 0.5).delay(0.05)) { appeared = true } } + .alert("Delete \(model.name)?", isPresented: $showDeleteConfirmation) { + Button("Cancel", role: .cancel) { } + Button("Delete", role: .destructive) { deleteModel() } + } message: { + Text("This removes the downloaded model files. You can download the model again later.") + } } // MARK: - Header @@ -123,7 +134,7 @@ struct ModelRow: View { private func note(icon: String, text: String, tint: Color) -> some View { HStack(spacing: 5) { Image(systemName: icon).font(.system(size: 10)) - Text(text).font(Typography.ui(11)).lineLimit(2) + Text(text).font(Typography.ui(11)).lineLimit(3) } .foregroundStyle(tint) } @@ -145,7 +156,7 @@ struct ModelRow: View { } .buttonStyle(.plain).help("Set as default model") } - Button(action: deleteModel) { + Button(action: { showDeleteConfirmation = true }) { Image(systemName: "trash").font(.system(size: 13)) .foregroundStyle(Color.textMuted).padding(8) .background(Circle().fill(Color.textPrimary.opacity(isHovered ? 0.06 : 0))) @@ -211,7 +222,10 @@ struct ModelRow: View { private func deleteModel() { Task { _ = await downloadService.deleteModel(variant: model.variant) - if selectedModel == model.variant { selectedModel = ModelSelection.none } + await transcription.unloadModelIfCurrent(variant: model.variant) + await MainActor.run { + if selectedModel == model.variant { selectedModel = ModelSelection.none } + } } } diff --git a/speaktype/Views/Screens/Settings/AIModelsView.swift b/speaktype/Views/Screens/Settings/AIModelsView.swift index 5f87e1b..c89a315 100644 --- a/speaktype/Views/Screens/Settings/AIModelsView.swift +++ b/speaktype/Views/Screens/Settings/AIModelsView.swift @@ -5,6 +5,8 @@ struct AIModelsView: View { @StateObject private var downloadService = ModelDownloadService.shared @AppStorage(ModelSelection.defaultsKey) private var selectedModel: String = ModelSelection.none @AppStorage("modelUseCase") private var useCaseRaw: String = AIModel.UseCase.dictation.rawValue + @State private var isLoadingRecommendedModel = false + @State private var recommendedLoadError: String? /// Keeps the content from stretching edge-to-edge on a wide window, so the /// name and its action never sit at opposite ends of a huge empty band. @@ -144,6 +146,14 @@ struct AIModelsView: View { heroAction(rec: rec, downloaded: downloaded, active: active) .padding(.top, 20) + + if let recommendedLoadError { + Text(recommendedLoadError) + .font(Typography.ui(11)) + .foregroundStyle(Color.accentError) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 8) + } } // RIGHT β€” the performance panel, so the hero isn't half-empty. @@ -196,9 +206,17 @@ struct AIModelsView: View { Text("This is your default model").font(Typography.uiBold(13)) } .foregroundStyle(Color.brandAccent) + } else if downloaded && isLoadingRecommendedModel { + HStack(spacing: 9) { + Spinner(size: 13, lineWidth: 2, tint: Color.textSecondary) + Text("Loading model…").font(Typography.uiBold(13)) + } + .foregroundStyle(Color.textSecondary) + .padding(.horizontal, 16).padding(.vertical, 10) + .background(Capsule().fill(Color.textPrimary.opacity(0.08))) } else if downloaded { Button { - selectedModel = rec.variant + loadRecommendedModel(rec) } label: { ActionButton.label(title: "Use this model", icon: "arrow.right", style: .primary, large: true) @@ -215,6 +233,26 @@ struct AIModelsView: View { } } + private func loadRecommendedModel(_ model: AIModel) { + isLoadingRecommendedModel = true + recommendedLoadError = nil + + Task { + do { + try await TranscriptionManager.shared.loadModel(variant: model.variant) + await MainActor.run { + selectedModel = model.variant + isLoadingRecommendedModel = false + } + } catch { + await MainActor.run { + recommendedLoadError = error.localizedDescription + isLoadingRecommendedModel = false + } + } + } + } + // MARK: - List private var listSection: some View { From dcc14d3c7335590947a29965ce1206073d72c83a Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 17:53:13 +0700 Subject: [PATCH 05/42] Polish history and file transcription flow --- .../Services/AudioRecordingService.swift | 6 +- .../Views/Screens/History/HistoryView.swift | 11 ++- speaktype/Views/TranscribeAudioView.swift | 72 ++++++++++++++----- 3 files changed, 67 insertions(+), 22 deletions(-) diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index dfd0107..0021f1c 100644 --- a/speaktype/Services/AudioRecordingService.swift +++ b/speaktype/Services/AudioRecordingService.swift @@ -396,7 +396,7 @@ class AudioRecordingService: NSObject, ObservableObject { } } - private func getRecordingsDirectory() -> URL { + static func recordingsDirectory() -> URL { // Use Application Support instead of Documents for app-managed storage let appSupport = FileManager.default.urls( for: .applicationSupportDirectory, @@ -417,6 +417,10 @@ class AudioRecordingService: NSObject, ObservableObject { return recordingsDir } + private func getRecordingsDirectory() -> URL { + Self.recordingsDirectory() + } + private func scheduleIdleSessionStop(delay: TimeInterval = 8) { cancelIdleSessionStop() diff --git a/speaktype/Views/Screens/History/HistoryView.swift b/speaktype/Views/Screens/History/HistoryView.swift index b1fc0bb..2bede0d 100644 --- a/speaktype/Views/Screens/History/HistoryView.swift +++ b/speaktype/Views/Screens/History/HistoryView.swift @@ -7,7 +7,12 @@ struct HistoryView: View { @State private var itemPendingDeletion: HistoryItem? = nil @State private var expandedItemId: UUID? = nil @State private var showCopyToast = false - + @AppStorage("selectedHotkey") private var selectedHotkeyRaw = HotkeyOption.default.rawValue + + private var selectedHotkeyName: String { + HotkeyOption(rawValue: selectedHotkeyRaw)?.displayName ?? HotkeyOption.default.displayName + } + var body: some View { ScrollView { VStack(spacing: 24) { @@ -62,7 +67,7 @@ struct HistoryView: View { .font(Typography.displaySmall) .foregroundStyle(Color.textPrimary) - Text("Press ⌘+Shift+Space to start recording") + Text("Press \(selectedHotkeyName) to start recording") .font(Typography.bodyMedium) .foregroundStyle(Color.textSecondary) } @@ -71,7 +76,7 @@ struct HistoryView: View { .padding(.top, 100) } else { // History items as individual cards - VStack(spacing: 16) { + LazyVStack(spacing: 16) { ForEach(historyService.items) { item in HistoryCard( item: item, diff --git a/speaktype/Views/TranscribeAudioView.swift b/speaktype/Views/TranscribeAudioView.swift index 6fdf033..d157dcf 100644 --- a/speaktype/Views/TranscribeAudioView.swift +++ b/speaktype/Views/TranscribeAudioView.swift @@ -4,10 +4,12 @@ import CoreMedia import UniformTypeIdentifiers struct TranscribeAudioView: View { - @StateObject private var audioRecorder = AudioRecordingService() + @StateObject private var audioRecorder = AudioRecordingService.shared private var transcription: TranscriptionManager { TranscriptionManager.shared } + @AppStorage(ModelSelection.defaultsKey) private var selectedModel: String = ModelSelection.none @AppStorage("transcriptionLanguage") private var transcriptionLanguage: String = "auto" @State private var transcribedText: String = "" + @State private var transcriptionError: String? @State private var isTranscribing = false @State private var showFileImporter = false @@ -109,6 +111,21 @@ struct TranscribeAudioView: View { .padding(.horizontal, 24) // Transcription Result + if let transcriptionError { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.accentError) + Text(transcriptionError) + .font(Typography.bodySmall) + .foregroundStyle(Color.textPrimary) + Spacer() + } + .padding(12) + .background(Color.accentError.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .padding(.horizontal, 24) + } + if !transcribedText.isEmpty { VStack(alignment: .leading, spacing: 12) { Text("Transcription") @@ -167,12 +184,12 @@ struct TranscribeAudioView: View { handleFileSelection(url: url) } case .failure(let error): - print("File selection error: \(error.localizedDescription)") + transcriptionError = "File selection failed: \(error.localizedDescription)" } } .onAppear { Task { - if !transcription.isInitialized { + if !transcription.isInitialized && !transcription.currentModelVariant.isEmpty { try? await transcription.initialize() } } @@ -190,22 +207,18 @@ struct TranscribeAudioView: View { // Given WhisperKit might need file access, let's copy to a temp location to be safe and avoid scope issues. do { - let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent) - try? FileManager.default.removeItem(at: tempURL) // Clean up if exists - try FileManager.default.copyItem(at: url, to: tempURL) + let recordingURL = try copyToRecordingsDirectory(url) if didStartAccessing { url.stopAccessingSecurityScopedResource() } - startTranscription(url: tempURL) + startTranscription(url: recordingURL) } catch { - print("Error copying file: \(error)") + transcriptionError = "Could not import file: \(error.localizedDescription)" if didStartAccessing { url.stopAccessingSecurityScopedResource() } - // Fallback: try original URL if copy fails - startTranscription(url: url) } } @@ -216,18 +229,20 @@ struct TranscribeAudioView: View { provider.loadFileRepresentation(forTypeIdentifier: UTType.content.identifier) { url, error in if let url = url { - // LoadFileRepresentation gives us a temporary URL that might not persist. - // We should copy it immediately. - let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(url.lastPathComponent) do { - try? FileManager.default.removeItem(at: tempURL) - try FileManager.default.copyItem(at: url, to: tempURL) + let recordingURL = try copyToRecordingsDirectory(url) DispatchQueue.main.async { - startTranscription(url: tempURL) + startTranscription(url: recordingURL) } } catch { - print("Error copying dropped file: \(error)") + DispatchQueue.main.async { + transcriptionError = "Could not import dropped file: \(error.localizedDescription)" + } + } + } else if let error { + DispatchQueue.main.async { + transcriptionError = "Could not read dropped file: \(error.localizedDescription)" } } } @@ -239,17 +254,38 @@ struct TranscribeAudioView: View { private func startTranscription(url: URL) { Task { isTranscribing = true + transcriptionError = nil + transcribedText = "" do { + try await ensureSelectedModelIsLoaded() transcribedText = try await transcription.transcribe(audioFile: url, language: transcriptionLanguage) // Save to History let duration = try await getAudioDuration(url: url) HistoryService.shared.addItem(transcript: transcribedText, duration: duration, audioFileURL: url) } catch { - transcribedText = "Error: \(error.localizedDescription)" + transcriptionError = error.localizedDescription } isTranscribing = false } } + + private func ensureSelectedModelIsLoaded() async throws { + guard selectedModel != ModelSelection.none else { + throw WhisperService.TranscriptionError.notInitialized + } + + if !transcription.isInitialized || transcription.currentModelVariant != selectedModel { + try await transcription.loadModel(variant: selectedModel) + } + } + + private func copyToRecordingsDirectory(_ sourceURL: URL) throws -> URL { + let filename = sourceURL.lastPathComponent.isEmpty ? "imported-audio" : sourceURL.lastPathComponent + let destinationURL = AudioRecordingService.recordingsDirectory() + .appendingPathComponent("imported-\(UUID().uuidString)-\(filename)") + try FileManager.default.copyItem(at: sourceURL, to: destinationURL) + return destinationURL + } private func getAudioDuration(url: URL) async throws -> TimeInterval { // Async duration check using AVURLAsset From 9ebdd5d4fd61c59fec783ea9ea87bdf4dfcba6cc Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 17:59:53 +0700 Subject: [PATCH 06/42] Capture audio from the first buffer to stop dropping opening words Buffers delivered before the asset writer was wired up were silently discarded, so the session cold start (startRunning + a 300ms settle sleep) swallowed the first word(s) of every dictation. Retain early buffers on the audio queue and flush them once the writer is ready, create the writer before starting the capture session, and drop the settle sleep. Also compute the minimum-duration wait from recordingStartTime instead of parsing the filename. --- .../Services/AudioRecordingService.swift | 115 +++++++++++------- 1 file changed, 70 insertions(+), 45 deletions(-) diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index 0021f1c..7576b87 100644 --- a/speaktype/Services/AudioRecordingService.swift +++ b/speaktype/Services/AudioRecordingService.swift @@ -41,6 +41,13 @@ class AudioRecordingService: NSObject, ObservableObject { private var smoothedAudioLevel: Float = 0.0 private var smoothedAudioFrequency: Float = 0.0 + /// Buffers captured before the asset writer is ready (audioQueue-confined). + /// Without this, everything the user says between pressing the hotkey and + /// the writer being wired up β€” session cold start plus writer setup β€” was + /// silently dropped, cutting off the first word(s) of every dictation. + private var pendingSampleBuffers: [CMSampleBuffer] = [] + private static let maxPendingSampleBuffers = 600 + private let audioQueue = DispatchQueue(label: "com.speaktype.audioQueue") private func validatedAudioFileURL( @@ -235,39 +242,21 @@ class AudioRecordingService: NSObject, ObservableObject { shouldDiscardCurrentRecordingOutput = false liveWaveSamples = [] resetMainWriterState() + // Clear stale pending buffers on the audio queue before new samples can + // accumulate (the delegate only buffers while isRecording is true, and + // audioQueue is serial, so this runs first). + audioQueue.async { + self.cancelIdleSessionStop() + self.pendingSampleBuffers.removeAll() + } isRecording = true recordingStartTime = Date() - // Hop onto audioQueue: idleSessionStopWorkItem is only ever touched there, - // and startRecording runs on the main thread. - audioQueue.async { self.cancelIdleSessionStop() } - // 2. Wrap setup in a Task so stopRecording can wait for it + // 2. Wrap setup in a Task so stopRecording can wait for it. + // The writer is wired up BEFORE the capture session starts, and any + // buffers that beat it (prewarmed session already running) are retained + // in pendingSampleBuffers β€” so no audio is lost on either path. setupTask = Task { @MainActor in - // Ensure the capture session is running before setting up the writer. - let didColdStart = await withCheckedContinuation { - (continuation: CheckedContinuation) in - audioQueue.async { - if self.captureSession?.isRunning != true { - print("🎀 Starting capture session...") - self.captureSession?.startRunning() - continuation.resume(returning: true) - } else { - continuation.resume(returning: false) - } - } - } - // Let the session settle before wiring the writer β€” but do NOT block the - // audio queue to do it. The capture delegate is delivered on `audioQueue`; - // the old code slept 0.3s ON that queue, so no buffers could arrive while - // it slept and the live waveform stayed empty for the first ~0.3s of every - // recording (text still worked β€” the writer just starts on the first - // buffer). Awaiting off-queue lets buffers, and the meter, flow while we - // wait, so the waveform is alive from the first frame. - if didColdStart { - try? await Task.sleep(nanoseconds: 300_000_000) - print("🎀 Capture session started") - } - let url = getRecordingsDirectory().appendingPathComponent( "recording-\(Date().timeIntervalSince1970).wav") currentFileURL = url @@ -309,6 +298,14 @@ class AudioRecordingService: NSObject, ObservableObject { audioQueue.async { self.captureSession?.stopRunning() } + return + } + + audioQueue.async { + if self.captureSession?.isRunning != true { + print("🎀 Starting capture session...") + self.captureSession?.startRunning() + } } } } @@ -321,11 +318,8 @@ class AudioRecordingService: NSObject, ObservableObject { shouldDiscardCurrentRecordingOutput = discardOutput // Ensure minimum recording duration to prevent empty/corrupted WAV files - if let startTime = currentFileURL?.path.components(separatedBy: "-").last? - .replacingOccurrences(of: ".wav", with: ""), - let startTimestamp = Double(startTime) - { - let duration = Date().timeIntervalSince1970 - startTimestamp + if let startTime = recordingStartTime { + let duration = Date().timeIntervalSince(startTime) if duration < 0.5 { try? await Task.sleep(nanoseconds: UInt64((0.5 - duration) * 1_000_000_000)) } @@ -348,6 +342,23 @@ class AudioRecordingService: NSObject, ObservableObject { let writer = self.assetWriter let writerInput = self.assetWriterInput + + // Flush any buffers the writer never got to see (e.g. a very + // short recording stopped before the first delegate callback + // after the writer became ready). + if let writer, let writerInput, writer.status == .writing, + !self.pendingSampleBuffers.isEmpty, !discardOutput + { + if !self.isSessionStarted, let first = self.pendingSampleBuffers.first { + writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(first)) + self.isSessionStarted = true + } + for buffered in self.pendingSampleBuffers + where writerInput.isReadyForMoreMediaData { + writerInput.append(buffered) + } + } + self.pendingSampleBuffers.removeAll() self.resetMainWriterState() if let writer { @@ -455,23 +466,37 @@ extension AudioRecordingService: AVCaptureAudioDataOutputSampleBufferDelegate { // Don't append if we're stopping - prevents race condition crash guard !isStopping else { return } - guard let writer = assetWriter, let input = assetWriterInput else { return } - - let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + guard let writer = assetWriter, let input = assetWriterInput, + writer.status == .writing + else { + // Writer not wired up yet β€” retain the audio so the start of the + // dictation isn't lost; flushed below once the writer is ready. + pendingSampleBuffers.append(sampleBuffer) + if pendingSampleBuffers.count > Self.maxPendingSampleBuffers { + pendingSampleBuffers.removeFirst( + pendingSampleBuffers.count - Self.maxPendingSampleBuffers) + } + return + } // --- Main writer (full recording) --- - if writer.status == .writing { - if !isSessionStarted { - writer.startSession(atSourceTime: pts) - isSessionStarted = true - } + if !isSessionStarted { + let firstBuffer = pendingSampleBuffers.first ?? sampleBuffer + writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(firstBuffer)) + isSessionStarted = true + } - if input.isReadyForMoreMediaData { - guard !isStopping else { return } - input.append(sampleBuffer) + if !pendingSampleBuffers.isEmpty { + for buffered in pendingSampleBuffers where input.isReadyForMoreMediaData { + input.append(buffered) } + pendingSampleBuffers.removeAll() } + if input.isReadyForMoreMediaData { + guard !isStopping else { return } + input.append(sampleBuffer) + } } private func processAudioLevel(from sampleBuffer: CMSampleBuffer) { From 52da9bf7bf502c3d3bc69c4e3c8ef596ab9ccdbb Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:01:40 +0700 Subject: [PATCH 07/42] Add model-load watchdog so a hung load can't wedge the recorder The 'watchdog' comment in performModelLoad had no implementation and TranscriptionError.loadingTimeout was never thrown, so a hung WhisperKit init (corrupt model dir, low-RAM swap spiral) left the pill on 'Warming up model...' forever. Race the load against a bounded timeout (180s, 360s below recommended RAM) and discard a late orphan result instead of resurrecting stale state. Also let unloadModelIfCurrent match a variant that is still mid-load, so deleting a model during warm-up doesn't keep loading deleted files. --- speaktype/Services/WhisperService.swift | 65 +++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/speaktype/Services/WhisperService.swift b/speaktype/Services/WhisperService.swift index 43471f7..7f7f3f7 100644 --- a/speaktype/Services/WhisperService.swift +++ b/speaktype/Services/WhisperService.swift @@ -159,7 +159,12 @@ class WhisperService { @MainActor func unloadModelIfCurrent(variant: String) { - guard currentModelVariant == variant else { return } + // Also match a variant that is still mid-load (not yet "current") so + // deleting a model during warm-up doesn't leave its load running + // against deleted files. + guard currentModelVariant == variant || activeLoadVariant == variant + || loadingModelVariant == variant + else { return } activeLoadTask?.cancel() activeLoadTask = nil @@ -231,10 +236,19 @@ class WhisperService { loadingStage = "Loading model into memory..." - // Start a watchdog timer that will flag a timeout let loadStart = Date() - pipe = try await WhisperKit(config) + // Watchdog: WhisperKit(config) can hang indefinitely (corrupt model + // dir, low-RAM swap spiral). Race it against a bounded timeout so + // the UI never sits on "Warming up model..." forever. The orphaned + // load can't be killed, but its late result is discarded below. + let timeout = Self.loadTimeout(ramGB: ramGB, minimumRAMGB: model.minimumRAMGB) + let loadedPipe = try await Self.loadPipelineWithTimeout(config: config, timeout: timeout) + + // A concurrent unload may have reset state while we were loading; + // don't resurrect it with a stale pipeline. + guard loadingModelVariant == variant else { return } + pipe = loadedPipe let loadDuration = Date().timeIntervalSince(loadStart) lastLoadDuration = loadDuration @@ -258,6 +272,51 @@ class WhisperService { } } + /// Generous ceiling: warm loads take seconds and documented cold loads + /// 30–60s, so only a genuinely wedged load hits this. Doubled when the + /// device is below the model's recommended RAM (swapping is slow, not stuck). + static func loadTimeout(ramGB: Int, minimumRAMGB: Int) -> TimeInterval { + ramGB < minimumRAMGB ? 360 : 180 + } + + /// Awaits WhisperKit init, but never longer than `timeout`. A task group + /// won't do here: it awaits all children before returning, so a hung child + /// would defeat the watchdog. If the timeout wins, the orphaned load keeps + /// running (WhisperKit init is not cancellable) and its result is dropped. + private static func loadPipelineWithTimeout( + config: WhisperKitConfig, timeout: TimeInterval + ) async throws -> WhisperKit { + final class ResumeOnce: @unchecked Sendable { + private let lock = NSLock() + private var resumed = false + func claim() -> Bool { + lock.lock() + defer { lock.unlock() } + if resumed { return false } + resumed = true + return true + } + } + + let once = ResumeOnce() + return try await withCheckedThrowingContinuation { continuation in + Task { + do { + let loaded = try await WhisperKit(config) + if once.claim() { continuation.resume(returning: loaded) } + } catch { + if once.claim() { continuation.resume(throwing: error) } + } + } + Task { + try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + if once.claim() { + continuation.resume(throwing: TranscriptionError.loadingTimeout) + } + } + } + } + private func modelDisplayName(for variant: String) -> String { AIModel.availableModels.first(where: { $0.variant == variant })?.name ?? variant } From 2836b1d846b316a61d96443424e3f9e7f6e80efe Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:02:46 +0700 Subject: [PATCH 08/42] Paste as soon as the target app has focus instead of a fixed 500ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commit path slept an unconditional 500ms 'waiting for focus' even though the recorder panel is non-activating and focus usually never left the target app β€” every dictation felt half a second slower than the model. Now the wait only happens when the target isn't frontmost, polling up to 500ms. Fall back to the current frontmost app when no target was captured, and widen the clipboard-restore window to 800ms so slow apps (Electron, remote desktops) don't paste the old clipboard β€” safe because restore is skipped unless the pasteboard still holds the transcript. --- .../MiniRecorderWindowController.swift | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/speaktype/Controllers/MiniRecorderWindowController.swift b/speaktype/Controllers/MiniRecorderWindowController.swift index 5528388..2a74562 100644 --- a/speaktype/Controllers/MiniRecorderWindowController.swift +++ b/speaktype/Controllers/MiniRecorderWindowController.swift @@ -176,24 +176,38 @@ class MiniRecorderWindowController: NSObject { return } - // 4. Re-activate the target app - if let app = self.lastActiveApp { - _ = await MainActor.run { - app.activate() + // 4. Re-activate the target app and wait until it is actually + // frontmost. The panel is non-activating, so focus usually never + // left the target β€” in that common case this adds zero delay + // (previously every paste ate an unconditional 500ms here). + let target = self.lastActiveApp ?? NSWorkspace.shared.frontmostApplication + if let app = target, + NSWorkspace.shared.frontmostApplication?.processIdentifier + != app.processIdentifier + { + _ = await MainActor.run { app.activate() } + for _ in 0..<25 { + if NSWorkspace.shared.frontmostApplication?.processIdentifier + == app.processIdentifier + { + break + } + try? await Task.sleep(nanoseconds: 20_000_000) } } - // 5. Wait for focus - try? await Task.sleep(nanoseconds: 500_000_000) - - // 6. Paste using CGEvent (Accessibility permission only) + // 5. Paste using CGEvent (Accessibility permission only) await MainActor.run { ClipboardService.shared.paste() } guard let previousClipboard else { return } - try? await Task.sleep(nanoseconds: 350_000_000) + // Give slow apps (Electron, remote desktops) time to service the + // Cmd+V before the clipboard is restored. A longer window is safe: + // restore() only fires if the pasteboard still holds the transcript, + // so a user copy in the meantime cancels it. + try? await Task.sleep(nanoseconds: 800_000_000) await MainActor.run { ClipboardService.shared.restore(previousClipboard, ifCurrentStringMatches: text) From 08925ec151b42ec47ba98c812a8b8d9d14dccbbd Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:04:36 +0700 Subject: [PATCH 09/42] Fix mid-recording device loss, denied-mic dead-end, and launch race - Restart the rebuilt capture session when the input device changes while recording (unplug fallback or external switch); previously the new session was never started, so the dictation silently went dead and finalized truncated with no error. - Show 'Mic access off' in the pill when microphone permission is denied instead of proceeding into a silent zero-byte recording. - Treat models as present until the launch disk scan completes, so a hotkey press seconds after login no longer flashes a false 'Model not downloaded' error. --- .../Services/AudioRecordingService.swift | 12 ++++++++++ speaktype/Services/ModelDownloadService.swift | 7 +++++- .../Views/Overlays/MiniRecorderView.swift | 23 +++++++++++++++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index 7576b87..2f83bad 100644 --- a/speaktype/Services/AudioRecordingService.swift +++ b/speaktype/Services/AudioRecordingService.swift @@ -17,6 +17,18 @@ class AudioRecordingService: NSObject, ObservableObject { @Published var selectedDeviceId: String? { didSet { setupSession() + // If a dictation is in flight (device unplugged mid-recording, or a + // switch from settings), restart the rebuilt session so capture + // continues β€” setupSession alone leaves the new session stopped and + // the recording would silently go dead until the user stops. + if isRecording { + audioQueue.async { + if self.captureSession?.isRunning != true { + print("🎀 Restarting capture session after device change mid-recording") + self.captureSession?.startRunning() + } + } + } // Persist so the selection survives app restarts. if let selectedDeviceId { UserDefaults.standard.set(selectedDeviceId, forKey: Self.selectedDeviceDefaultsKey) diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift index eaf8a5a..e4ecf7d 100644 --- a/speaktype/Services/ModelDownloadService.swift +++ b/speaktype/Services/ModelDownloadService.swift @@ -115,7 +115,10 @@ class ModelDownloadService: ObservableObject { @Published var downloadProgress: [String: Double] = [:] // Map Model Variant (String) to progress @Published var downloadError: [String: String] = [:] // Debugging: track errors @Published var isDownloading: [String: Bool] = [:] - + /// False until the first launch disk scan finishes; callers should treat + /// missing progress entries as "unknown", not "not downloaded", before then. + @Published private(set) var hasCompletedInitialScan = false + private var activeTasks: [String: Task] = [:] // Track running download tasks private init() { @@ -164,6 +167,8 @@ class ModelDownloadService: ObservableObject { } await MainActor.run { + self.hasCompletedInitialScan = true + // Keep live download rows stable while refreshing disk state. self.downloadProgress = self.downloadProgress.filter { self.isDownloading[$0.key] == true diff --git a/speaktype/Views/Overlays/MiniRecorderView.swift b/speaktype/Views/Overlays/MiniRecorderView.swift index 0f63691..6ef1e8d 100644 --- a/speaktype/Views/Overlays/MiniRecorderView.swift +++ b/speaktype/Views/Overlays/MiniRecorderView.swift @@ -719,6 +719,22 @@ struct MiniRecorderView: View { } #endif + // Denied mic access previously produced a silent zero-byte recording; + // tell the user what is wrong instead. + let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) + guard micStatus == .authorized || micStatus == .notDetermined else { + debugLog("Microphone access denied - showing error") + isProcessing = true + statusMessage = "Mic access off β€” System Settings β†’ Privacy & Security" + + Task { @MainActor in + try? await Task.sleep(nanoseconds: 3_000_000_000) + isProcessing = false + onCancel?() + } + return + } + // Check if model is selected BEFORE starting recording guard !selectedModel.isEmpty else { debugLog("No model selected - showing error") @@ -733,9 +749,12 @@ struct MiniRecorderView: View { return } - // Check if model is downloaded + // Check if model is downloaded. Until the launch disk scan finishes, + // treat the model as present β€” a hotkey press seconds after login used + // to flash a false "Model not downloaded" (transcription still fails + // with a real error in the rare case the model is genuinely missing). let progress = ModelDownloadService.shared.downloadProgress[selectedModel] ?? 0 - guard progress >= 1.0 else { + guard progress >= 1.0 || !ModelDownloadService.shared.hasCompletedInitialScan else { debugLog("Model not downloaded - showing error") isProcessing = true statusMessage = "Model not downloaded" From 056d60b0f1b15f4fbd3ff63fedaf5ec541f2bfe7 Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:07:23 +0700 Subject: [PATCH 10/42] Separate download status notes from errors and clarify no-model error The auto-repair strings ('Cleaning duplicates...', 'Retrying download...') were written into downloadError, so once ModelRow began rendering that map they appeared as red failure notes while the retry was actually progressing. Route them through a new downloadStatus map rendered neutrally, and clear both maps on every terminal path (success, failure, cancel). Also make file transcription's no-model-selected error say where to fix it instead of the generic 'Model is not initialized'. --- speaktype/Services/ModelDownloadService.swift | 13 +++++++++++-- speaktype/Views/Components/ModelRow.swift | 4 ++++ speaktype/Views/TranscribeAudioView.swift | 7 ++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift index e4ecf7d..7b7075d 100644 --- a/speaktype/Services/ModelDownloadService.swift +++ b/speaktype/Services/ModelDownloadService.swift @@ -114,6 +114,8 @@ class ModelDownloadService: ObservableObject { @Published var downloadProgress: [String: Double] = [:] // Map Model Variant (String) to progress @Published var downloadError: [String: String] = [:] // Debugging: track errors + /// Transient, non-error progress notes (e.g. auto-repair) shown neutrally in the UI. + @Published var downloadStatus: [String: String] = [:] @Published var isDownloading: [String: Bool] = [:] /// False until the first launch disk scan finishes; callers should treat /// missing progress entries as "unknown", not "not downloaded", before then. @@ -264,6 +266,7 @@ class ModelDownloadService: ObservableObject { isDownloading[variant] = true downloadProgress[variant] = 0.0 downloadError[variant] = nil + downloadStatus[variant] = nil // Create the storage directory now, on first download β€” never eagerly on launch. ModelStorage.ensureWhisperKitModelsDir() print("Starting WhisperKit download for: \(variant)") @@ -300,6 +303,7 @@ class ModelDownloadService: ObservableObject { DispatchQueue.main.async { self.isDownloading[variant] = false self.downloadProgress[variant] = 1.0 + self.downloadStatus[variant] = nil self.activeTasks[variant] = nil // Cleanup task } } catch { @@ -315,7 +319,7 @@ class ModelDownloadService: ObservableObject { print("⚠️ Multiple models detected. Cleaning cache and retrying...") await MainActor.run { - self.downloadError[variant] = "Cleaning duplicates..." + self.downloadStatus[variant] = "Cleaning duplicates..." } let log = await self.deleteModel(variant: variant) @@ -326,7 +330,7 @@ class ModelDownloadService: ObservableObject { if Task.isCancelled { return } await MainActor.run { - self.downloadError[variant] = "Retrying download..." + self.downloadStatus[variant] = "Retrying download..." } // Retry download once @@ -345,6 +349,7 @@ class ModelDownloadService: ObservableObject { self.isDownloading[variant] = false self.downloadProgress[variant] = 1.0 self.downloadError[variant] = nil + self.downloadStatus[variant] = nil self.activeTasks[variant] = nil } } catch { @@ -355,6 +360,7 @@ class ModelDownloadService: ObservableObject { self.downloadProgress[variant] = 0.0 self.downloadError[variant] = "Download failed: \(error.localizedDescription). Try downloading again." + self.downloadStatus[variant] = nil self.activeTasks[variant] = nil } } @@ -366,6 +372,7 @@ class ModelDownloadService: ObservableObject { self.downloadProgress[variant] = 0.0 self.downloadError[variant] = "Download failed: \(error.localizedDescription). Try downloading again." + self.downloadStatus[variant] = nil self.activeTasks[variant] = nil } } @@ -379,6 +386,7 @@ class ModelDownloadService: ObservableObject { isDownloading[variant] = true downloadProgress[variant] = 0.0 downloadError[variant] = nil + downloadStatus[variant] = nil print("Starting FluidAudio (Parakeet) download for: \(variant)") let version = ParakeetCatalog.version(for: variant) @@ -487,6 +495,7 @@ class ModelDownloadService: ObservableObject { isDownloading[variant] = false downloadProgress[variant] = 0.0 downloadError[variant] = nil + downloadStatus[variant] = nil // Delete any partial download Task { diff --git a/speaktype/Views/Components/ModelRow.swift b/speaktype/Views/Components/ModelRow.swift index 65f142c..c8cdd41 100644 --- a/speaktype/Views/Components/ModelRow.swift +++ b/speaktype/Views/Components/ModelRow.swift @@ -27,6 +27,7 @@ struct ModelRow: View { var isDownloaded: Bool { progress >= 1.0 } var isActive: Bool { selectedModel == model.variant } var downloadError: String? { downloadService.downloadError[model.variant] } + var downloadStatus: String? { downloadService.downloadStatus[model.variant] } // MARK: - Body @@ -50,6 +51,9 @@ struct ModelRow: View { if let downloadError { note(icon: "xmark.circle.fill", text: downloadError, tint: .accentError) } + if let downloadStatus { + note(icon: "arrow.triangle.2.circlepath", text: downloadStatus, tint: .textMuted) + } } Spacer(minLength: 8) diff --git a/speaktype/Views/TranscribeAudioView.swift b/speaktype/Views/TranscribeAudioView.swift index d157dcf..ce85cd0 100644 --- a/speaktype/Views/TranscribeAudioView.swift +++ b/speaktype/Views/TranscribeAudioView.swift @@ -271,7 +271,12 @@ struct TranscribeAudioView: View { private func ensureSelectedModelIsLoaded() async throws { guard selectedModel != ModelSelection.none else { - throw WhisperService.TranscriptionError.notInitialized + throw NSError( + domain: "SpeakType", code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "No AI model selected. Download and select one in Settings \u{2192} AI Models first." + ]) } if !transcription.isInitialized || transcription.currentModelVariant != selectedModel { From f151443be3a63c29db00a9e157cfef4096680fcc Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:09:10 +0700 Subject: [PATCH 11/42] Validate Parakeet caches properly and stop silent loads-as-downloads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parakeet models were marked 'Installed' if their cache directory contained any file at all, so an interrupted download showed as installed forever and then failed to load with no hint of the cause. Use FluidAudio's AsrModels.modelsExist required-files check instead. ParakeetEngine.loadModel also called downloadAndLoad, which silently kicked off a multi-hundred-MB Hugging Face fetch with no progress or cancel when files were missing; it now loads strictly from cache and throws an actionable modelFilesMissing error pointing at Settings β†’ AI Models. --- speaktype/Services/ModelDownloadService.swift | 5 +++-- .../Services/Transcription/ParakeetEngine.swift | 12 ++++++++++-- speaktype/Services/WhisperService.swift | 3 +++ 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift index 7b7075d..1fa7b74 100644 --- a/speaktype/Services/ModelDownloadService.swift +++ b/speaktype/Services/ModelDownloadService.swift @@ -157,12 +157,13 @@ class ModelDownloadService: ObservableObject { } // Check Parakeet (FluidAudio) models, which live in their own cache dir. + // Use FluidAudio's own required-files check β€” a merely non-empty cache + // (failed or interrupted download) used to show as "Installed" forever. for variant in ParakeetCatalog.variants { let version = ParakeetCatalog.version(for: variant) let cacheDir = AsrModels.defaultCacheDirectory(for: version) if fileManager.fileExists(atPath: cacheDir.path), - let contents = try? fileManager.contentsOfDirectory(atPath: cacheDir.path), - !contents.isEmpty { + AsrModels.modelsExist(at: cacheDir, version: version) { foundModels.insert(variant) print("βœ… Parakeet model \(variant) found in cache") } diff --git a/speaktype/Services/Transcription/ParakeetEngine.swift b/speaktype/Services/Transcription/ParakeetEngine.swift index 7a7f400..dfd1990 100644 --- a/speaktype/Services/Transcription/ParakeetEngine.swift +++ b/speaktype/Services/Transcription/ParakeetEngine.swift @@ -67,8 +67,16 @@ class ParakeetEngine: SpeechToTextEngine { let version = ParakeetCatalog.version(for: variant) loadingStage = "Loading Parakeet model…" - // Downloads from Hugging Face on first use, then loads from cache. - let models = try await AsrModels.downloadAndLoad(version: version) + // Load strictly from the local cache. downloadAndLoad used to kick off + // a silent multi-hundred-MB Hugging Face fetch here when files were + // missing β€” with no progress UI and contradicting the offline-first + // promise. Downloads belong to ModelDownloadService. + let cacheDir = AsrModels.defaultCacheDirectory(for: version) + guard AsrModels.modelsExist(at: cacheDir, version: version) else { + loadingStage = "" + throw WhisperService.TranscriptionError.modelFilesMissing + } + let models = try await AsrModels.load(from: cacheDir, version: version) let manager = AsrManager(config: .default) try await manager.loadModels(models) diff --git a/speaktype/Services/WhisperService.swift b/speaktype/Services/WhisperService.swift index 7f7f3f7..021d86c 100644 --- a/speaktype/Services/WhisperService.swift +++ b/speaktype/Services/WhisperService.swift @@ -75,6 +75,7 @@ class WhisperService { case alreadyLoading case loadingTimeout case unsupportedModelVariant + case modelFilesMissing var errorDescription: String? { switch self { @@ -85,6 +86,8 @@ class WhisperService { return "Model loading timed out β€” your Mac may not have enough RAM for this model" case .unsupportedModelVariant: return "The selected model is not supported." + case .modelFilesMissing: + return "Model files are missing or incomplete β€” download the model again in Settings β†’ AI Models." } } } From cd63f52d484390d52389a9aaf91fb782053ed383 Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:10:26 +0700 Subject: [PATCH 12/42] Skip launch-time history re-normalization and sweep orphaned audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every launch re-ran ~30 regex passes over every stored transcript, a cost that grows linearly with usage. Gate it behind a stored normalization-schema version so it runs once per rule change instead of every launch. Also sweep the Recordings directory at launch for audio files no history item references (pruned rows, failed imports) β€” only app-named files older than 24h, so in-flight recordings are never touched. --- speaktype/Services/HistoryService.swift | 47 ++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/speaktype/Services/HistoryService.swift b/speaktype/Services/HistoryService.swift index ca52317..ef7a68c 100644 --- a/speaktype/Services/HistoryService.swift +++ b/speaktype/Services/HistoryService.swift @@ -35,10 +35,17 @@ class HistoryService: ObservableObject { private let saveKey = "history_items" private let statsSaveKey = "history_stats_entries" - + + /// Bump when the normalization rules change in a way stored transcripts + /// should be re-run through. While it matches, launch skips re-normalizing + /// the whole history (~30 regex passes per item, growing with usage). + private static let normalizationVersionKey = "history_normalization_version" + private static let normalizationVersion = 1 + private init() { loadStats() loadHistory() + sweepOrphanedRecordings() } func addItem(transcript: String, duration: TimeInterval, audioFileURL: URL? = nil, modelUsed: String? = nil, transcriptionTime: TimeInterval? = nil) { @@ -134,6 +141,14 @@ class HistoryService: ObservableObject { private func loadHistory() { if let data = UserDefaults.standard.data(forKey: saveKey), let decoded = try? JSONDecoder().decode([HistoryItem].self, from: data) { + let storedVersion = UserDefaults.standard.integer( + forKey: Self.normalizationVersionKey) + if storedVersion == Self.normalizationVersion { + items = decoded + migrateStatsIfNeeded(from: decoded) + return + } + let normalizedItems = decoded.compactMap { item -> HistoryItem? in let normalizedTranscript = WhisperService.normalizedTranscription( from: item.transcript) @@ -162,6 +177,36 @@ class HistoryService: ObservableObject { migrateStatsIfNeeded(from: normalizedItems) } + UserDefaults.standard.set( + Self.normalizationVersion, forKey: Self.normalizationVersionKey) + } + + /// Delete recordings in the app-managed Recordings directory that no + /// history item references. Recordings whose history rows were pruned + /// (decode failures, failed imports) otherwise accumulate forever at + /// ~1.9 MB/min of dictation. Only touches our own file patterns, and only + /// files older than a day, so an in-flight recording is never swept. + private func sweepOrphanedRecordings() { + let referenced = Set(items.compactMap { $0.audioFileURL?.standardizedFileURL.path }) + DispatchQueue.global(qos: .utility).async { + let dir = AudioRecordingService.recordingsDirectory() + guard let files = try? FileManager.default.contentsOfDirectory( + at: dir, includingPropertiesForKeys: [.contentModificationDateKey]) + else { return } + + let cutoff = Date().addingTimeInterval(-24 * 60 * 60) + for file in files { + let name = file.lastPathComponent + guard name.hasPrefix("recording-") || name.hasPrefix("imported-") else { + continue + } + guard !referenced.contains(file.standardizedFileURL.path) else { continue } + let modified = (try? file.resourceValues( + forKeys: [.contentModificationDateKey]))?.contentModificationDate + guard let modified, modified < cutoff else { continue } + try? FileManager.default.removeItem(at: file) + } + } } private func loadStats() { From a077bf179a26c6701e2814af192b0c4c44597245 Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:12:32 +0700 Subject: [PATCH 13/42] Install keystroke monitors only while they can do anything MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two app-lifetime global keyDown monitors (modifier-combo cancel in AppDelegate, Escape in MiniRecorderView) woke SpeakType on every keystroke in every app just to bail on a guard. The combo-cancel monitors now live only while the hotkey is held, and the Escape monitors only while recording or processing. Also stop handling Fn in the NSEvent flagsChanged path while the suppressing CGEvent tap is active β€” a duplicate arriving after the 50ms dedupe window could double-toggle recording. --- speaktype/App/AppDelegate.swift | 28 +++++++++ .../Views/Overlays/MiniRecorderView.swift | 62 +++++++++++++------ 2 files changed, 70 insertions(+), 20 deletions(-) diff --git a/speaktype/App/AppDelegate.swift b/speaktype/App/AppDelegate.swift index 019d791..324a9dc 100644 --- a/speaktype/App/AppDelegate.swift +++ b/speaktype/App/AppDelegate.swift @@ -188,6 +188,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { return event } + // The keyDown (modifier-combo cancel) monitors are installed on demand + // while the hotkey is held β€” see installModifierComboMonitors(). Keeping + // them alive for the app's lifetime woke SpeakType on every keystroke + // system-wide just to bail on the isHotkeyPressed guard. + } + + /// Installed only for the duration of a hotkey hold; removed on release. + private func installModifierComboMonitors() { + guard globalKeyDownMonitor == nil && localKeyDownMonitor == nil else { return } + globalKeyDownMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in self?.handleModifierComboEvent(event) @@ -200,6 +210,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } + private func removeModifierComboMonitors() { + if let globalKeyDownMonitor { + NSEvent.removeMonitor(globalKeyDownMonitor) + self.globalKeyDownMonitor = nil + } + if let localKeyDownMonitor { + NSEvent.removeMonitor(localKeyDownMonitor) + self.localKeyDownMonitor = nil + } + } + private func setupSuppressingHotkeyEventTap() { guard hotkeyEventTap == nil else { return } @@ -268,6 +289,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func handleHotkeyEvent(_ event: NSEvent) { let currentHotkey = getSelectedHotkey() + // When the suppressing event tap is active it fully owns the Fn key + // (and swallows those events); an Fn event that still reaches this + // monitor is a late duplicate β€” acting on it would double-toggle. + if currentHotkey == .fn && hotkeyEventTap != nil { return } guard event.keyCode == currentHotkey.keyCode else { return } let isPressed = event.modifierFlags.contains(currentHotkey.modifierFlag) @@ -280,6 +305,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let currentHotkey = getSelectedHotkey() if isPressed && !isHotkeyPressed { isHotkeyPressed = true + installModifierComboMonitors() // Only inject the synthetic F19 when the Globe/Fn key is actually // configured to show the emoji picker β€” otherwise there's nothing to @@ -303,6 +329,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } else if !isPressed && isHotkeyPressed { isHotkeyPressed = false + removeModifierComboMonitors() let recordingMode = UserDefaults.standard.integer(forKey: "recordingMode") if recordingMode == 0 { @@ -321,6 +348,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { guard event.keyCode != Self.emojiSuppressionKeyCode else { return } isHotkeyPressed = false + removeModifierComboMonitors() miniRecorderController?.cancelRecording() } diff --git a/speaktype/Views/Overlays/MiniRecorderView.swift b/speaktype/Views/Overlays/MiniRecorderView.swift index 6ef1e8d..0f34c34 100644 --- a/speaktype/Views/Overlays/MiniRecorderView.swift +++ b/speaktype/Views/Overlays/MiniRecorderView.swift @@ -450,30 +450,13 @@ struct MiniRecorderView: View { .onAppear { initializedService() audioRecorder.fetchAvailableDevices() - - // Set up Escape key monitors - globalEscapeMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in - if event.keyCode == 53 { - Task { @MainActor in self.handleEscape() } - } - } - localEscapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - if event.keyCode == 53 { - Task { @MainActor in self.handleEscape() } - return nil // swallow Escape - } - return event - } } .onDisappear { - if let globalEscapeMonitor = globalEscapeMonitor { - NSEvent.removeMonitor(globalEscapeMonitor) - } - if let localEscapeMonitor = localEscapeMonitor { - NSEvent.removeMonitor(localEscapeMonitor) - } + removeEscapeMonitors() audioRecorder.stopSessionIfIdle() } + .onChange(of: isListening) { updateEscapeMonitors() } + .onChange(of: isProcessing) { updateEscapeMonitors() } .onChange(of: isListening) { // Only animate when actually recording to save CPU if isListening { @@ -853,6 +836,45 @@ struct MiniRecorderView: View { } } + /// Escape monitors live only while a recording or processing pass is + /// active. A permanent global keyDown monitor woke the app on every + /// keystroke system-wide just to check for Escape. + private func updateEscapeMonitors() { + if isListening || isProcessing { + installEscapeMonitors() + } else { + removeEscapeMonitors() + } + } + + private func installEscapeMonitors() { + guard globalEscapeMonitor == nil && localEscapeMonitor == nil else { return } + + globalEscapeMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in + if event.keyCode == 53 { + Task { @MainActor in self.handleEscape() } + } + } + localEscapeMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + if event.keyCode == 53 { + Task { @MainActor in self.handleEscape() } + return nil // swallow Escape + } + return event + } + } + + private func removeEscapeMonitors() { + if let globalEscapeMonitor { + NSEvent.removeMonitor(globalEscapeMonitor) + self.globalEscapeMonitor = nil + } + if let localEscapeMonitor { + NSEvent.removeMonitor(localEscapeMonitor) + self.localEscapeMonitor = nil + } + } + private func handleEscape() { guard isListening || isProcessing || isWarmingUp || transcription.isLoading else { return } From bea407f781c37ccb1d48c0fd049176c326eee241 Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:15:06 +0700 Subject: [PATCH 14/42] Delete dead screens, models, and the unused KeyboardShortcuts hookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes files with no references outside themselves: the parallel onboarding PermissionsView, HistoryDetailView, HotkeyConfiguration (which claimed a Ctrl+Shift+Space default the real hotkey logic never used), RecordingSession, TranscriptionState, and the never-called WhisperService.transcribeChunk left over from removed chunk stitching. AudioInputView shrinks to just DeviceRow, the one component SettingsView actually uses. The KeyboardShortcuts package was imported in three files but its API never called and its one shortcut name never consumed β€” imports and README mention dropped (package ref left in the project for a separate pass). --- README.md | 1 - speaktype/App/AppDelegate.swift | 1 - speaktype/App/speaktypeApp.swift | 1 - .../Constants/Shortcuts+Extensions.swift | 7 - speaktype/Models/HotkeyConfiguration.swift | 214 --------------- speaktype/Models/RecordingSession.swift | 202 -------------- speaktype/Models/TranscriptionState.swift | 53 ---- speaktype/Services/WhisperService.swift | 26 -- .../Screens/History/HistoryDetailView.swift | 252 ------------------ .../Screens/Onboarding/PermissionsView.swift | 137 ---------- .../Screens/Settings/AudioInputView.swift | 132 --------- .../Views/Screens/Settings/DeviceRow.swift | 44 +++ .../Views/Screens/Settings/SettingsView.swift | 1 - 13 files changed, 44 insertions(+), 1027 deletions(-) delete mode 100644 speaktype/Models/HotkeyConfiguration.swift delete mode 100644 speaktype/Models/RecordingSession.swift delete mode 100644 speaktype/Models/TranscriptionState.swift delete mode 100644 speaktype/Views/Screens/History/HistoryDetailView.swift delete mode 100644 speaktype/Views/Screens/Onboarding/PermissionsView.swift delete mode 100644 speaktype/Views/Screens/Settings/AudioInputView.swift create mode 100644 speaktype/Views/Screens/Settings/DeviceRow.swift diff --git a/README.md b/README.md index d90ad7c..3e07a50 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,6 @@ speaktype/ - **Swift 5.9+** / SwiftUI + AppKit - **[WhisperKit](https://github.com/argmaxinc/WhisperKit)** - Local Whisper inference -- **[KeyboardShortcuts](https://github.com/sindresorhus/KeyboardShortcuts)** - Global hotkeys - **AVFoundation** - Audio capture --- diff --git a/speaktype/App/AppDelegate.swift b/speaktype/App/AppDelegate.swift index 324a9dc..f23c0ce 100644 --- a/speaktype/App/AppDelegate.swift +++ b/speaktype/App/AppDelegate.swift @@ -1,5 +1,4 @@ import Combine -import KeyboardShortcuts import SwiftUI class AppDelegate: NSObject, NSApplicationDelegate { diff --git a/speaktype/App/speaktypeApp.swift b/speaktype/App/speaktypeApp.swift index 75bc539..01e8a83 100644 --- a/speaktype/App/speaktypeApp.swift +++ b/speaktype/App/speaktypeApp.swift @@ -5,7 +5,6 @@ // Created by Karan Singh on 7/1/26. // -import KeyboardShortcuts import SwiftData import SwiftUI diff --git a/speaktype/Constants/Shortcuts+Extensions.swift b/speaktype/Constants/Shortcuts+Extensions.swift index cd2b015..e234803 100644 --- a/speaktype/Constants/Shortcuts+Extensions.swift +++ b/speaktype/Constants/Shortcuts+Extensions.swift @@ -1,13 +1,6 @@ import Foundation -import KeyboardShortcuts -import AppKit - -extension KeyboardShortcuts.Name { - static let toggleRecord = Self("toggleRecord", default: .init(.space, modifiers: [.control, .option])) -} extension Notification.Name { - static let hotkeyTriggered = Notification.Name("hotkeyTriggered") // Legacy, can be removed static let recordingStartRequested = Notification.Name("recordingStartRequested") static let recordingStopRequested = Notification.Name("recordingStopRequested") static let recordingCancelRequested = Notification.Name("recordingCancelRequested") diff --git a/speaktype/Models/HotkeyConfiguration.swift b/speaktype/Models/HotkeyConfiguration.swift deleted file mode 100644 index 3a43637..0000000 --- a/speaktype/Models/HotkeyConfiguration.swift +++ /dev/null @@ -1,214 +0,0 @@ -// -// HotkeyConfiguration.swift -// speaktype -// -// Created on 2026-01-07. -// - -import Foundation -import Carbon.HIToolbox - -/// Configuration for global keyboard shortcuts -struct HotkeyConfiguration: Codable, Equatable, Identifiable { - /// Unique identifier - let id: UUID - - /// The key code (e.g., kVK_ANSI_A for 'A' key) - let keyCode: UInt32 - - /// Modifier flags (Command, Option, Control, Shift) - let modifierFlags: ModifierFlags - - /// Human-readable description - var description: String { - var parts: [String] = [] - - if modifierFlags.contains(.control) { - parts.append("βŒƒ") - } - if modifierFlags.contains(.option) { - parts.append("βŒ₯") - } - if modifierFlags.contains(.shift) { - parts.append("⇧") - } - if modifierFlags.contains(.command) { - parts.append("⌘") - } - - parts.append(keyCodeToString(keyCode)) - - return parts.joined() - } - - // MARK: - Initialization - - init( - id: UUID = UUID(), - keyCode: UInt32, - modifierFlags: ModifierFlags - ) { - self.id = id - self.keyCode = keyCode - self.modifierFlags = modifierFlags - } - - // MARK: - Default Configurations - - /// Default hotkey: Control + Shift + Space - static let `default` = HotkeyConfiguration( - keyCode: UInt32(kVK_Space), - modifierFlags: [.control, .shift] - ) - - /// Alternative: Option + Space - static let alternativeOne = HotkeyConfiguration( - keyCode: UInt32(kVK_Space), - modifierFlags: [.option] - ) - - /// Alternative: Command + Shift + V - static let alternativeTwo = HotkeyConfiguration( - keyCode: UInt32(kVK_ANSI_V), - modifierFlags: [.command, .shift] - ) - - // MARK: - Validation - - /// Check if the hotkey configuration is valid - var isValid: Bool { - // Must have at least one modifier - !modifierFlags.isEmpty - } - - /// Check if this conflicts with common system shortcuts - var conflictsWithSystemShortcuts: Bool { - // Check for common conflicts - let commonConflicts: [(UInt32, ModifierFlags)] = [ - (UInt32(kVK_ANSI_C), [.command]), // Copy - (UInt32(kVK_ANSI_V), [.command]), // Paste - (UInt32(kVK_ANSI_X), [.command]), // Cut - (UInt32(kVK_ANSI_Z), [.command]), // Undo - (UInt32(kVK_ANSI_Q), [.command]), // Quit - (UInt32(kVK_ANSI_W), [.command]), // Close - (UInt32(kVK_Tab), [.command]), // Switch apps - (UInt32(kVK_Space), [.command]), // Spotlight - ] - - return commonConflicts.contains { code, flags in - code == keyCode && flags == modifierFlags - } - } -} - -// MARK: - Modifier Flags - -/// Keyboard modifier flags -struct ModifierFlags: OptionSet, Codable, Equatable { - let rawValue: UInt32 - - static let command = ModifierFlags(rawValue: 1 << 0) - static let shift = ModifierFlags(rawValue: 1 << 1) - static let option = ModifierFlags(rawValue: 1 << 2) - static let control = ModifierFlags(rawValue: 1 << 3) - - /// Convert to Carbon event modifier flags - var carbonFlags: UInt32 { - var flags: UInt32 = 0 - if contains(.command) { flags |= UInt32(cmdKey) } - if contains(.shift) { flags |= UInt32(shiftKey) } - if contains(.option) { flags |= UInt32(optionKey) } - if contains(.control) { flags |= UInt32(controlKey) } - return flags - } - - /// Convert to Cocoa event modifier flags - var cocoaFlags: UInt { - var flags: UInt = 0 - if contains(.command) { flags |= 1 << 20 } // NSEvent.ModifierFlags.command - if contains(.shift) { flags |= 1 << 17 } // NSEvent.ModifierFlags.shift - if contains(.option) { flags |= 1 << 19 } // NSEvent.ModifierFlags.option - if contains(.control) { flags |= 1 << 18 } // NSEvent.ModifierFlags.control - return flags - } -} - -// MARK: - Key Code Mapping - -/// Convert virtual key code to display string -private func keyCodeToString(_ keyCode: UInt32) -> String { - switch Int(keyCode) { - case kVK_Space: return "Space" - case kVK_Return: return "Return" - case kVK_Tab: return "Tab" - case kVK_Delete: return "Delete" - case kVK_Escape: return "Esc" - case kVK_ForwardDelete: return "⌦" - case kVK_Home: return "Home" - case kVK_End: return "End" - case kVK_PageUp: return "PgUp" - case kVK_PageDown: return "PgDn" - case kVK_LeftArrow: return "←" - case kVK_RightArrow: return "β†’" - case kVK_UpArrow: return "↑" - case kVK_DownArrow: return "↓" - - // F-keys - case kVK_F1: return "F1" - case kVK_F2: return "F2" - case kVK_F3: return "F3" - case kVK_F4: return "F4" - case kVK_F5: return "F5" - case kVK_F6: return "F6" - case kVK_F7: return "F7" - case kVK_F8: return "F8" - case kVK_F9: return "F9" - case kVK_F10: return "F10" - case kVK_F11: return "F11" - case kVK_F12: return "F12" - - // Letters - case kVK_ANSI_A: return "A" - case kVK_ANSI_B: return "B" - case kVK_ANSI_C: return "C" - case kVK_ANSI_D: return "D" - case kVK_ANSI_E: return "E" - case kVK_ANSI_F: return "F" - case kVK_ANSI_G: return "G" - case kVK_ANSI_H: return "H" - case kVK_ANSI_I: return "I" - case kVK_ANSI_J: return "J" - case kVK_ANSI_K: return "K" - case kVK_ANSI_L: return "L" - case kVK_ANSI_M: return "M" - case kVK_ANSI_N: return "N" - case kVK_ANSI_O: return "O" - case kVK_ANSI_P: return "P" - case kVK_ANSI_Q: return "Q" - case kVK_ANSI_R: return "R" - case kVK_ANSI_S: return "S" - case kVK_ANSI_T: return "T" - case kVK_ANSI_U: return "U" - case kVK_ANSI_V: return "V" - case kVK_ANSI_W: return "W" - case kVK_ANSI_X: return "X" - case kVK_ANSI_Y: return "Y" - case kVK_ANSI_Z: return "Z" - - // Numbers - case kVK_ANSI_0: return "0" - case kVK_ANSI_1: return "1" - case kVK_ANSI_2: return "2" - case kVK_ANSI_3: return "3" - case kVK_ANSI_4: return "4" - case kVK_ANSI_5: return "5" - case kVK_ANSI_6: return "6" - case kVK_ANSI_7: return "7" - case kVK_ANSI_8: return "8" - case kVK_ANSI_9: return "9" - - default: - return String(format: "Key%02X", keyCode) - } -} - diff --git a/speaktype/Models/RecordingSession.swift b/speaktype/Models/RecordingSession.swift deleted file mode 100644 index 04d903b..0000000 --- a/speaktype/Models/RecordingSession.swift +++ /dev/null @@ -1,202 +0,0 @@ -// -// RecordingSession.swift -// speaktype -// -// Created on 2026-01-07. -// - -import Foundation -import AVFoundation - -/// Represents an active or completed audio recording session -struct RecordingSession: Identifiable, Equatable { - /// Unique identifier for the session - let id: UUID - - /// When the recording started - let startTime: Date - - /// When the recording ended (nil if still recording) - var endTime: Date? - - /// Current or final duration of the recording - var duration: TimeInterval { - let end = endTime ?? Date() - return end.timeIntervalSince(startTime) - } - - /// Audio format information - let audioFormat: AudioFormat - - /// Temporary file URL where audio is being saved - let fileURL: URL? - - /// Current recording state - var state: RecordingState - - /// Audio level samples for visualization (normalized 0.0 to 1.0) - var audioLevels: [Float] - - /// Error if recording failed - var error: RecordingError? - - // MARK: - Initialization - - init( - id: UUID = UUID(), - startTime: Date = Date(), - endTime: Date? = nil, - audioFormat: AudioFormat = .default, - fileURL: URL? = nil, - state: RecordingState = .recording, - audioLevels: [Float] = [], - error: RecordingError? = nil - ) { - self.id = id - self.startTime = startTime - self.endTime = endTime - self.audioFormat = audioFormat - self.fileURL = fileURL - self.state = state - self.audioLevels = audioLevels - self.error = error - } - - // MARK: - Computed Properties - - /// Whether the recording is currently active - var isRecording: Bool { - state == .recording - } - - /// Whether the recording completed successfully - var isCompleted: Bool { - state == .completed && error == nil - } - - /// Formatted duration string (MM:SS) - var formattedDuration: String { - let minutes = Int(duration) / 60 - let seconds = Int(duration) % 60 - return String(format: "%02d:%02d", minutes, seconds) - } - - /// Average audio level - var averageLevel: Float { - guard !audioLevels.isEmpty else { return 0.0 } - return audioLevels.reduce(0, +) / Float(audioLevels.count) - } - - /// Peak audio level - var peakLevel: Float { - audioLevels.max() ?? 0.0 - } -} - -// MARK: - Recording State - -/// State of the recording session -enum RecordingState: String, Codable, Equatable { - /// Recording is in progress - case recording - - /// Recording is paused (future feature) - case paused - - /// Recording completed successfully - case completed - - /// Recording was cancelled - case cancelled - - /// Recording failed with error - case failed -} - -// MARK: - Audio Format - -/// Audio format configuration for recording -struct AudioFormat: Equatable, Codable { - /// Sample rate in Hz - let sampleRate: Double - - /// Number of audio channels - let channels: Int - - /// Bit depth - let bitDepth: Int - - /// Audio format identifier - let formatID: AudioFormatID - - /// Default format optimized for Whisper - static let `default` = AudioFormat( - sampleRate: 16000.0, // Whisper expects 16kHz - channels: 1, // Mono - bitDepth: 16, - formatID: kAudioFormatLinearPCM - ) - - /// High quality format - static let highQuality = AudioFormat( - sampleRate: 48000.0, - channels: 1, - bitDepth: 24, - formatID: kAudioFormatLinearPCM - ) - - /// Description of the format - var description: String { - "\(Int(sampleRate / 1000))kHz, \(channels)ch, \(bitDepth)-bit" - } -} - -// MARK: - Recording Error - -/// Errors that can occur during recording -enum RecordingError: Error, LocalizedError, Equatable { - case permissionDenied - case audioEngineFailure - case fileWriteError - case maxDurationExceeded - case audioInputUnavailable - case unknown(String) - - var errorDescription: String? { - switch self { - case .permissionDenied: - return "Microphone permission is required to record audio." - case .audioEngineFailure: - return "Failed to initialize audio recording engine." - case .fileWriteError: - return "Failed to save audio recording." - case .maxDurationExceeded: - return "Recording stopped: Maximum duration exceeded." - case .audioInputUnavailable: - return "No audio input device available." - case .unknown(let message): - return "Recording error: \(message)" - } - } -} - -// MARK: - Factory Methods - -extension RecordingSession { - /// Create a new recording session - static func new() -> RecordingSession { - RecordingSession( - startTime: Date(), - state: .recording - ) - } - - /// Create a failed session with error - static func failed(error: RecordingError) -> RecordingSession { - RecordingSession( - state: .failed, - error: error - ) - } -} - diff --git a/speaktype/Models/TranscriptionState.swift b/speaktype/Models/TranscriptionState.swift deleted file mode 100644 index ea6cf50..0000000 --- a/speaktype/Models/TranscriptionState.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// TranscriptionState.swift -// speaktype -// -// Created on 2026-01-07. -// - -import Foundation - -/// Represents the current state of the transcription process -enum TranscriptionState: String, Codable, Equatable { - /// App is idle and ready to start recording - case idle - - /// Currently recording audio from microphone - case listening - - /// Processing audio and generating transcription - case transcribing - - /// Transcription complete and ready to display/paste - case ready - - /// An error occurred during the process - case error - - /// Displayable title for the current state - var title: String { - switch self { - case .idle: - return "Ready" - case .listening: - return "Listening..." - case .transcribing: - return "Transcribing..." - case .ready: - return "Transcription Ready" - case .error: - return "Error" - } - } - - /// Whether the state represents an active operation - var isActive: Bool { - switch self { - case .listening, .transcribing: - return true - case .idle, .ready, .error: - return false - } - } -} - diff --git a/speaktype/Services/WhisperService.swift b/speaktype/Services/WhisperService.swift index 021d86c..ea280b6 100644 --- a/speaktype/Services/WhisperService.swift +++ b/speaktype/Services/WhisperService.swift @@ -352,32 +352,6 @@ class WhisperService { } } - /// Transcribe a background audio chunk without affecting the global `isTranscribing` flag. - /// Chunk files are automatically deleted after transcription. - func transcribeChunk(audioFile: URL, language: String = "auto") async throws -> String { - guard let pipe = pipe, isInitialized else { - throw TranscriptionError.notInitialized - } - - guard FileManager.default.fileExists(atPath: audioFile.path) else { - // Chunk file may have been cleaned up already - return empty gracefully - return "" - } - - print("πŸ”ͺ Chunk transcription started: \(audioFile.lastPathComponent)") - - let results = try await pipe.transcribe( - audioPath: audioFile.path, - decodeOptions: decodingOptions(for: language) - ) - let text = Self.normalizedTranscription(from: results.map { $0.text }.joined(separator: " ")) - - AppLogger.success("Chunk transcription complete", category: AppLogger.transcription) - // Clean up temp chunk file after transcription - try? FileManager.default.removeItem(at: audioFile) - return text - } - private func decodingOptions(for language: String) -> DecodingOptions { var options = DecodingOptions() options.task = .transcribe diff --git a/speaktype/Views/Screens/History/HistoryDetailView.swift b/speaktype/Views/Screens/History/HistoryDetailView.swift deleted file mode 100644 index 385e2c2..0000000 --- a/speaktype/Views/Screens/History/HistoryDetailView.swift +++ /dev/null @@ -1,252 +0,0 @@ -import SwiftUI - -/// Enhanced detail view for history items with audio playback -struct HistoryDetailView: View { - let item: HistoryItem - - @StateObject private var audioPlayer = AudioPlayerService.shared - @State private var showCopyAlert = false - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - // Header with date and duration - HStack(alignment: .top) { - Text(item.date.formatted(date: .abbreviated, time: .shortened)) - .font(Typography.caption) - .foregroundStyle(.gray) - - Spacer() - - Text(formatDuration(item.duration)) - .font(Typography.labelMedium) - .foregroundStyle(.blue) - } - - // Badges and copy button - HStack { - // Original badge - Text("Original") - .font(Typography.badge) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.blue) - .foregroundStyle(.white) - .cornerRadius(12) - - Spacer() - - // Copy button - Button(action: { - copyToClipboard(text: item.transcript) - }) { - HStack(spacing: 6) { - Image(systemName: "doc.on.doc") - .font(.system(size: 11)) - Text("Copy") - .font(Typography.labelMedium) - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.blue) - .foregroundStyle(.white) - .cornerRadius(12) - } - .buttonStyle(.plain) - } - - Divider() - - // Transcript - Text(item.transcript) - .font(Typography.bodyMedium) - .textSelection(.enabled) - .padding(.vertical, 8) - - Divider() - - // Audio playback section - if let audioURL = item.audioFileURL { - VStack(spacing: 16) { - // Recording label with duration - HStack { - HStack(spacing: 6) { - Image(systemName: "waveform") - .font(.system(size: 11)) - .foregroundStyle(.gray) - Text("Recording") - .font(Typography.caption) - .foregroundStyle(.gray) - } - - Spacer() - - Text(formatTime(audioPlayer.currentTime)) - .font(Typography.caption) - .foregroundStyle(.gray) - .monospacedDigit() - } - - // Waveform visualization - WaveformView( - audioURL: audioURL, - currentTime: $audioPlayer.currentTime, - duration: $audioPlayer.duration - ) - - // Playback controls - HStack(spacing: 20) { - // Folder/file icon - Button(action: { - NSWorkspace.shared.activateFileViewerSelecting([audioURL]) - }) { - Image(systemName: "folder") - .font(.title3) - .foregroundStyle(.orange) - } - .buttonStyle(.plain) - .help("Show in Finder") - - Spacer() - - // Play/Pause button - Button(action: togglePlayback) { - Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill") - .font(.title2) - .foregroundStyle(.blue) - } - .buttonStyle(.plain) - - Spacer() - - // Refresh/restart button - Button(action: { - audioPlayer.seek(to: 0) - }) { - Image(systemName: "arrow.clockwise") - .font(.title3) - .foregroundStyle(.green) - } - .buttonStyle(.plain) - .help("Restart") - } - .padding(.vertical, 8) - } - .padding() - .background(Color.white.opacity(0.05)) - .cornerRadius(12) - } - - Divider() - - // Metrics section - VStack(alignment: .leading, spacing: 12) { - // Audio Duration - HStack { - Image(systemName: "waveform.circle") - .foregroundStyle(.gray) - Text("Audio Duration") - .font(Typography.bodySmall) - .foregroundStyle(.gray) - Spacer() - Text(formatDuration(item.duration)) - .font(Typography.bodySmall) - .foregroundStyle(.white) - } - - // Transcription Model - if let model = item.modelUsed { - HStack { - Image(systemName: "cpu") - .foregroundStyle(.gray) - Text("Transcription Model") - .font(Typography.bodySmall) - .foregroundStyle(.gray) - Spacer() - Text(model) - .font(Typography.bodySmall) - .foregroundStyle(.white) - .lineLimit(1) - } - } - - // Transcription Time - if let transcriptionTime = item.transcriptionTime { - HStack { - Image(systemName: "clock") - .foregroundStyle(.gray) - Text("Transcription Time") - .font(Typography.bodySmall) - .foregroundStyle(.gray) - Spacer() - Text(formatDuration(transcriptionTime)) - .font(Typography.bodySmall) - .foregroundStyle(.white) - } - } - } - } - .padding() - } - .background(Color.contentBackground) - .navigationTitle("Transcript Details") - .onAppear { - if let audioURL = item.audioFileURL { - audioPlayer.loadAudio(from: audioURL) - } - } - .onDisappear { - audioPlayer.stop() - } - .alert("Copied", isPresented: $showCopyAlert) { - Button("OK", role: .cancel) { } - } message: { - Text("Transcript copied to clipboard.") - } - } - - // MARK: - Helper Methods - - private func togglePlayback() { - if audioPlayer.isPlaying { - audioPlayer.pause() - } else { - audioPlayer.play() - } - } - - private func copyToClipboard(text: String) { - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - pasteboard.setString(text, forType: .string) - showCopyAlert = true - } - - private func formatDuration(_ duration: TimeInterval) -> String { - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.minute, .second] - formatter.unitsStyle = .abbreviated - formatter.zeroFormattingBehavior = .pad - return formatter.string(from: duration) ?? "0s" - } - - private func formatTime(_ time: TimeInterval) -> String { - let minutes = Int(time) / 60 - let seconds = Int(time) % 60 - return String(format: "%d:%02d", minutes, seconds) - } -} - -#Preview { - HistoryDetailView( - item: HistoryItem( - id: UUID(), - date: Date(), - transcript: "Hello, hello, hello, hello, hello.", - duration: 3.4, - audioFileURL: nil, - modelUsed: "Large v3 Turbo (Quantized)", - transcriptionTime: 1.3 - ) - ) -} diff --git a/speaktype/Views/Screens/Onboarding/PermissionsView.swift b/speaktype/Views/Screens/Onboarding/PermissionsView.swift deleted file mode 100644 index 89041b7..0000000 --- a/speaktype/Views/Screens/Onboarding/PermissionsView.swift +++ /dev/null @@ -1,137 +0,0 @@ -import SwiftUI -import AVFoundation - - -struct PermissionsView: View { - @State private var micStatus: AVAuthorizationStatus = .notDetermined - @State private var accessibilityStatus: Bool = false - @State private var timer: Timer? - - var body: some View { - ScrollView { - VStack(spacing: 30) { - // Header - VStack(spacing: 12) { - Image(systemName: "shield.fill") - .font(.system(size: 36)) - .foregroundStyle(Color.accentPrimary) - - Text("App Permissions") - .font(Typography.displayLarge) - .foregroundStyle(Color.textPrimary) - - Text("Manage permissions to ensure full functionality") - .font(Typography.bodyMedium) - .foregroundStyle(Color.textSecondary) - } - .padding(.top, 32) - - // Permission Items - VStack(spacing: 16) { - // Microphone - PermissionRow( - icon: "mic.fill", - color: .green, - title: "Microphone Access", - desc: "Allow SpeakType to record your voice for transcription", - isGranted: micStatus == .authorized, - action: { openSettings(for: "Privacy_Microphone") } - ) - - // Accessibility - PermissionRow( - icon: "hand.raised.fill", - color: .green, - title: "Accessibility Access", - desc: "Allow SpeakType to paste transcribed text directly", - isGranted: accessibilityStatus, - action: { - ClipboardService.shared.requestAccessibilityPermission() - // System dialog handles opening Settings when user clicks "Open System Settings" - } - ) - - - } - .padding(.horizontal, 40) - } - .padding(.bottom, 40) - } - .background(Color.clear) - .onAppear { - checkPermissions() - startPolling() - } - .onDisappear { - timer?.invalidate() - } - } - - func startPolling() { - timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in - checkPermissions() - } - } - - func checkPermissions() { - micStatus = AVCaptureDevice.authorizationStatus(for: .audio) - accessibilityStatus = AXIsProcessTrusted() - } - - func openSettings(for pane: String) { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") { - NSWorkspace.shared.open(url) - } - } -} - -struct PermissionRow: View { - let icon: String - let color: Color - let title: String - let desc: String - let isGranted: Bool - let action: () -> Void - - var body: some View { - HStack(spacing: 20) { - ZStack { - Circle() - .fill(color.opacity(0.15)) - .frame(width: 44, height: 44) - Image(systemName: icon) - .foregroundStyle(color) - .font(.title2) - } - - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(Typography.headlineSmall) - .foregroundStyle(Color.textPrimary) - Text(desc) - .font(Typography.bodySmall) - .foregroundStyle(Color.textSecondary) - } - - Spacer() - - Button(action: action) { - Text("Manage") - .font(Typography.bodySmall) - .foregroundStyle(Color.textSecondary) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(Color.bgHover) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - - if isGranted { - Image(systemName: "checkmark.seal.fill") - .foregroundStyle(.green) - .font(.title2) - } - } - .themedCard(padding: 20) - } -} diff --git a/speaktype/Views/Screens/Settings/AudioInputView.swift b/speaktype/Views/Screens/Settings/AudioInputView.swift deleted file mode 100644 index a618c49..0000000 --- a/speaktype/Views/Screens/Settings/AudioInputView.swift +++ /dev/null @@ -1,132 +0,0 @@ -import SwiftUI -import AVFoundation - -struct AudioInputView: View { - @StateObject private var audioRecorder = AudioRecordingService.shared - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 30) { - // Header - VStack(spacing: 12) { - Image(systemName: "waveform") - .font(.system(size: 36)) - .foregroundStyle(Color.accentPrimary) - - Text("Audio Input") - .font(Typography.displayLarge) - .foregroundStyle(Color.textPrimary) - - Text("Configure your microphone preferences") - .font(Typography.bodyMedium) - .foregroundStyle(Color.textSecondary) - } - .frame(maxWidth: .infinity) - .padding(.top, 32) - - // Input Mode Section Removed - - - - - - // Available Devices Section - VStack(alignment: .leading, spacing: 15) { - HStack { - Text("Available Devices") - .font(Typography.headlineMedium) - .foregroundStyle(Color.textPrimary) - Spacer() - Button(action: { - audioRecorder.fetchAvailableDevices() - }) { - HStack(spacing: 6) { - Image(systemName: "arrow.clockwise") - Text("Refresh") - } - .font(Typography.bodySmall) - .foregroundStyle(Color.textSecondary) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color.bgHover) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } - .buttonStyle(.plain) - } - - Text("Note: SpeakType will use the selected device for all recordings.") - .font(Typography.bodySmall) - .foregroundStyle(Color.textSecondary) - - VStack(spacing: 12) { - if audioRecorder.availableDevices.isEmpty { - Text("No input devices found.") - .foregroundStyle(.gray) - .padding() - } else { - ForEach(audioRecorder.availableDevices, id: \.uniqueID) { device in - DeviceRow( - name: device.localizedName, - isActive: audioRecorder.selectedDeviceId == device.uniqueID, // Simple check - isSelected: audioRecorder.selectedDeviceId == device.uniqueID - ) - .onTapGesture { - audioRecorder.selectedDeviceId = device.uniqueID - } - } - } - } - } - .padding(.horizontal, 40) - } - .padding(.bottom, 40) - } - .background(Color.clear) - .onAppear { - audioRecorder.fetchAvailableDevices() - } - } -} - - - -struct DeviceRow: View { - let name: String - let isActive: Bool - let isSelected: Bool - - var body: some View { - HStack { - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .foregroundStyle(isSelected ? Color.accentPrimary : Color.textMuted) - .font(.title3) - - Text(name) - .font(Typography.bodyMedium) - .foregroundStyle(Color.textPrimary) - - Spacer() - - if isActive { - HStack(spacing: 4) { - Image(systemName: "waveform") - Text("Active") - } - .font(Typography.labelSmall) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(Color.accentSuccess.opacity(0.15)) - .foregroundStyle(Color.accentSuccess) - .clipShape(RoundedRectangle(cornerRadius: 6)) - } - } - .padding(16) - .background(isSelected ? Color.bgSelected : Color.bgCard) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(isSelected ? Color.bgSelected : Color.border, lineWidth: 1) - ) - .cardShadow() - } -} diff --git a/speaktype/Views/Screens/Settings/DeviceRow.swift b/speaktype/Views/Screens/Settings/DeviceRow.swift new file mode 100644 index 0000000..1f4899e --- /dev/null +++ b/speaktype/Views/Screens/Settings/DeviceRow.swift @@ -0,0 +1,44 @@ +import SwiftUI +import AVFoundation + +// Row component used by the Audio tab in SettingsView. +struct DeviceRow: View { + let name: String + let isActive: Bool + let isSelected: Bool + + var body: some View { + HStack { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? Color.accentPrimary : Color.textMuted) + .font(.title3) + + Text(name) + .font(Typography.bodyMedium) + .foregroundStyle(Color.textPrimary) + + Spacer() + + if isActive { + HStack(spacing: 4) { + Image(systemName: "waveform") + Text("Active") + } + .font(Typography.labelSmall) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Color.accentSuccess.opacity(0.15)) + .foregroundStyle(Color.accentSuccess) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + .padding(16) + .background(isSelected ? Color.bgSelected : Color.bgCard) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Color.bgSelected : Color.border, lineWidth: 1) + ) + .cardShadow() + } +} diff --git a/speaktype/Views/Screens/Settings/SettingsView.swift b/speaktype/Views/Screens/Settings/SettingsView.swift index bda33ff..b98134d 100644 --- a/speaktype/Views/Screens/Settings/SettingsView.swift +++ b/speaktype/Views/Screens/Settings/SettingsView.swift @@ -1,5 +1,4 @@ import AVFoundation -import KeyboardShortcuts import SwiftUI struct SettingsView: View { From 4e368e34a8bfef4fc8f54ba8a50759d89ebe45e5 Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:16:18 +0700 Subject: [PATCH 15/42] Show load stage and elapsed time while the model warms up The pill said only 'Warming up model...' for the whole 30-60s cold load, which reads as a hang at login. Surface the engine's live loadingStage (already tracked but never rendered) plus elapsed seconds once the load passes 5s, and widen the warming pill to fit. --- .../Transcription/TranscriptionManager.swift | 7 ++++++ .../Views/Overlays/MiniRecorderView.swift | 23 +++++++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/speaktype/Services/Transcription/TranscriptionManager.swift b/speaktype/Services/Transcription/TranscriptionManager.swift index bdfb1e2..4d3569a 100644 --- a/speaktype/Services/Transcription/TranscriptionManager.swift +++ b/speaktype/Services/Transcription/TranscriptionManager.swift @@ -62,6 +62,13 @@ class TranscriptionManager { case .parakeet: return parakeet.loadingStage } } + /// When the in-flight model load began (Whisper only), for elapsed-time UI. + var loadingStartedAt: Date? { + switch activeKind { + case .whisper: return whisper.loadingStartedAt + case .parakeet: return nil + } + } var currentModelVariant: String { switch activeKind { case .whisper: return whisper.currentModelVariant diff --git a/speaktype/Views/Overlays/MiniRecorderView.swift b/speaktype/Views/Overlays/MiniRecorderView.swift index 0f34c34..1dd2689 100644 --- a/speaktype/Views/Overlays/MiniRecorderView.swift +++ b/speaktype/Views/Overlays/MiniRecorderView.swift @@ -275,7 +275,7 @@ struct MiniRecorderView: View { private var pillWidth: CGFloat { switch displayPhase { case .idle: return 58 - case .warming: return 200 + case .warming: return 280 // fits stage text + elapsed seconds case .processing: return 210 case .recording: return expanded ? 460 : 250 } @@ -314,13 +314,28 @@ struct MiniRecorderView: View { ProgressView() .controlSize(.small) .colorScheme(.dark) - Text("Warming up model...") - .font(Typography.pillLabel) - .foregroundColor(.white.opacity(0.9)) + // Cold loads run 30-60s; show the live stage and elapsed seconds so + // a long warm-up reads as progress, not a hang. + TimelineView(.periodic(from: .now, by: 1)) { _ in + Text(warmingLabel) + .font(Typography.pillLabel) + .foregroundColor(.white.opacity(0.9)) + .lineLimit(1) + } } .transition(.opacity) } + private var warmingLabel: String { + let stage = transcription.loadingStage + let base = stage.isEmpty ? "Warming up model..." : stage + if let started = transcription.loadingStartedAt { + let seconds = Int(Date().timeIntervalSince(started)) + if seconds >= 5 { return "\(base) (\(seconds)s)" } + } + return base + } + private var processingContent: some View { Text(statusMessage) .font(Typography.pillLabel) From 8f0fc6064f66ccf03cd0ab8934decbad5fdca43c Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:17:12 +0700 Subject: [PATCH 16/42] Let onboarding finish without Accessibility Setup hard-blocked on granting Accessibility even though dictation works fine without it (text lands on the clipboard instead of auto-pasting). Once the mic is granted, offer 'Continue without auto-paste' with a note explaining the tradeoff and where to enable it later. Also drop a duplicated frame modifier. --- .../Screens/Onboarding/OnboardingView.swift | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/speaktype/Views/Screens/Onboarding/OnboardingView.swift b/speaktype/Views/Screens/Onboarding/OnboardingView.swift index ce0c6cb..4dee034 100644 --- a/speaktype/Views/Screens/Onboarding/OnboardingView.swift +++ b/speaktype/Views/Screens/Onboarding/OnboardingView.swift @@ -35,7 +35,6 @@ struct OnboardingView: View { } } .frame(minWidth: 600, minHeight: 500) // Lower minimum size - .frame(minWidth: 600, minHeight: 500) // Lower minimum size } func completeOnboarding() { @@ -198,10 +197,27 @@ struct PermissionsPage: View { Spacer() - ContinueButton( - isEnabled: micStatus == .authorized && accessibilityStatus, - action: finishAction - ) + VStack(spacing: 10) { + ContinueButton( + isEnabled: micStatus == .authorized && accessibilityStatus, + action: finishAction + ) + + // Accessibility is optional: without it dictation still works, + // the text just lands on the clipboard instead of auto-pasting. + // Blocking setup on it left privacy-cautious users stuck here. + if micStatus == .authorized && !accessibilityStatus { + Button("Continue without auto-paste", action: finishAction) + .buttonStyle(.plain) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.textSecondary) + + Text("Transcriptions will be copied to the clipboard β€” paste with ⌘V.\nEnable auto-paste anytime in Settings β†’ Permissions.") + .font(.system(size: 11)) + .foregroundStyle(Color.textMuted) + .multilineTextAlignment(.center) + } + } .padding(.bottom, 48) } .padding(.horizontal, 60) From 932e1e058548c533b11ec15a52c8c9fcb4fa4d5c Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:17:57 +0700 Subject: [PATCH 17/42] Draw the real audio waveform in history playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WaveformView rendered Float.random noise re-rolled on every appearance, implying the bars represented the recording when they were decorative. Downsample the actual file into per-bucket peaks (16 kHz mono WAVs β€” a few ms of work, done off-main) and normalize so quiet recordings still show a shape. --- speaktype/Views/Components/WaveformView.swift | 63 +++++++++++++++---- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/speaktype/Views/Components/WaveformView.swift b/speaktype/Views/Components/WaveformView.swift index d28380c..f96e778 100644 --- a/speaktype/Views/Components/WaveformView.swift +++ b/speaktype/Views/Components/WaveformView.swift @@ -1,3 +1,4 @@ +import AVFoundation import SwiftUI /// Simple waveform visualization for audio playback @@ -55,20 +56,58 @@ struct WaveformView: View { return path } + /// Downsample the actual audio into per-bucket peaks. Recordings are + /// 16 kHz mono WAVs, so even a minutes-long file reads in a few ms; done + /// off-main regardless. (This used to render random noise re-rolled on + /// every appearance, which implied the bars meant something they didn't.) private func generateSamples() { - // Generate simple waveform samples - // In production, this would analyze actual audio data - // For now, generate random realistic-looking waveform - let sampleCount = 100 - var newSamples: [Float] = [] - - for i in 0.. [Float] { + guard let file = try? AVAudioFile(forReading: url) else { return [] } + + let frameCount = AVAudioFrameCount(file.length) + guard frameCount > 0, + let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, + frameCapacity: frameCount), + (try? file.read(into: buffer)) != nil, + let channel = buffer.floatChannelData?[0] + else { return [] } + + let total = Int(buffer.frameLength) + guard total > 0 else { return [] } + + let bucketSize = max(1, total / bucketCount) + var peaks: [Float] = [] + peaks.reserveCapacity(bucketCount) + + var index = 0 + while index < total && peaks.count < bucketCount { + var peak: Float = 0 + let end = min(index + bucketSize, total) + while index < end { + peak = max(peak, abs(channel[index])) + index += 1 + } + peaks.append(peak) + } + + // Normalize so quiet recordings still draw a visible shape. + if let maxPeak = peaks.max(), maxPeak > 0 { + peaks = peaks.map { min(1.0, $0 / maxPeak) } + } + return peaks } } From 8a5619aea8c46fd705fdb6a17f4b98bb0f4fcb84 Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:19:21 +0700 Subject: [PATCH 18/42] Guard download cancel races and pre-flight disk space - Progress callbacks now bail once a download is no longer active, so a cancelled transfer can't repaint a row the user already reset (or briefly mark a just-deleted model as installed at 100%). - Partial-download cleanup waits for the cancelled task to actually finish before deleting, instead of racing WhisperKit's in-flight writes and potentially leaving a tree the size scan later accepts. - Check free space (expected size + 20% margin) before starting a multi-GB download and fail immediately with an actionable message rather than minutes later with a raw write error. --- speaktype/Services/ModelDownloadService.swift | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift index 1fa7b74..df465b4 100644 --- a/speaktype/Services/ModelDownloadService.swift +++ b/speaktype/Services/ModelDownloadService.swift @@ -258,6 +258,14 @@ class ModelDownloadService: ObservableObject { return } + // Pre-flight free-space check: a full disk otherwise surfaces as a raw + // network/write error minutes into a multi-GB download. + if let shortfallMessage = Self.diskSpaceShortfallMessage(for: variant) { + downloadProgress[variant] = 0.0 + downloadError[variant] = shortfallMessage + return + } + // Route Parakeet variants to FluidAudio. if engineKind == .parakeet { downloadParakeetModel(variant: variant) @@ -292,6 +300,9 @@ class ModelDownloadService: ObservableObject { // likely: download(variant:progressCallback:) - 'from' usually has a default let _ = try await WhisperKit.download(variant: variant, downloadBase: ModelStorage.whisperKitBase, progressCallback: { progress in DispatchQueue.main.async { + // A cancelled download's transfer can keep reporting for a + // moment; don't repaint a row the user already reset. + guard self.isDownloading[variant] == true else { return } self.downloadProgress[variant] = progress.fractionCompleted } }) @@ -338,6 +349,7 @@ class ModelDownloadService: ObservableObject { do { let _ = try await WhisperKit.download(variant: variant, downloadBase: ModelStorage.whisperKitBase, progressCallback: { progress in DispatchQueue.main.async { + guard self.isDownloading[variant] == true else { return } self.downloadProgress[variant] = progress.fractionCompleted } }) @@ -398,6 +410,7 @@ class ModelDownloadService: ObservableObject { version: version, progressHandler: { progress in DispatchQueue.main.async { + guard self.isDownloading[variant] == true else { return } self.downloadProgress[variant] = progress.fractionCompleted } }) @@ -487,7 +500,8 @@ class ModelDownloadService: ObservableObject { } func cancelDownload(for variant: String) { - if let task = activeTasks[variant] { + let task = activeTasks[variant] + if let task { task.cancel() activeTasks[variant] = nil print("Cancelled download task for \(variant)") @@ -498,15 +512,38 @@ class ModelDownloadService: ObservableObject { downloadError[variant] = nil downloadStatus[variant] = nil - // Delete any partial download + // Delete any partial download β€” but only after the cancelled task has + // actually stopped, or the deletion races WhisperKit's in-flight writes + // and can leave a partial tree the size scan later marks as installed. Task { + _ = await task?.value let result = await deleteModel(variant: variant) print("πŸ—‘οΈ Cleaned up partial download: \(result)") } } // MARK: - Helper Functions - + + /// Returns an actionable error when the volume lacks room for the model + /// (expected size + 20% working margin), nil when there is space or the + /// check itself can't run. + static func diskSpaceShortfallMessage(for variant: String) -> String? { + guard let expectedSize = AIModel.expectedSize(for: variant) else { return nil } + + let home = URL(fileURLWithPath: NSHomeDirectory()) + guard let values = try? home.resourceValues( + forKeys: [.volumeAvailableCapacityForImportantUsageKey]), + let available = values.volumeAvailableCapacityForImportantUsage + else { return nil } + + let required = Int64(Double(expectedSize) * 1.2) + guard available < required else { return nil } + + return "Not enough disk space: this model needs about " + + "\(formatBytes(required)) free, but only " + + "\(formatBytes(available)) is available." + } + /// Calculate total size of a directory recursively static func calculateDirectorySize(at url: URL) -> Int64 { let fileManager = FileManager.default From 2990c215365fe8d4f2107bb4ed7b031f85197a8b Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:20:12 +0700 Subject: [PATCH 19/42] Coalesce live level/waveform publishes to 30 Hz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three @Published writes per audio buffer re-rendered the entire observing pill 50-100x/s while recording. Accumulate the peak between publishes and emit at most 30x/s β€” the waveform gets a uniform sample cadence and older machines stop burning CPU on invisible re-renders. --- .../Services/AudioRecordingService.swift | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index 2f83bad..85aa0c9 100644 --- a/speaktype/Services/AudioRecordingService.swift +++ b/speaktype/Services/AudioRecordingService.swift @@ -53,6 +53,13 @@ class AudioRecordingService: NSObject, ObservableObject { private var smoothedAudioLevel: Float = 0.0 private var smoothedAudioFrequency: Float = 0.0 + // Coalesce level/waveform publishes to ~30 Hz (audioQueue-confined). + // Publishing three @Published values per audio buffer re-rendered the + // whole observing pill 50-100x/s for no visible gain. + private var pendingWavePeak: Float = 0 + private var lastLevelPublishUptime: TimeInterval = 0 + private static let levelPublishInterval: TimeInterval = 1.0 / 30.0 + /// Buffers captured before the asset writer is ready (audioQueue-confined). /// Without this, everything the user says between pressing the hotkey and /// the writer being wired up β€” session cold start plus writer setup β€” was @@ -260,6 +267,8 @@ class AudioRecordingService: NSObject, ObservableObject { audioQueue.async { self.cancelIdleSessionStop() self.pendingSampleBuffers.removeAll() + self.pendingWavePeak = 0 + self.lastLevelPublishUptime = 0 } isRecording = true recordingStartTime = Date() @@ -656,10 +665,19 @@ extension AudioRecordingService: AVCaptureAudioDataOutputSampleBufferDelegate { smoothedAudioLevel += (normalizedLevel - smoothedAudioLevel) * levelSmoothing smoothedAudioFrequency += (normalizedFreq - smoothedAudioFrequency) * frequencySmoothing + pendingWavePeak = max(pendingWavePeak, min(1.0, peakLevel)) + + let now = ProcessInfo.processInfo.systemUptime + guard now - lastLevelPublishUptime >= Self.levelPublishInterval else { return } + lastLevelPublishUptime = now + + let wavePeak = pendingWavePeak + pendingWavePeak = 0 + DispatchQueue.main.async { self.audioLevel = self.smoothedAudioLevel self.audioFrequency = self.smoothedAudioFrequency - self.liveWaveSamples.append(min(1.0, peakLevel)) + self.liveWaveSamples.append(wavePeak) if self.liveWaveSamples.count > Self.maxLiveWaveSamples { self.liveWaveSamples.removeFirst( self.liveWaveSamples.count - Self.maxLiveWaveSamples) From f25ad31822a49a0f98503af8802059a885ff8fc9 Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:21:21 +0700 Subject: [PATCH 20/42] Allow cancelling a stalled update download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While installing, the update sheet replaced every control with a static 'Update in progress' label, so a hung DMG download trapped the user until force-quit. Add UpdateService.cancelInstall(), which invalidates the download session and resolves the pending continuation with a cancellation the install flow resets on silently (no error banner for a user-initiated cancel). The Cancel button appears only during the download phase β€” once the installer is touching disk, finishing is safer than stopping halfway. --- speaktype/Services/UpdateService.swift | 16 +++++++++++++++- speaktype/Views/Components/UpdateSheet.swift | 10 +++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/speaktype/Services/UpdateService.swift b/speaktype/Services/UpdateService.swift index 34e778c..efc4cf5 100644 --- a/speaktype/Services/UpdateService.swift +++ b/speaktype/Services/UpdateService.swift @@ -226,9 +226,11 @@ class UpdateService: NSObject, ObservableObject { relaunch() } catch { + let userCancelled: Bool + if case UpdateError.downloadCancelled = error { userCancelled = true } else { userCancelled = false } await MainActor.run { self.isInstalling = false - self.installError = error.localizedDescription + self.installError = userCancelled ? nil : error.localizedDescription self.installPhase = "" self.installStatus = "" } @@ -236,6 +238,16 @@ class UpdateService: NSObject, ObservableObject { } } + /// Abort an in-flight update download. Only the download phase is + /// cancellable; once installation starts touching disk, finishing is + /// safer than stopping halfway. Without this, a stalled download left + /// the update sheet stuck on "Update in progress" with no way out. + func cancelInstall() { + guard isInstalling, activeDownloadContinuation != nil else { return } + activeDownloadSession?.invalidateAndCancel() + finishDownload(.failure(UpdateError.downloadCancelled)) + } + // MARK: - Private Helpers private func downloadWithProgress(from url: URL) async throws -> URL { @@ -633,6 +645,7 @@ extension UpdateService: URLSessionDownloadDelegate { enum UpdateError: LocalizedError, Equatable { case downloadFailed(String) + case downloadCancelled case mountFailed case appNotFoundInDMG case copyFailed(String) @@ -645,6 +658,7 @@ enum UpdateError: LocalizedError, Equatable { var errorDescription: String? { switch self { case .downloadFailed(let msg): return "Failed to download update: \(msg)" + case .downloadCancelled: return "Update cancelled." case .mountFailed: return "Failed to mount the update disk image." case .appNotFoundInDMG: return "Could not find the app inside the downloaded update." case .copyFailed(let msg): return "Failed to install: \(msg)" diff --git a/speaktype/Views/Components/UpdateSheet.swift b/speaktype/Views/Components/UpdateSheet.swift index d3675c3..3786b20 100644 --- a/speaktype/Views/Components/UpdateSheet.swift +++ b/speaktype/Views/Components/UpdateSheet.swift @@ -125,11 +125,19 @@ struct UpdateSheet: View { // Action buttons HStack(spacing: 12) { if updateService.isInstalling { - // Show only a disabled cancel-style placeholder while work is in progress Text("Update in progress…") .font(Typography.bodySmall) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) + + // Cancellable only while downloading; after that the + // installer is touching disk and should run to completion. + if updateService.installPhase == "Downloading" { + Button("Cancel") { + updateService.cancelInstall() + } + .buttonStyle(SecondaryButtonStyle()) + } } else { Button("Skip This Version") { updateService.skipVersion(update.version) From 1cf905487d30ed2da656df5cc54b04958d3c92eb Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:37:30 +0700 Subject: [PATCH 21/42] Remove the KeyboardShortcuts package dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing calls its API β€” the imports and the one unused shortcut name were already deleted β€” so drop the package reference, product dependency, and framework link from the project. Package.resolved regenerated by xcodebuild without the entry. --- speaktype.xcodeproj/project.pbxproj | 17 ----------------- .../xcshareddata/swiftpm/Package.resolved | 11 +---------- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/speaktype.xcodeproj/project.pbxproj b/speaktype.xcodeproj/project.pbxproj index f8ee77b..829c663 100644 --- a/speaktype.xcodeproj/project.pbxproj +++ b/speaktype.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 1C547F2B2F0E7DC0008120A6 /* WhisperKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1C547F2A2F0E7DC0008120A6 /* WhisperKit */; }; - 1CE4CB3F2F0EF6AF00C01C62 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = 1CE4CB3E2F0EF6AF00C01C62 /* KeyboardShortcuts */; }; 1CE4CB412F0EF86B00C01C62 /* whisperkit-cli in Frameworks */ = {isa = PBXBuildFile; productRef = 1CE4CB402F0EF86B00C01C62 /* whisperkit-cli */; }; FA0000000000000000000003 /* FluidAudio in Frameworks */ = {isa = PBXBuildFile; productRef = FA0000000000000000000002 /* FluidAudio */; }; /* End PBXBuildFile section */ @@ -73,7 +72,6 @@ buildActionMask = 2147483647; files = ( 1C547F2B2F0E7DC0008120A6 /* WhisperKit in Frameworks */, - 1CE4CB3F2F0EF6AF00C01C62 /* KeyboardShortcuts in Frameworks */, 1CE4CB412F0EF86B00C01C62 /* whisperkit-cli in Frameworks */, FA0000000000000000000003 /* FluidAudio in Frameworks */, ); @@ -145,7 +143,6 @@ name = speaktype; packageProductDependencies = ( 1C547F2A2F0E7DC0008120A6 /* WhisperKit */, - 1CE4CB3E2F0EF6AF00C01C62 /* KeyboardShortcuts */, 1CE4CB402F0EF86B00C01C62 /* whisperkit-cli */, FA0000000000000000000002 /* FluidAudio */, ); @@ -233,7 +230,6 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 1C547F222F0E7D07008120A6 /* XCRemoteSwiftPackageReference "WhisperKit" */, - 1CE4CB3D2F0EF6AF00C01C62 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */, FA0000000000000000000001 /* XCRemoteSwiftPackageReference "FluidAudio" */, ); preferredProjectObjectVersion = 77; @@ -624,14 +620,6 @@ version = 0.9.4; }; }; - 1CE4CB3D2F0EF6AF00C01C62 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/sindresorhus/KeyboardShortcuts"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.4.0; - }; - }; FA0000000000000000000001 /* XCRemoteSwiftPackageReference "FluidAudio" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/FluidInference/FluidAudio.git"; @@ -648,11 +636,6 @@ package = 1C547F222F0E7D07008120A6 /* XCRemoteSwiftPackageReference "WhisperKit" */; productName = WhisperKit; }; - 1CE4CB3E2F0EF6AF00C01C62 /* KeyboardShortcuts */ = { - isa = XCSwiftPackageProductDependency; - package = 1CE4CB3D2F0EF6AF00C01C62 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */; - productName = KeyboardShortcuts; - }; 1CE4CB402F0EF86B00C01C62 /* whisperkit-cli */ = { isa = XCSwiftPackageProductDependency; package = 1C547F222F0E7D07008120A6 /* XCRemoteSwiftPackageReference "WhisperKit" */; diff --git a/speaktype.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/speaktype.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index be936cd..cc773c3 100644 --- a/speaktype.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/speaktype.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0f11857f5bee9050d6db2d9fba0b6a89cfb4e35548e40029a5b22ec281d8572b", + "originHash" : "eb937faf75b1e6f87de96fd79ed81e2a3e51c32ce7d938a365ec88992fb4a244", "pins" : [ { "identity" : "fluidaudio", @@ -10,15 +10,6 @@ "version" : "0.15.4" } }, - { - "identity" : "keyboardshortcuts", - "kind" : "remoteSourceControl", - "location" : "https://github.com/sindresorhus/KeyboardShortcuts", - "state" : { - "revision" : "1aef85578fdd4f9eaeeb8d53b7b4fc31bf08fe27", - "version" : "2.4.0" - } - }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", From ea48c679bc1105749ae78fbb2d86ebf97e281267 Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:42:50 +0700 Subject: [PATCH 22/42] Rewrite UI tests against the current interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old suite asserted on 'SpeakType Shortcuts' text and a 'Permissions' sidebar item, neither of which exists β€” it could never pass and guarded nothing. The new tests walk every real sidebar destination asserting a marker each screen renders unconditionally, and exercise the Settings General/Audio/Permissions tabs. Verified via build-for-testing only: executing XCUITests locally requires a code-signing identity (Gatekeeper refuses ad-hoc-signed test runners), which this environment lacks. --- speaktypeUITests/speaktypeUITests.swift | 91 +++++++++++++++---------- 1 file changed, 56 insertions(+), 35 deletions(-) diff --git a/speaktypeUITests/speaktypeUITests.swift b/speaktypeUITests/speaktypeUITests.swift index eed51b3..8b38a79 100644 --- a/speaktypeUITests/speaktypeUITests.swift +++ b/speaktypeUITests/speaktypeUITests.swift @@ -6,46 +6,67 @@ final class speaktypeUITests: XCTestCase { continueAfterFailure = false } - func testAppLaunchAndNavigation() throws { + private func launchApp() -> XCUIApplication { let app = XCUIApplication() app.launchArguments = ["--uitesting"] app.launch() - - // Wait for app to fully load - sleep(2) - - // Navigate to Settings - look for the link directly - let settingsLink = app.links["Settings"] - XCTAssertTrue(settingsLink.waitForExistence(timeout: 5.0), "Settings link should exist") - - settingsLink.click() - - // Verify we are on Settings View - let settingsContent = app.staticTexts["SpeakType Shortcuts"] - XCTAssertTrue(settingsContent.waitForExistence(timeout: 5.0), "Should find Settings content") + return app } - + + /// Every sidebar destination opens and shows content unique to that screen. + /// Markers are texts each screen renders unconditionally, so the assertions + /// hold on a fresh install (no models, no history). func testSidebarNavigation() throws { - let app = XCUIApplication() - app.launchArguments = ["--uitesting"] - app.launch() - - // Wait for app to fully load - sleep(2) - - // Define sidebar items to test - these should appear as NavigationLinks - let items = ["Dashboard", "Transcribe Audio", "History", "AI Models", "Permissions", "Settings"] - - for item in items { - let link = app.links[item] - XCTAssertTrue(link.exists, "Link for '\(item)' should exist") - - if link.exists { - link.click() - // Just verify we can click without crashing - sleep(1) - } + let app = launchApp() + + let destinations: [(button: String, marker: String)] = [ + ("Transcribe Audio", "Drop audio or video file here"), + ("History", "History"), + ("Statistics", "Statistics"), + ("AI Models", "CURRENTLY USING"), + ("Settings", "Primary Hotkey"), + ("Dashboard", "Recent transcriptions"), + ] + + for (button, marker) in destinations { + let sidebarButton = app.buttons[button] + XCTAssertTrue( + sidebarButton.waitForExistence(timeout: 5.0), + "Sidebar button '\(button)' should exist" + ) + sidebarButton.click() + + XCTAssertTrue( + app.staticTexts[marker].waitForExistence(timeout: 5.0), + "'\(button)' screen should show '\(marker)'" + ) } } -} + /// The Settings screen's tab bar switches between General, Audio, and + /// Permissions content. + func testSettingsTabs() throws { + let app = launchApp() + + let settingsButton = app.buttons["Settings"] + XCTAssertTrue(settingsButton.waitForExistence(timeout: 5.0)) + settingsButton.click() + + XCTAssertTrue( + app.staticTexts["Primary Hotkey"].waitForExistence(timeout: 5.0), + "General tab should be selected by default" + ) + + app.buttons["Audio"].click() + XCTAssertTrue( + app.buttons["Refresh Devices"].waitForExistence(timeout: 5.0), + "Audio tab should show the device list controls" + ) + + app.buttons["Permissions"].click() + XCTAssertTrue( + app.staticTexts["App Permissions"].waitForExistence(timeout: 5.0), + "Permissions tab should show the permissions list" + ) + } +} From 3e7bd110bfbbe8bb6c784e81cacd179bab63f45d Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 18:43:24 +0700 Subject: [PATCH 23/42] Show the configured hotkey in Dashboard's empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same fix HistoryView already got: the hint hardcoded ⌘+Shift+Space, which has never been the default (Fn) or necessarily the user's choice. --- speaktype/Views/Screens/Dashboard/DashboardView.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/speaktype/Views/Screens/Dashboard/DashboardView.swift b/speaktype/Views/Screens/Dashboard/DashboardView.swift index 07bf334..330c424 100644 --- a/speaktype/Views/Screens/Dashboard/DashboardView.swift +++ b/speaktype/Views/Screens/Dashboard/DashboardView.swift @@ -5,6 +5,12 @@ import SwiftUI import UniformTypeIdentifiers struct DashboardView: View { + @AppStorage("selectedHotkey") private var selectedHotkeyRaw = HotkeyOption.default.rawValue + + private var hotkeyDisplayName: String { + HotkeyOption(rawValue: selectedHotkeyRaw)?.displayName ?? HotkeyOption.default.displayName + } + @Binding var selection: SidebarItem? @StateObject private var historyService = HistoryService.shared @StateObject private var audioRecorder = AudioRecordingService() @@ -136,7 +142,7 @@ struct DashboardView: View { .font(Typography.bodyMedium) .foregroundStyle(Color.textPrimary) - Text("Press ⌘+Shift+Space to start recording") + Text("Press \(hotkeyDisplayName) to start recording") .font(Typography.bodySmall) .foregroundStyle(Color.textSecondary) } From fd88eb8cc87d69e591743a903bfa2f2de0fbe0e7 Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 19:50:51 +0700 Subject: [PATCH 24/42] Fix watchdog killing legitimate first loads; make stale loads throw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 180s load watchdog fired mid-load on a healthy 16GB machine: first load of a large model includes CoreML/ANE specialization, which legitimately runs several minutes, so the 'timeout' reported a working load as failed (this is what broke loading whisper-large-v3_turbo). Raise the ceiling to 600s/1200s β€” it exists to catch wedged loads, not to bound performance β€” and reword the error, which wrongly blamed RAM. Also from review: a load that finds its state reset by a concurrent unload now throws CancellationError instead of returning normally, and both selection flows re-check the model is still downloaded before selecting β€” deleting a model during warm-up could otherwise re-select it. ParakeetEngine gets the same protection (tracks its in-flight variant, unload cancels a warm-up, stale completions clean up and throw). --- .../Transcription/ParakeetEngine.swift | 21 +++++++++++++++++-- speaktype/Services/WhisperService.swift | 20 +++++++++++------- speaktype/Views/Components/ModelRow.swift | 7 ++++++- .../Views/Screens/Settings/AIModelsView.swift | 6 +++++- 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/speaktype/Services/Transcription/ParakeetEngine.swift b/speaktype/Services/Transcription/ParakeetEngine.swift index dfd1990..8116d07 100644 --- a/speaktype/Services/Transcription/ParakeetEngine.swift +++ b/speaktype/Services/Transcription/ParakeetEngine.swift @@ -42,6 +42,8 @@ class ParakeetEngine: SpeechToTextEngine { /// FluidAudio's actor that owns the loaded CoreML models. private var manager: AsrManager? + /// Variant currently being loaded, so unload can cancel a warm-up too. + private var loadingVariant = "" private init() {} @@ -55,8 +57,12 @@ class ParakeetEngine: SpeechToTextEngine { isLoading = true isInitialized = false + loadingVariant = variant loadingStage = "Preparing Parakeet model…" - defer { isLoading = false } + defer { + isLoading = false + loadingVariant = "" + } // Release any previously loaded model before loading a new one. if let manager { @@ -77,9 +83,19 @@ class ParakeetEngine: SpeechToTextEngine { throw WhisperService.TranscriptionError.modelFilesMissing } let models = try await AsrModels.load(from: cacheDir, version: version) + + // The model may have been deleted/unloaded while loading; don't + // resurrect it, and don't report success to the caller. + guard loadingVariant == variant else { throw CancellationError() } + let manager = AsrManager(config: .default) try await manager.loadModels(models) + guard loadingVariant == variant else { + await manager.cleanup() + throw CancellationError() + } + self.manager = manager currentModelVariant = variant isInitialized = true @@ -87,7 +103,8 @@ class ParakeetEngine: SpeechToTextEngine { } func unloadModelIfCurrent(variant: String) async { - guard currentModelVariant == variant else { return } + guard currentModelVariant == variant || loadingVariant == variant else { return } + loadingVariant = "" if let manager { await manager.cleanup() diff --git a/speaktype/Services/WhisperService.swift b/speaktype/Services/WhisperService.swift index ea280b6..3da160c 100644 --- a/speaktype/Services/WhisperService.swift +++ b/speaktype/Services/WhisperService.swift @@ -83,7 +83,8 @@ class WhisperService { case .fileNotFound: return "Audio file not found" case .alreadyLoading: return "Model loading already in progress" case .loadingTimeout: - return "Model loading timed out β€” your Mac may not have enough RAM for this model" + return "Model loading stalled and was stopped. Try again β€” if it keeps happening, " + + "re-download the model or pick a smaller one." case .unsupportedModelVariant: return "The selected model is not supported." case .modelFilesMissing: @@ -248,9 +249,10 @@ class WhisperService { let timeout = Self.loadTimeout(ramGB: ramGB, minimumRAMGB: model.minimumRAMGB) let loadedPipe = try await Self.loadPipelineWithTimeout(config: config, timeout: timeout) - // A concurrent unload may have reset state while we were loading; - // don't resurrect it with a stale pipeline. - guard loadingModelVariant == variant else { return } + // A concurrent unload reset state while we were loading; surface + // that as cancellation β€” returning normally made callers treat a + // deleted model as successfully loaded and re-select it. + guard loadingModelVariant == variant else { throw CancellationError() } pipe = loadedPipe let loadDuration = Date().timeIntervalSince(loadStart) @@ -275,11 +277,13 @@ class WhisperService { } } - /// Generous ceiling: warm loads take seconds and documented cold loads - /// 30–60s, so only a genuinely wedged load hits this. Doubled when the - /// device is below the model's recommended RAM (swapping is slow, not stuck). + /// Ceiling for a wedged load, NOT a performance bound: the first load of a + /// large model includes CoreML/ANE specialization, which legitimately runs + /// several minutes (a 180s value here fired mid-load on a 16GB machine and + /// reported a healthy load as failed). Longer still below recommended RAM, + /// where swapping is slow but not stuck. static func loadTimeout(ramGB: Int, minimumRAMGB: Int) -> TimeInterval { - ramGB < minimumRAMGB ? 360 : 180 + ramGB < minimumRAMGB ? 1200 : 600 } /// Awaits WhisperKit init, but never longer than `timeout`. A task group diff --git a/speaktype/Views/Components/ModelRow.swift b/speaktype/Views/Components/ModelRow.swift index c8cdd41..894885c 100644 --- a/speaktype/Views/Components/ModelRow.swift +++ b/speaktype/Views/Components/ModelRow.swift @@ -245,7 +245,12 @@ struct ModelRow: View { do { try await transcription.loadModel(variant: model.variant) await MainActor.run { - stopLoadingTimer(); isLoadingModel = false; selectedModel = model.variant + stopLoadingTimer(); isLoadingModel = false + // Re-check before selecting: the model may have been + // deleted while the load was in flight. + if downloadService.downloadProgress[model.variant] ?? 0 >= 1.0 { + selectedModel = model.variant + } } } catch { await MainActor.run { diff --git a/speaktype/Views/Screens/Settings/AIModelsView.swift b/speaktype/Views/Screens/Settings/AIModelsView.swift index c89a315..c3e2cf8 100644 --- a/speaktype/Views/Screens/Settings/AIModelsView.swift +++ b/speaktype/Views/Screens/Settings/AIModelsView.swift @@ -241,7 +241,11 @@ struct AIModelsView: View { do { try await TranscriptionManager.shared.loadModel(variant: model.variant) await MainActor.run { - selectedModel = model.variant + // Re-check before selecting: the model may have been + // deleted while the load was in flight. + if downloadService.downloadProgress[model.variant] ?? 0 >= 1.0 { + selectedModel = model.variant + } isLoadingRecommendedModel = false } } catch { From 7b5212ba010beeff3f47899d8d5d3d8e65c3879d Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 19:50:51 +0700 Subject: [PATCH 25/42] Don't let cancelled-download cleanup delete a retry's files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cancelDownload re-enables the Download button immediately, so a user could start a fresh download while the old task was still unwinding β€” whose deferred cleanup then deleted the new download's files. The cleanup now re-checks on the main actor that no new download is in flight for the variant before deleting. --- speaktype/Services/ModelDownloadService.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift index df465b4..569223b 100644 --- a/speaktype/Services/ModelDownloadService.swift +++ b/speaktype/Services/ModelDownloadService.swift @@ -513,10 +513,17 @@ class ModelDownloadService: ObservableObject { downloadStatus[variant] = nil // Delete any partial download β€” but only after the cancelled task has - // actually stopped, or the deletion races WhisperKit's in-flight writes - // and can leave a partial tree the size scan later marks as installed. + // actually stopped (or the deletion races WhisperKit's in-flight + // writes), and only if the user hasn't started a fresh download for + // this variant in the meantime (or the cleanup would delete the + // retry's files out from under it). Task { _ = await task?.value + let restarted = await MainActor.run { self.isDownloading[variant] == true } + guard !restarted else { + print("↩️ Skipping partial-download cleanup for \(variant): a new download is in flight") + return + } let result = await deleteModel(variant: variant) print("πŸ—‘οΈ Cleaned up partial download: \(result)") } From 0680a5215547e0a5013d85ff416d97fccf64275a Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 19:50:51 +0700 Subject: [PATCH 26/42] Wait for the mic-permission answer before recording starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On first run, startRecording fired the permission prompt and immediately began 'recording' into a mic it might never get β€” even a granted prompt produced an empty take. Recording now begins in the requestAccess callback when granted, and not at all when denied; the pill already handles a nil stop gracefully. Removes the fire-and- forget requestPermission. --- .../Services/AudioRecordingService.swift | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index 85aa0c9..83ecb23 100644 --- a/speaktype/Services/AudioRecordingService.swift +++ b/speaktype/Services/AudioRecordingService.swift @@ -251,8 +251,30 @@ class AudioRecordingService: NSObject, ObservableObject { } func startRecording() { - requestPermission() + guard !isRecording else { return } + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + beginAuthorizedRecording() + case .notDetermined: + // First run: wait for the user's answer to the system prompt. + // Starting immediately used to record into a mic we might never + // get, producing an empty take even when the user granted access. + AVCaptureDevice.requestAccess(for: .audio) { granted in + DispatchQueue.main.async { + guard granted else { + print("Microphone access denied") + return + } + self.beginAuthorizedRecording() + } + } + default: + print("Microphone access denied") + } + } + + private func beginAuthorizedRecording() { guard !isRecording else { return } if captureSession == nil { setupSession() } @@ -418,16 +440,6 @@ class AudioRecordingService: NSObject, ObservableObject { } } - func requestPermission() { - switch AVCaptureDevice.authorizationStatus(for: .audio) { - case .authorized: break - case .notDetermined: - AVCaptureDevice.requestAccess(for: .audio) { _ in } - default: - print("Microphone access denied") - } - } - static func recordingsDirectory() -> URL { // Use Application Support instead of Documents for app-managed storage let appSupport = FileManager.default.urls( From ff182aeba9b7fed30274305c33f09d0a22a2fbd7 Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 20:03:14 +0700 Subject: [PATCH 27/42] Explain the one-time model optimization during first load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first load after downloading a model runs CoreML/ANE specialization β€” minutes for large models, cached by macOS afterward β€” but the UI presented it like any other load, which reads as a hang (and is exactly what made the too-tight watchdog look plausible). Track per-variant first-load completion and, during that one load, say 'First-time setup β€” optimizing model for your Mac' in the pill, the model row (with an honest few-minutes estimate replacing the 10-30s tooltip), and the recommended-model button. README's stale 30-60s note updated to match. --- README.md | 7 +++--- .../Transcription/ParakeetEngine.swift | 1 + speaktype/Services/WhisperService.swift | 23 +++++++++++++++++++ speaktype/Views/Components/ModelRow.swift | 11 +++++++-- .../Views/Overlays/MiniRecorderView.swift | 8 ++++++- .../Views/Screens/Settings/AIModelsView.swift | 5 +++- 6 files changed, 48 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3e07a50..f2f95c7 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,10 @@ make dmg # Create DMG installer ### Current Issues -⚠️ When loading a model for the first time / switching to another model, there is a startup delay of 30-60 seconds. - -So the first transcription will appear ultra slow, but it will go back to instantaneous dictation right after it's warmed up. +⚠️ The first load of a freshly downloaded model runs a one-time CoreML/Neural Engine +optimization pass β€” around 30-60 seconds for small models and up to a few minutes for +large ones. The app shows a "First-time setup β€” optimizing model for your Mac" status +while this runs; macOS caches the result, so every later load takes seconds. ### Project Structure diff --git a/speaktype/Services/Transcription/ParakeetEngine.swift b/speaktype/Services/Transcription/ParakeetEngine.swift index 8116d07..4e8eb5c 100644 --- a/speaktype/Services/Transcription/ParakeetEngine.swift +++ b/speaktype/Services/Transcription/ParakeetEngine.swift @@ -96,6 +96,7 @@ class ParakeetEngine: SpeechToTextEngine { throw CancellationError() } + WhisperService.markFirstLoadCompletedForUI(variant: variant) self.manager = manager currentModelVariant = variant isInitialized = true diff --git a/speaktype/Services/WhisperService.swift b/speaktype/Services/WhisperService.swift index 3da160c..e25ad04 100644 --- a/speaktype/Services/WhisperService.swift +++ b/speaktype/Services/WhisperService.swift @@ -6,6 +6,7 @@ class WhisperService { // Shared singleton instance - use this everywhere static let shared = WhisperService() private static let autoEditEnabledKey = "enableAutoEdit" + private static let warmedUpVariantsKey = "modelWarmupCompletedVariants" private static let customReplacementRulesKey = "customReplacementRules" private static let placeholderPatterns = [ #"\[(?:BLANK_AUDIO|SILENCE)\]"#, @@ -64,6 +65,27 @@ class WhisperService { @MainActor private var activeLoadVariant: String = "" @MainActor private var activeLoadToken: UUID? + /// Whether this variant has ever completed a load on this machine. The + /// first load after download includes CoreML/ANE specialization (minutes + /// for large models, one-time, OS-cached); the UI uses this to set + /// expectations instead of looking hung. + static func hasCompletedFirstLoad(variant: String) -> Bool { + let done = UserDefaults.standard.stringArray(forKey: warmedUpVariantsKey) ?? [] + return done.contains(variant) + } + + /// Same as the private marker, callable from other engines (Parakeet). + static func markFirstLoadCompletedForUI(variant: String) { + markFirstLoadCompleted(variant: variant) + } + + private static func markFirstLoadCompleted(variant: String) { + var done = UserDefaults.standard.stringArray(forKey: warmedUpVariantsKey) ?? [] + guard !done.contains(variant) else { return } + done.append(variant) + UserDefaults.standard.set(done, forKey: warmedUpVariantsKey) + } + /// Device RAM in GB (cached on init) static let deviceRAMGB: Int = { Int(ProcessInfo.processInfo.physicalMemory / (1024 * 1024 * 1024)) @@ -259,6 +281,7 @@ class WhisperService { lastLoadDuration = loadDuration print("⏱️ Model loaded in \(String(format: "%.1f", loadDuration))s") + Self.markFirstLoadCompleted(variant: variant) currentModelVariant = variant isInitialized = true isLoading = false diff --git a/speaktype/Views/Components/ModelRow.swift b/speaktype/Views/Components/ModelRow.swift index 894885c..595aec1 100644 --- a/speaktype/Views/Components/ModelRow.swift +++ b/speaktype/Views/Components/ModelRow.swift @@ -28,6 +28,7 @@ struct ModelRow: View { var isActive: Bool { selectedModel == model.variant } var downloadError: String? { downloadService.downloadError[model.variant] } var downloadStatus: String? { downloadService.downloadStatus[model.variant] } + var isFirstLoad: Bool { !WhisperService.hasCompletedFirstLoad(variant: model.variant) } // MARK: - Body @@ -192,13 +193,19 @@ struct ModelRow: View { .background(Capsule().fill(Color.textPrimary.opacity(0.08))) .foregroundStyle(Color.textSecondary) - if loadingElapsed > 15 { + if isFirstLoad { + Text("Optimizing for your Mac β€” first load can take a few minutes") + .font(Typography.ui(10)) + .foregroundStyle(Color.textMuted) + } else if loadingElapsed > 15 { Text(loadingElapsed > 30 ? "Taking longer than expected…" : "\(Int(loadingElapsed))s") .font(Typography.ui(10)) .foregroundStyle(loadingElapsed > 30 ? Color.accentWarning : Color.textMuted) } } - .help("First load may take 10-30 seconds") + .help(isFirstLoad + ? "The first load compiles the model for the Neural Engine (one-time, a few minutes for large models)" + : "Loading usually takes a few seconds") } private var downloadProgressSection: some View { diff --git a/speaktype/Views/Overlays/MiniRecorderView.swift b/speaktype/Views/Overlays/MiniRecorderView.swift index 1dd2689..0a20993 100644 --- a/speaktype/Views/Overlays/MiniRecorderView.swift +++ b/speaktype/Views/Overlays/MiniRecorderView.swift @@ -327,8 +327,14 @@ struct MiniRecorderView: View { } private var warmingLabel: String { + // The first load after download includes a one-time CoreML/ANE + // compile that can run minutes for large models; say so, or the + // pill reads as hung. + let firstLoad = !WhisperService.hasCompletedFirstLoad(variant: selectedModel) let stage = transcription.loadingStage - let base = stage.isEmpty ? "Warming up model..." : stage + let base = firstLoad + ? "First-time setup β€” optimizing model for your Mac…" + : (stage.isEmpty ? "Warming up model..." : stage) if let started = transcription.loadingStartedAt { let seconds = Int(Date().timeIntervalSince(started)) if seconds >= 5 { return "\(base) (\(seconds)s)" } diff --git a/speaktype/Views/Screens/Settings/AIModelsView.swift b/speaktype/Views/Screens/Settings/AIModelsView.swift index c3e2cf8..daac23f 100644 --- a/speaktype/Views/Screens/Settings/AIModelsView.swift +++ b/speaktype/Views/Screens/Settings/AIModelsView.swift @@ -209,7 +209,10 @@ struct AIModelsView: View { } else if downloaded && isLoadingRecommendedModel { HStack(spacing: 9) { Spinner(size: 13, lineWidth: 2, tint: Color.textSecondary) - Text("Loading model…").font(Typography.uiBold(13)) + Text(WhisperService.hasCompletedFirstLoad(variant: rec.variant) + ? "Loading model…" + : "Optimizing for your Mac β€” first load can take a few minutes…") + .font(Typography.uiBold(13)) } .foregroundStyle(Color.textSecondary) .padding(.horizontal, 16).padding(.vertical, 10) From 25c501cdf68f28a9b72c95b412e4f3202317e482 Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 20:09:27 +0700 Subject: [PATCH 28/42] Add Option+Space as a hotkey choice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hotkey system only supported bare modifier keys held alone; this adds the first modifier+key chord. Chords start on the key's keyDown (with the modifier held) and stop when the modifier is released, so hold-to-record and toggle modes both behave like the existing options. The suppressing event tap gains keyDown/keyUp in its mask β€” only when a chord is selected, so modifier-hotkey users keep the narrow flagsChanged-only mask β€” and swallows the chord's key (plus repeats and the trailing keyUp) so Option+Space doesn't also type into the target app. Without Accessibility the tap doesn't exist; NSEvent fallback monitors still trigger the chord, just without suppression. Because the tap mask now depends on the selection, the monitoring stack rebuilds when the hotkey setting changes. --- speaktype/App/AppDelegate.swift | 150 +++++++++++++++++++++++++++- speaktype/Models/HotkeyOption.swift | 15 ++- 2 files changed, 161 insertions(+), 4 deletions(-) diff --git a/speaktype/App/AppDelegate.swift b/speaktype/App/AppDelegate.swift index f23c0ce..468c36f 100644 --- a/speaktype/App/AppDelegate.swift +++ b/speaktype/App/AppDelegate.swift @@ -13,6 +13,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var lastHandledHotkeyPressedState = false private var globalKeyDownMonitor: Any? private var localKeyDownMonitor: Any? + private var globalChordMonitor: Any? + private var localChordMonitor: Any? + private var configuredHotkey: HotkeyOption? private let updateCheckScheduler = NSBackgroundActivityScheduler( identifier: "com.2048labs.speaktype.update-check") @@ -30,6 +33,14 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Setup dynamic hotkey monitoring based on user selection setupHotkeyMonitoring() + // Chord hotkeys need a different event-tap mask than bare modifiers, + // so rebuild the monitoring stack when the selection changes. + NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, object: nil, queue: .main + ) { [weak self] _ in + self?.reconfigureHotkeyMonitoringIfNeeded() + } + schedulePeriodicUpdateChecks() checkForUpdatesOnLaunch() @@ -171,7 +182,37 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Hotkey Monitoring + private func reconfigureHotkeyMonitoringIfNeeded() { + let current = getSelectedHotkey() + guard current != configuredHotkey else { return } + teardownHotkeyMonitoring() + setupHotkeyMonitoring() + } + + private func teardownHotkeyMonitoring() { + if let globalFlagsMonitor { + NSEvent.removeMonitor(globalFlagsMonitor) + self.globalFlagsMonitor = nil + } + if let localFlagsMonitor { + NSEvent.removeMonitor(localFlagsMonitor) + self.localFlagsMonitor = nil + } + removeChordFallbackMonitors() + removeModifierComboMonitors() + if let hotkeyEventTap { + CGEvent.tapEnable(tap: hotkeyEventTap, enable: false) + if let hotkeyEventTapSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), hotkeyEventTapSource, .commonModes) + } + CFMachPortInvalidate(hotkeyEventTap) + self.hotkeyEventTap = nil + self.hotkeyEventTapSource = nil + } + } + private func setupHotkeyMonitoring() { + configuredHotkey = getSelectedHotkey() setupSuppressingHotkeyEventTap() // Add global monitor for hotkey events @@ -191,6 +232,54 @@ class AppDelegate: NSObject, NSApplicationDelegate { // while the hotkey is held β€” see installModifierComboMonitors(). Keeping // them alive for the app's lifetime woke SpeakType on every keystroke // system-wide just to bail on the isHotkeyPressed guard. + + // Chord hotkeys start on a keyDown the event tap normally owns; if the + // tap couldn't be created (no Accessibility grant), fall back to NSEvent + // monitors β€” they can't suppress the keystroke, but the chord still works. + if getSelectedHotkey().isChord && hotkeyEventTap == nil { + installChordFallbackMonitors() + } + } + + private func installChordFallbackMonitors() { + guard globalChordMonitor == nil && localChordMonitor == nil else { return } + + globalChordMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { + [weak self] event in + self?.handleChordKeyDownEvent(event) + } + localChordMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { + [weak self] event in + guard let self, self.handleChordKeyDownEvent(event) else { return event } + return nil + } + } + + private func removeChordFallbackMonitors() { + if let globalChordMonitor { + NSEvent.removeMonitor(globalChordMonitor) + self.globalChordMonitor = nil + } + if let localChordMonitor { + NSEvent.removeMonitor(localChordMonitor) + self.localChordMonitor = nil + } + } + + /// NSEvent fallback for chord start. Returns true when the event was the + /// chord (so local monitors can swallow it). + @discardableResult + private func handleChordKeyDownEvent(_ event: NSEvent) -> Bool { + let currentHotkey = getSelectedHotkey() + guard currentHotkey.isChord, + event.keyCode == currentHotkey.keyCode, + event.modifierFlags.contains(currentHotkey.modifierFlag) + else { return false } + + if !event.isARepeat { + handleHotkeyStateChange(isPressed: true) + } + return true } /// Installed only for the duration of a hotkey hold; removed on release. @@ -223,7 +312,15 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func setupSuppressingHotkeyEventTap() { guard hotkeyEventTap == nil else { return } - let eventMask = (1 << CGEventType.flagsChanged.rawValue) + // Bare-modifier hotkeys only need flagsChanged; chords also need + // keyDown/keyUp (to trigger on, and suppress, the chord's key). The + // narrow mask keeps every keystroke from waking the app for the + // majority who use a modifier hotkey. + var eventMask = (1 << CGEventType.flagsChanged.rawValue) + if getSelectedHotkey().isChord { + eventMask |= (1 << CGEventType.keyDown.rawValue) + eventMask |= (1 << CGEventType.keyUp.rawValue) + } let callback: CGEventTapCallBack = { _, type, event, refcon in guard let refcon else { return Unmanaged.passUnretained(event) @@ -263,16 +360,52 @@ class AppDelegate: NSObject, NSApplicationDelegate { return Unmanaged.passUnretained(event) } + let currentHotkey = getSelectedHotkey() + let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) + + if type == .keyDown || type == .keyUp { + // Only chords subscribe to key events. Trigger on modifier+key, + // and swallow the chord's key so it doesn't also type into the + // target app (repeats and the trailing keyUp included). + guard currentHotkey.isChord, keyCode == currentHotkey.keyCode else { + return Unmanaged.passUnretained(event) + } + + if type == .keyDown && event.flags.contains(.maskAlternate) { + if !isHotkeyPressed { + DispatchQueue.main.async { [weak self] in + self?.handleHotkeyStateChange(isPressed: true) + } + } + return nil + } + + if isHotkeyPressed { + return nil + } + return Unmanaged.passUnretained(event) + } + guard type == .flagsChanged else { return Unmanaged.passUnretained(event) } - let currentHotkey = getSelectedHotkey() + // Chord stop: the modifier was released while the chord was active. + // The flagsChanged event itself passes through β€” other apps still need + // to see the modifier go up. + if currentHotkey.isChord { + if isHotkeyPressed && !event.flags.contains(.maskAlternate) { + DispatchQueue.main.async { [weak self] in + self?.handleHotkeyStateChange(isPressed: false) + } + } + return Unmanaged.passUnretained(event) + } + guard currentHotkey == .fn else { return Unmanaged.passUnretained(event) } - let keyCode = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) guard keyCode == currentHotkey.keyCode else { return Unmanaged.passUnretained(event) } @@ -292,6 +425,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { // (and swallows those events); an Fn event that still reaches this // monitor is a late duplicate β€” acting on it would double-toggle. if currentHotkey == .fn && hotkeyEventTap != nil { return } + + // Chord release fallback when the tap is unavailable: stop once the + // modifier goes up. Chord *start* comes from the keyDown monitors. + if currentHotkey.isChord { + guard hotkeyEventTap == nil else { return } + if isHotkeyPressed && !event.modifierFlags.contains(currentHotkey.modifierFlag) { + handleHotkeyStateChange(isPressed: false) + } + return + } + guard event.keyCode == currentHotkey.keyCode else { return } let isPressed = event.modifierFlags.contains(currentHotkey.modifierFlag) diff --git a/speaktype/Models/HotkeyOption.swift b/speaktype/Models/HotkeyOption.swift index bdb3743..d3fc3a8 100644 --- a/speaktype/Models/HotkeyOption.swift +++ b/speaktype/Models/HotkeyOption.swift @@ -10,6 +10,7 @@ enum HotkeyOption: String, Codable, CaseIterable, Identifiable { case leftControl = "leftControl" case rightOption = "rightOption" case leftOption = "leftOption" + case optionSpace = "optionSpace" var id: String { rawValue } @@ -30,8 +31,18 @@ enum HotkeyOption: String, Codable, CaseIterable, Identifiable { return "Right βŒ₯" case .leftOption: return "Left βŒ₯" + case .optionSpace: + return "βŒ₯ + Space" } } + + /// Whether this hotkey is a modifier+key chord rather than a bare + /// modifier. Chords start on the non-modifier key's keyDown (which is + /// suppressed so it doesn't also type into the target app) and stop when + /// the modifier is released. + var isChord: Bool { + self == .optionSpace + } /// macOS keycode for this modifier key var keyCode: UInt16 { @@ -50,6 +61,8 @@ enum HotkeyOption: String, Codable, CaseIterable, Identifiable { return 61 case .leftOption: return 58 + case .optionSpace: + return 49 // Space β€” the chord's non-modifier key } } @@ -62,7 +75,7 @@ enum HotkeyOption: String, Codable, CaseIterable, Identifiable { return .command case .rightControl, .leftControl: return .control - case .rightOption, .leftOption: + case .rightOption, .leftOption, .optionSpace: return .option } } From 337e8150c6879895b214fa0835eb48c3f73e19b7 Mon Sep 17 00:00:00 2001 From: s Date: Sun, 5 Jul 2026 20:14:54 +0700 Subject: [PATCH 29/42] Add a setting to hide the idle pill; recover the tap after grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The always-on resting pill divides opinion β€” some want the anchor, others see clutter that floats over everything while doing nothing. New General setting 'Show floating pill when idle' (default on keeps current behavior): when off, the panel is ordered out whenever it returns to idle and reappears the moment recording starts. Also rebuild the hotkey monitoring when Accessibility becomes granted mid-session: the suppressing event tap could only be created at launch, so granting the permission later left global hotkeys dead until an app restart (global key monitors need the grant; local ones don't β€” which made the hotkey appear to work only inside the app). --- speaktype/App/AppDelegate.swift | 14 +++++++++++- .../MiniRecorderWindowController.swift | 22 ++++++++++++++++++- .../Views/Screens/Settings/SettingsView.swift | 15 +++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/speaktype/App/AppDelegate.swift b/speaktype/App/AppDelegate.swift index 468c36f..1810bad 100644 --- a/speaktype/App/AppDelegate.swift +++ b/speaktype/App/AppDelegate.swift @@ -37,6 +37,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { // so rebuild the monitoring stack when the selection changes. NotificationCenter.default.addObserver( forName: UserDefaults.didChangeNotification, object: nil, queue: .main + ) { [weak self] _ in + self?.reconfigureHotkeyMonitoringIfNeeded() + self?.miniRecorderController?.applyIdlePillPreference() + } + // Also retry after returning from System Settings (Accessibility grant). + NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main ) { [weak self] _ in self?.reconfigureHotkeyMonitoringIfNeeded() } @@ -184,7 +191,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func reconfigureHotkeyMonitoringIfNeeded() { let current = getSelectedHotkey() - guard current != configuredHotkey else { return } + // Rebuild when the selection changed, or when Accessibility was + // granted after launch β€” the suppressing tap could not be created + // without it, and previously stayed dead until an app restart + // (leaving global hotkeys broken outside the app). + let tapNowPossible = hotkeyEventTap == nil && AXIsProcessTrusted() + guard current != configuredHotkey || tapNowPossible else { return } teardownHotkeyMonitoring() setupHotkeyMonitoring() } diff --git a/speaktype/Controllers/MiniRecorderWindowController.swift b/speaktype/Controllers/MiniRecorderWindowController.swift index 2a74562..ae13cc8 100644 --- a/speaktype/Controllers/MiniRecorderWindowController.swift +++ b/speaktype/Controllers/MiniRecorderWindowController.swift @@ -8,6 +8,10 @@ class MiniRecorderWindowController: NSObject { private var shouldRestoreClipboardAfterAutoPaste: Bool { UserDefaults.standard.object(forKey: "restoreClipboardAfterAutoPaste") as? Bool ?? true } + static let showIdlePillDefaultsKey = "showIdleRecorderPill" + private var showIdlePill: Bool { + UserDefaults.standard.object(forKey: Self.showIdlePillDefaultsKey) as? Bool ?? true + } /// Show the always-present resting pill. Called once at launch; the pill then /// lives on screen and morphs into the recording HUD on demand. @@ -22,11 +26,23 @@ class MiniRecorderWindowController: NSObject { // behind it so the transparent window never blocks the desktop or dock. panel.ignoresMouseEvents = true + guard showIdlePill else { + panel.orderOut(nil) + return + } + if !panel.isVisible { panel.orderFrontRegardless() } } + /// Re-apply idle visibility when the user flips the setting. Only acts + /// while the pill is actually idle (interactive = recording/processing). + func applyIdlePillPreference() { + guard let panel, panel.ignoresMouseEvents else { return } + showIdleRecorder() + } + // Start recording - show panel and begin recording func startRecording() { // Capture previous app to restore focus later @@ -68,9 +84,13 @@ class MiniRecorderWindowController: NSObject { } } - /// Return the pill to its passive resting state without hiding it. + /// Return the pill to its passive resting state (hidden entirely when the + /// user has turned the idle pill off). private func returnToIdle() { panel?.ignoresMouseEvents = true + if !showIdlePill { + panel?.orderOut(nil) + } } // Stop recording - trigger transcription and paste diff --git a/speaktype/Views/Screens/Settings/SettingsView.swift b/speaktype/Views/Screens/Settings/SettingsView.swift index b98134d..1b706f4 100644 --- a/speaktype/Views/Screens/Settings/SettingsView.swift +++ b/speaktype/Views/Screens/Settings/SettingsView.swift @@ -91,6 +91,7 @@ struct GeneralSettingsTab: View { @AppStorage("restoreClipboardAfterAutoPaste") private var restoreClipboardAfterAutoPaste = true @AppStorage("showMenuBarIcon") private var showMenuBarIcon: Bool = true + @AppStorage(MiniRecorderWindowController.showIdlePillDefaultsKey) private var showIdlePill = true @AppStorage("transcriptionLanguage") private var transcriptionLanguage: String = "auto" @AppStorage("recentTranscriptionLanguages") private var recentLanguagesString: String = "" @AppStorage("enableAutoEdit") private var enableAutoEdit: Bool = false @@ -211,6 +212,20 @@ struct GeneralSettingsTab: View { .labelsHidden() } + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("Show floating pill when idle") + .font(Typography.bodyMedium) + .foregroundStyle(Color.textPrimary) + Spacer() + Toggle("", isOn: $showIdlePill) + .labelsHidden() + } + Text("When off, the pill appears only while recording or transcribing.") + .font(Typography.captionSmall) + .foregroundStyle(Color.textMuted) + } + VStack(alignment: .leading, spacing: 6) { HStack { Text("Restore clipboard after auto-paste") From 6526b97b93cf080934d9fd90cc940a59782d7af6 Mon Sep 17 00:00:00 2001 From: s Date: Mon, 6 Jul 2026 03:41:16 +0700 Subject: [PATCH 30/42] Use per-download generation tokens to end the cancel/retry delete race cancelDownload re-enabled downloads immediately and its deferred cleanup only skipped deletion when isDownloading was true. If a retry *completed* before the cancelled task's cleanup ran, isDownloading was false and the cleanup deleted the retry's freshly downloaded files. A boolean can't distinguish 'my download' from 'a newer retry that already finished'. Each download now claims a monotonic per-variant generation. Progress callbacks, completion/error handlers, and post-cancel cleanup only act while their generation is still current, so a superseded task can neither repaint the row nor delete a newer download's files. The auto-repair path (which also calls deleteModel) gets the same guard, and cancelDownload no longer runs cleanup at all when no task was actually running (avoids deleting a completed model). --- speaktype/Services/ModelDownloadService.swift | 108 +++++++++++++----- 1 file changed, 78 insertions(+), 30 deletions(-) diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift index 569223b..f4cbd03 100644 --- a/speaktype/Services/ModelDownloadService.swift +++ b/speaktype/Services/ModelDownloadService.swift @@ -122,7 +122,27 @@ class ModelDownloadService: ObservableObject { @Published private(set) var hasCompletedInitialScan = false private var activeTasks: [String: Task] = [:] // Track running download tasks - + + /// Monotonic per-variant download id. Every download claims a new + /// generation; progress callbacks, completion handlers, and post-cancel + /// cleanup only act while their generation is still current for the variant. + /// A boolean `isDownloading` can't tell "my download" apart from "a newer + /// retry that already finished", which let a cancelled task's cleanup delete + /// the retry's freshly downloaded files. All access is on the main thread + /// (downloadModel/cancelDownload run on main; callbacks hop via + /// DispatchQueue.main / MainActor.run). + private var downloadGeneration: [String: Int] = [:] + + private func claimDownloadGeneration(for variant: String) -> Int { + let next = (downloadGeneration[variant] ?? 0) + 1 + downloadGeneration[variant] = next + return next + } + + private func ownsDownload(_ variant: String, generation: Int) -> Bool { + downloadGeneration[variant] == generation + } + private init() { // Move any models left in the legacy ~/Documents/huggingface location into // Application Support. No directory is created eagerly β€” a fresh install @@ -276,10 +296,11 @@ class ModelDownloadService: ObservableObject { downloadProgress[variant] = 0.0 downloadError[variant] = nil downloadStatus[variant] = nil + let generation = claimDownloadGeneration(for: variant) // Create the storage directory now, on first download β€” never eagerly on launch. ModelStorage.ensureWhisperKitModelsDir() print("Starting WhisperKit download for: \(variant)") - + let task = Task { // Debug: List what WhisperKit sees // Note: WhisperKit API might differ, but let's try to see if we can get info. @@ -300,19 +321,22 @@ class ModelDownloadService: ObservableObject { // likely: download(variant:progressCallback:) - 'from' usually has a default let _ = try await WhisperKit.download(variant: variant, downloadBase: ModelStorage.whisperKitBase, progressCallback: { progress in DispatchQueue.main.async { - // A cancelled download's transfer can keep reporting for a - // moment; don't repaint a row the user already reset. - guard self.isDownloading[variant] == true else { return } + // Ignore a cancelled/superseded download's stray progress: + // it must still be actively downloading AND the current + // generation to repaint the row. + guard self.isDownloading[variant] == true, + self.ownsDownload(variant, generation: generation) else { return } self.downloadProgress[variant] = progress.fractionCompleted } }) - + // Check if task was cancelled before declaring success if Task.isCancelled { return } - + print("Model downloaded successfully") - + DispatchQueue.main.async { + guard self.ownsDownload(variant, generation: generation) else { return } self.isDownloading[variant] = false self.downloadProgress[variant] = 1.0 self.downloadStatus[variant] = nil @@ -329,11 +353,18 @@ class ModelDownloadService: ObservableObject { // Auto-Repair: If duplicate models found, delete and retry ONCE if error.localizedDescription.contains("Multiple models found") { print("⚠️ Multiple models detected. Cleaning cache and retrying...") - + + // Bail if a newer download has claimed this variant β€” the + // deleteModel below would otherwise remove its files. + let stillOwned = await MainActor.run { + self.ownsDownload(variant, generation: generation) + } + guard stillOwned else { return } + await MainActor.run { self.downloadStatus[variant] = "Cleaning duplicates..." } - + let log = await self.deleteModel(variant: variant) print("🧹 Cleanup result: \(log)") @@ -349,16 +380,18 @@ class ModelDownloadService: ObservableObject { do { let _ = try await WhisperKit.download(variant: variant, downloadBase: ModelStorage.whisperKitBase, progressCallback: { progress in DispatchQueue.main.async { - guard self.isDownloading[variant] == true else { return } + guard self.isDownloading[variant] == true, + self.ownsDownload(variant, generation: generation) else { return } self.downloadProgress[variant] = progress.fractionCompleted } }) - + if Task.isCancelled { return } - + print("βœ… Model downloaded successfully after cleanup") - + DispatchQueue.main.async { + guard self.ownsDownload(variant, generation: generation) else { return } self.isDownloading[variant] = false self.downloadProgress[variant] = 1.0 self.downloadError[variant] = nil @@ -369,6 +402,7 @@ class ModelDownloadService: ObservableObject { if Task.isCancelled { return } print("❌ Retry failed: \(error)") DispatchQueue.main.async { + guard self.ownsDownload(variant, generation: generation) else { return } self.isDownloading[variant] = false self.downloadProgress[variant] = 0.0 self.downloadError[variant] = @@ -381,6 +415,7 @@ class ModelDownloadService: ObservableObject { } DispatchQueue.main.async { + guard self.ownsDownload(variant, generation: generation) else { return } self.isDownloading[variant] = false self.downloadProgress[variant] = 0.0 self.downloadError[variant] = @@ -400,6 +435,7 @@ class ModelDownloadService: ObservableObject { downloadProgress[variant] = 0.0 downloadError[variant] = nil downloadStatus[variant] = nil + let generation = claimDownloadGeneration(for: variant) print("Starting FluidAudio (Parakeet) download for: \(variant)") let version = ParakeetCatalog.version(for: variant) @@ -410,7 +446,8 @@ class ModelDownloadService: ObservableObject { version: version, progressHandler: { progress in DispatchQueue.main.async { - guard self.isDownloading[variant] == true else { return } + guard self.isDownloading[variant] == true, + self.ownsDownload(variant, generation: generation) else { return } self.downloadProgress[variant] = progress.fractionCompleted } }) @@ -419,6 +456,7 @@ class ModelDownloadService: ObservableObject { print("Parakeet model downloaded successfully") DispatchQueue.main.async { + guard self.ownsDownload(variant, generation: generation) else { return } self.isDownloading[variant] = false self.downloadProgress[variant] = 1.0 self.activeTasks[variant] = nil @@ -430,6 +468,7 @@ class ModelDownloadService: ObservableObject { } print("FluidAudio download error: \(error)") DispatchQueue.main.async { + guard self.ownsDownload(variant, generation: generation) else { return } self.isDownloading[variant] = false self.downloadProgress[variant] = 0.0 self.downloadError[variant] = error.localizedDescription @@ -500,28 +539,37 @@ class ModelDownloadService: ObservableObject { } func cancelDownload(for variant: String) { - let task = activeTasks[variant] - if let task { - task.cancel() - activeTasks[variant] = nil - print("Cancelled download task for \(variant)") + guard let task = activeTasks[variant] else { + // Nothing running (e.g. cancel arrived after completion); just clear + // any transient UI state without touching downloaded files. + isDownloading[variant] = false + downloadStatus[variant] = nil + return } - + + // Snapshot the generation being cancelled so cleanup can tell whether a + // newer download has since claimed this variant. + let cancelledGeneration = downloadGeneration[variant] + task.cancel() + activeTasks[variant] = nil + print("Cancelled download task for \(variant)") + isDownloading[variant] = false downloadProgress[variant] = 0.0 downloadError[variant] = nil downloadStatus[variant] = nil - - // Delete any partial download β€” but only after the cancelled task has + + // Delete the partial download β€” but only after the cancelled task has // actually stopped (or the deletion races WhisperKit's in-flight - // writes), and only if the user hasn't started a fresh download for - // this variant in the meantime (or the cleanup would delete the - // retry's files out from under it). + // writes), and only if no newer download has since claimed the variant + // (otherwise we'd delete the retry's freshly downloaded files). Task { - _ = await task?.value - let restarted = await MainActor.run { self.isDownloading[variant] == true } - guard !restarted else { - print("↩️ Skipping partial-download cleanup for \(variant): a new download is in flight") + _ = await task.value + let stillOwned = await MainActor.run { + self.downloadGeneration[variant] == cancelledGeneration + } + guard stillOwned else { + print("↩️ Skipping partial-download cleanup for \(variant): a newer download claimed it") return } let result = await deleteModel(variant: variant) From 0f025b48f46925a3bb2f02ef030a75a68c59e872 Mon Sep 17 00:00:00 2001 From: s Date: Mon, 6 Jul 2026 03:41:23 +0700 Subject: [PATCH 31/42] Enter the recording HUD only when capture actually begins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On first run, startRecording requests mic access asynchronously, but the pill flipped isListening = true synchronously β€” so if the user denied the prompt, the HUD stayed stuck showing 'recording' until a manual stop/cancel. startRecording now reports back via an onStarted callback (true when capture begins, false when denied); the authorized path still fires it synchronously so the common case is unchanged. The pill enters the listening HUD only on true, and surfaces the mic-off hint on false. --- .../Services/AudioRecordingService.swift | 18 ++++++++++++++--- .../Views/Overlays/MiniRecorderView.swift | 20 +++++++++++++++++-- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index 83ecb23..e1c9d1a 100644 --- a/speaktype/Services/AudioRecordingService.swift +++ b/speaktype/Services/AudioRecordingService.swift @@ -250,27 +250,39 @@ class AudioRecordingService: NSObject, ObservableObject { } } - func startRecording() { - guard !isRecording else { return } + /// Start recording. `onStarted` fires with `true` once capture actually + /// begins and `false` if permission is denied β€” so callers can flip their + /// "recording" UI only when a take is really underway, instead of guessing + /// before the (possibly asynchronous) permission answer. + func startRecording(onStarted: ((Bool) -> Void)? = nil) { + guard !isRecording else { + onStarted?(false) + return + } switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: beginAuthorizedRecording() + onStarted?(true) case .notDetermined: // First run: wait for the user's answer to the system prompt. // Starting immediately used to record into a mic we might never - // get, producing an empty take even when the user granted access. + // get, producing an empty take even when the user granted access β€” + // and callers must not enter "recording" state until this resolves. AVCaptureDevice.requestAccess(for: .audio) { granted in DispatchQueue.main.async { guard granted else { print("Microphone access denied") + onStarted?(false) return } self.beginAuthorizedRecording() + onStarted?(true) } } default: print("Microphone access denied") + onStarted?(false) } } diff --git a/speaktype/Views/Overlays/MiniRecorderView.swift b/speaktype/Views/Overlays/MiniRecorderView.swift index 0a20993..5f4d8c8 100644 --- a/speaktype/Views/Overlays/MiniRecorderView.swift +++ b/speaktype/Views/Overlays/MiniRecorderView.swift @@ -774,8 +774,24 @@ struct MiniRecorderView: View { cancelCommit = false debugLog("Starting recording...") - audioRecorder.startRecording() - isListening = true + // Enter the "listening" HUD only once capture actually begins. On first + // run the mic prompt resolves asynchronously; flipping isListening + // eagerly left the pill stuck showing "recording" if the user denied. + audioRecorder.startRecording { started in + guard started else { + debugLog("Recording did not start (microphone permission)") + isListening = false + isProcessing = true + statusMessage = "Mic access off β€” System Settings β†’ Privacy & Security" + Task { @MainActor in + try? await Task.sleep(nanoseconds: 3_000_000_000) + isProcessing = false + onCancel?() + } + return + } + isListening = true + } } private func selectAudioDevice(_ deviceId: String) { From b28ffbec756a1d1d55a98f7f7980ca7cedd025a0 Mon Sep 17 00:00:00 2001 From: s Date: Mon, 6 Jul 2026 03:41:30 +0700 Subject: [PATCH 32/42] Don't drop a quick Option+Space release The chord press is dispatched to the main queue asynchronously, but the release branch only queued a stop when isHotkeyPressed was already true. A fast tap can deliver the release event to the tap callback before the press block runs and sets isHotkeyPressed, so the stop was dropped and recording stuck on (and toggle mode wedged, since the release also resets the pressed flag). Queue the release unconditionally: press and release run on the main queue in FIFO order and handleHotkeyStateChange no-ops a release with no matching press, so this is safe. Applied to both the event-tap and NSEvent-fallback paths. --- speaktype/App/AppDelegate.swift | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/speaktype/App/AppDelegate.swift b/speaktype/App/AppDelegate.swift index 1810bad..16f8e6e 100644 --- a/speaktype/App/AppDelegate.swift +++ b/speaktype/App/AppDelegate.swift @@ -402,11 +402,17 @@ class AppDelegate: NSObject, NSApplicationDelegate { return Unmanaged.passUnretained(event) } - // Chord stop: the modifier was released while the chord was active. - // The flagsChanged event itself passes through β€” other apps still need - // to see the modifier go up. + // Chord stop: the chord modifier was released. Queue the release + // UNCONDITIONALLY β€” not gated on isHotkeyPressed. The press is + // dispatched async, so a quick tap can deliver this release event to + // the tap callback before the press block has run and set + // isHotkeyPressed; gating here would drop the stop and leave recording + // stuck on. Press and release both run on the main queue in FIFO order, + // and handleHotkeyStateChange no-ops a release with no matching press, + // so queuing unconditionally is safe. The flagsChanged event itself + // passes through β€” other apps still need to see the modifier go up. if currentHotkey.isChord { - if isHotkeyPressed && !event.flags.contains(.maskAlternate) { + if !event.flags.contains(.maskAlternate) { DispatchQueue.main.async { [weak self] in self?.handleHotkeyStateChange(isPressed: false) } @@ -440,9 +446,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Chord release fallback when the tap is unavailable: stop once the // modifier goes up. Chord *start* comes from the keyDown monitors. + // Call unconditionally (matching the tap path) β€” handleHotkeyStateChange + // no-ops a release with no active press β€” so a fast tap can't drop it. if currentHotkey.isChord { guard hotkeyEventTap == nil else { return } - if isHotkeyPressed && !event.modifierFlags.contains(currentHotkey.modifierFlag) { + if !event.modifierFlags.contains(currentHotkey.modifierFlag) { handleHotkeyStateChange(isPressed: false) } return From 14e0a12e4655460e85b9ed8e4672771c4176ed81 Mon Sep 17 00:00:00 2001 From: s Date: Mon, 6 Jul 2026 03:41:36 +0700 Subject: [PATCH 33/42] Strip trailing whitespace in DeviceRow Inherited from the old AudioInputView when DeviceRow was extracted; git diff --check flagged it. --- speaktype/Views/Screens/Settings/DeviceRow.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/speaktype/Views/Screens/Settings/DeviceRow.swift b/speaktype/Views/Screens/Settings/DeviceRow.swift index 1f4899e..1966ea9 100644 --- a/speaktype/Views/Screens/Settings/DeviceRow.swift +++ b/speaktype/Views/Screens/Settings/DeviceRow.swift @@ -6,19 +6,19 @@ struct DeviceRow: View { let name: String let isActive: Bool let isSelected: Bool - + var body: some View { HStack { Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .foregroundStyle(isSelected ? Color.accentPrimary : Color.textMuted) .font(.title3) - + Text(name) .font(Typography.bodyMedium) .foregroundStyle(Color.textPrimary) - + Spacer() - + if isActive { HStack(spacing: 4) { Image(systemName: "waveform") From 483eaa5e4e7642891057320fae7e4cc7a34655f0 Mon Sep 17 00:00:00 2001 From: s Date: Mon, 6 Jul 2026 03:59:25 +0700 Subject: [PATCH 34/42] Close the remaining download-generation races surfaced in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the generation-token commit found two holes the guards missed: 1. Auto-repair's deleteModel was neither generation- nor cancellation- aware. Cancel-then-download during an in-flight 'Multiple models found' repair could let that deleteModel remove the new download's files and reset its UI β€” the exact race the tokens were meant to close. deleteModel now takes an expectedGeneration and bails (before any removal and before its terminal state writes) when a newer download has claimed the variant; cancelDownload tombstones the generation so an already-running repair loses ownership immediately. The user's trash button still passes nil for an unconditional delete. 2. refreshDownloadedModels' 'keep in-flight rows' filter dropped a download that COMPLETED during the disk scan (isDownloading flipped false, but the stale scan snapshot saw it <80% so didn't re-add it), reverting a just-installed model to the Download button. The filter now also retains completed rows (progress >= 1.0). --- speaktype/Services/ModelDownloadService.swift | 60 +++++++++++++++---- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift index f4cbd03..3abe1d8 100644 --- a/speaktype/Services/ModelDownloadService.swift +++ b/speaktype/Services/ModelDownloadService.swift @@ -143,6 +143,14 @@ class ModelDownloadService: ObservableObject { downloadGeneration[variant] == generation } + /// True when `expectedGeneration` is set but a newer download has since + /// claimed the variant. Used by deleteModel so an auto-repair or post-cancel + /// cleanup won't remove a newer download's files. Reads on the main thread. + private func supersededByNewerDownload(_ variant: String, expectedGeneration: Int?) async -> Bool { + guard let expectedGeneration else { return false } + return await MainActor.run { self.downloadGeneration[variant] != expectedGeneration } + } + private init() { // Move any models left in the legacy ~/Documents/huggingface location into // Application Support. No directory is created eagerly β€” a fresh install @@ -192,9 +200,14 @@ class ModelDownloadService: ObservableObject { await MainActor.run { self.hasCompletedInitialScan = true - // Keep live download rows stable while refreshing disk state. + // Keep live download rows stable while refreshing disk state. Retain + // both in-flight rows AND completed ones (progress >= 1.0): the disk + // scan snapshot can be stale by the time it applies, so a download + // that finished mid-scan would otherwise be dropped here (isDownloading + // now false) and not re-added (foundModels saw it <80%), reverting a + // just-installed model to the "Download" button. self.downloadProgress = self.downloadProgress.filter { - self.isDownloading[$0.key] == true + self.isDownloading[$0.key] == true || $0.value >= 1.0 } // Only mark models that actually exist @@ -365,7 +378,7 @@ class ModelDownloadService: ObservableObject { self.downloadStatus[variant] = "Cleaning duplicates..." } - let log = await self.deleteModel(variant: variant) + let log = await self.deleteModel(variant: variant, expectedGeneration: generation) print("🧹 Cleanup result: \(log)") // Give filesystem time to settle @@ -480,10 +493,22 @@ class ModelDownloadService: ObservableObject { activeTasks[variant] = task } - // Aggressively deletes any potential cache for this variant - func deleteModel(variant: String) async -> String { + // Aggressively deletes any potential cache for this variant. + // `expectedGeneration` (non-nil for auto-repair / post-cancel cleanup) makes + // the deletion bail when a newer download has since claimed the variant, so + // it can't remove the newer download's files or reset its UI. The user's + // explicit trash button passes nil for an unconditional delete. The + // ownership check is repeated right before the terminal state writes to + // narrow the window in which a newer download can start mid-removal. + func deleteModel(variant: String, expectedGeneration: Int? = nil) async -> String { + if await supersededByNewerDownload(variant, expectedGeneration: expectedGeneration) { + return "Skipped delete for \(variant): a newer download claimed it" + } + guard let engineKind = AIModel.engineKind(for: variant) else { await MainActor.run { + guard expectedGeneration == nil + || self.downloadGeneration[variant] == expectedGeneration else { return } self.downloadProgress[variant] = 0.0 self.isDownloading[variant] = false self.downloadError[variant] = "Unknown model variant." @@ -497,6 +522,8 @@ class ModelDownloadService: ObservableObject { let cacheDir = AsrModels.defaultCacheDirectory(for: version) try? FileManager.default.removeItem(at: cacheDir) await MainActor.run { + guard expectedGeneration == nil + || self.downloadGeneration[variant] == expectedGeneration else { return } self.downloadProgress[variant] = 0.0 self.isDownloading[variant] = false } @@ -524,12 +551,16 @@ class ModelDownloadService: ObservableObject { if deletedCount > 0 { await MainActor.run { + guard expectedGeneration == nil + || self.downloadGeneration[variant] == expectedGeneration else { return } self.downloadProgress[variant] = 0.0 self.isDownloading[variant] = false } return "Deleted \(deletedCount) items" } else { await MainActor.run { + guard expectedGeneration == nil + || self.downloadGeneration[variant] == expectedGeneration else { return } self.downloadProgress[variant] = 0.0 self.isDownloading[variant] = false } @@ -547,13 +578,16 @@ class ModelDownloadService: ObservableObject { return } - // Snapshot the generation being cancelled so cleanup can tell whether a - // newer download has since claimed this variant. - let cancelledGeneration = downloadGeneration[variant] task.cancel() activeTasks[variant] = nil print("Cancelled download task for \(variant)") + // Tombstone the generation: claiming a new one that no task owns means + // an auto-repair still running inside the cancelled task immediately + // loses ownership, so its deleteModel bails before removing anything. + // A subsequent real download claims a higher generation still. + let tombstone = claimDownloadGeneration(for: variant) + isDownloading[variant] = false downloadProgress[variant] = 0.0 downloadError[variant] = nil @@ -562,17 +596,17 @@ class ModelDownloadService: ObservableObject { // Delete the partial download β€” but only after the cancelled task has // actually stopped (or the deletion races WhisperKit's in-flight // writes), and only if no newer download has since claimed the variant - // (otherwise we'd delete the retry's freshly downloaded files). + // past the tombstone (otherwise we'd delete the retry's files). Task { _ = await task.value - let stillOwned = await MainActor.run { - self.downloadGeneration[variant] == cancelledGeneration + let stillTombstoned = await MainActor.run { + self.downloadGeneration[variant] == tombstone } - guard stillOwned else { + guard stillTombstoned else { print("↩️ Skipping partial-download cleanup for \(variant): a newer download claimed it") return } - let result = await deleteModel(variant: variant) + let result = await deleteModel(variant: variant, expectedGeneration: tombstone) print("πŸ—‘οΈ Cleaned up partial download: \(result)") } } From 01eb1b2ad23e125583fd96a925bf3139c05d4ac6 Mon Sep 17 00:00:00 2001 From: s Date: Mon, 6 Jul 2026 03:59:37 +0700 Subject: [PATCH 35/42] Gate device-switch resume and model-switch on success Two more eager-UI-state flips of the same class as the mic-permission fix: - selectAudioDevice resumed recording with a fire-and-forget startRecording() and then unconditionally set isListening = true, so switching to a device that can't be added (unplugged that instant, grabbed exclusively) left the pill showing a live HUD over nothing. It now uses the onStarted callback and returns to idle if capture doesn't restart. - The pill's model menu set selectedModel eagerly and only debugLogged a load failure, so picking an undownloaded/failing model left the app permanently switched to a model that can't load. It now reverts the selection on failure, matching the other selection paths. --- .../Views/Overlays/MiniRecorderView.swift | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/speaktype/Views/Overlays/MiniRecorderView.swift b/speaktype/Views/Overlays/MiniRecorderView.swift index 5f4d8c8..1744bdc 100644 --- a/speaktype/Views/Overlays/MiniRecorderView.swift +++ b/speaktype/Views/Overlays/MiniRecorderView.swift @@ -636,6 +636,10 @@ struct MiniRecorderView: View { debugLog("Model pre-loaded after switch: \(model.variant)") } catch { debugLog("Model pre-load failed: \(error.localizedDescription)") + // Don't leave the app switched to a model that can't + // load (not downloaded, load failed/timed out) β€” revert + // the selection so dictation keeps using the old model. + await MainActor.run { selectedModel = previousModel } } await MainActor.run { isWarmingUp = false } } @@ -816,11 +820,20 @@ struct MiniRecorderView: View { guard shouldResumeRecording else { return } - audioRecorder.startRecording() - + // Resume on the new device, but only re-enter the recording HUD if + // capture actually restarts β€” a device that can't be added (unplugged + // in the same instant, grabbed exclusively by another app) must not + // leave the pill showing a live HUD over nothing. Mic is already + // authorized here (we were recording), so onStarted fires synchronously. await MainActor.run { - isProcessing = false - isListening = true + audioRecorder.startRecording { started in + isProcessing = false + isListening = started + if !started { + statusMessage = "Couldn't switch input" + onCancel?() + } + } } } } From 9534ce01c0a55837636f3b908ff29c18ca7b6cf2 Mon Sep 17 00:00:00 2001 From: s Date: Mon, 6 Jul 2026 03:59:44 +0700 Subject: [PATCH 36/42] Dedupe the Software Update window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A silent launch/periodic check reached showUpdateWindow twice for the same release β€” once via showUpdateWindowPublisher's sink (fired inside checkForUpdates) and once via performUpdateCheckIfNeeded's reminder check β€” and showUpdateWindow created a fresh NSWindow each time with no guard, stacking two identical windows. It now reuses the open window. --- speaktype/App/AppDelegate.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/speaktype/App/AppDelegate.swift b/speaktype/App/AppDelegate.swift index 16f8e6e..0c42f5e 100644 --- a/speaktype/App/AppDelegate.swift +++ b/speaktype/App/AppDelegate.swift @@ -18,6 +18,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var configuredHotkey: HotkeyOption? private let updateCheckScheduler = NSBackgroundActivityScheduler( identifier: "com.2048labs.speaktype.update-check") + private var updateWindow: NSWindow? func applicationDidFinishLaunching(_ notification: Notification) { UpdateService.registerDefaults() @@ -577,6 +578,16 @@ class AppDelegate: NSObject, NSApplicationDelegate { private func showUpdateWindow() { guard let update = UpdateService.shared.availableUpdate else { return } + // Idempotent: a silent launch/periodic check reaches here twice for the + // same release β€” once via showUpdateWindowPublisher's sink, once via + // performUpdateCheckIfNeeded's reminder check. Reuse the open window + // instead of stacking a second identical "Software Update" window. + if let existing = updateWindow, existing.isVisible { + existing.makeKeyAndOrderFront(nil) + NSApp.activate() + return + } + let updateSheetView = UpdateSheet(update: update) let hostingController = NSHostingController(rootView: updateSheetView) @@ -586,6 +597,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { window.isReleasedWhenClosed = false window.center() window.isMovableByWindowBackground = true + updateWindow = window window.makeKeyAndOrderFront(nil) NSApp.activate() } From ed26406b631734ca6fddd86de20a7210a4c7429e Mon Sep 17 00:00:00 2001 From: s Date: Mon, 6 Jul 2026 04:36:23 +0700 Subject: [PATCH 37/42] Serialize per-variant downloads so cancel/retry can't overlap writers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generation tokens guarded UI/delete STATE but not concurrent WhisperKit writes: cancelDownload freed the slot (isDownloading=false) before the cancelled task's WhisperKit.download had actually stopped (cancellation is cooperative), and downloadModel gated only on isDownloading β€” so a quick cancel-then-retry could have two downloads writing the same cache directory, corrupting it or tripping 'Multiple models found'. The new download now captures the prior task and awaits it before writing (cancelDownload keeps the task reference for exactly this), so writers for a variant never overlap. --- speaktype/Services/ModelDownloadService.swift | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift index 3abe1d8..d198772 100644 --- a/speaktype/Services/ModelDownloadService.swift +++ b/speaktype/Services/ModelDownloadService.swift @@ -310,15 +310,18 @@ class ModelDownloadService: ObservableObject { downloadError[variant] = nil downloadStatus[variant] = nil let generation = claimDownloadGeneration(for: variant) + // A just-cancelled task for this variant may still be unwinding its + // WhisperKit.download (cancellation is cooperative); the new task awaits + // it before writing so two downloads never write the same cache dir. + let previousTask = activeTasks[variant] // Create the storage directory now, on first download β€” never eagerly on launch. ModelStorage.ensureWhisperKitModelsDir() print("Starting WhisperKit download for: \(variant)") let task = Task { - // Debug: List what WhisperKit sees - // Note: WhisperKit API might differ, but let's try to see if we can get info. - // If fetchAvailableModels exists. - + _ = await previousTask?.value + if Task.isCancelled { return } + do { // Determine model variant enum/string // Note: WhisperKit.download(variant:from:) is the likely API. @@ -449,11 +452,15 @@ class ModelDownloadService: ObservableObject { downloadError[variant] = nil downloadStatus[variant] = nil let generation = claimDownloadGeneration(for: variant) + let previousTask = activeTasks[variant] print("Starting FluidAudio (Parakeet) download for: \(variant)") let version = ParakeetCatalog.version(for: variant) let task = Task { + _ = await previousTask?.value + if Task.isCancelled { return } + do { _ = try await AsrModels.download( version: version, @@ -579,7 +586,11 @@ class ModelDownloadService: ObservableObject { } task.cancel() - activeTasks[variant] = nil + // Deliberately do NOT clear activeTasks[variant] here: the next + // downloadModel captures it as `previousTask` and awaits it before + // writing, so a retry can't race this task's still-unwinding writes. + // A completing (non-cancelled) task clears its own entry; this cancelled + // task's entry is overwritten by the next download. print("Cancelled download task for \(variant)") // Tombstone the generation: claiming a new one that no task owns means From 286024690d358fcacf4c60209ed3f05f82316de1 Mon Sep 17 00:00:00 2001 From: s Date: Mon, 6 Jul 2026 04:36:30 +0700 Subject: [PATCH 38/42] Report recording started only when capture is genuinely underway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onStarted(true) fired immediately after beginAuthorizedRecording, but setupSession could leave an inputless session (device missing / can't be added) and the async writer setup could still throw β€” either way the pill entered the recording HUD over nothing. setupSession now returns whether a usable input was installed; beginAuthorizedRecording bails with onStarted(false) when there's no input, and onStarted(true) now fires from inside the writer-setup task, after the writer is created and the session start is scheduled (onStarted(false) on writer failure). Capture-time buffering is unaffected β€” isRecording still flips before the task, so no opening audio is lost. --- .../Services/AudioRecordingService.swift | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index e1c9d1a..cfb98fe 100644 --- a/speaktype/Services/AudioRecordingService.swift +++ b/speaktype/Services/AudioRecordingService.swift @@ -183,21 +183,24 @@ class AudioRecordingService: NSObject, ObservableObject { } } - func setupSession() { + /// Builds the capture session. Returns whether a usable input was installed + /// β€” false when the selected device is missing or can't be added, which + /// leaves an inputless session that would capture nothing. + @discardableResult + func setupSession() -> Bool { captureSession?.stopRunning() captureSession = AVCaptureSession() guard let deviceId = selectedDeviceId, let device = AVCaptureDevice(uniqueID: deviceId), - let input = try? AVCaptureDeviceInput(device: device) + let input = try? AVCaptureDeviceInput(device: device), + captureSession?.canAddInput(input) == true else { print("Failed to find or add device with ID: \(selectedDeviceId ?? "nil")") - return + return false } - if captureSession?.canAddInput(input) == true { - captureSession?.addInput(input) - } + captureSession?.addInput(input) audioOutput = AVCaptureAudioDataOutput() // Pin a fixed Linear PCM output format so the live level meter always sees a @@ -221,6 +224,7 @@ class AudioRecordingService: NSObject, ObservableObject { // Don't start session here - only start when recording begins // This prevents continuous CPU usage when idle + return true } /// Pre-warm the capture session so first recording starts instantly @@ -262,8 +266,7 @@ class AudioRecordingService: NSObject, ObservableObject { switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: - beginAuthorizedRecording() - onStarted?(true) + beginAuthorizedRecording(onStarted: onStarted) case .notDetermined: // First run: wait for the user's answer to the system prompt. // Starting immediately used to record into a mic we might never @@ -276,8 +279,7 @@ class AudioRecordingService: NSObject, ObservableObject { onStarted?(false) return } - self.beginAuthorizedRecording() - onStarted?(true) + self.beginAuthorizedRecording(onStarted: onStarted) } } default: @@ -286,10 +288,23 @@ class AudioRecordingService: NSObject, ObservableObject { } } - private func beginAuthorizedRecording() { - guard !isRecording else { return } + private func beginAuthorizedRecording(onStarted: ((Bool) -> Void)?) { + guard !isRecording else { onStarted?(false); return } if captureSession == nil { setupSession() } + // A missing/unpluggable device leaves an inputless session that would + // capture nothing. Rebuild once in case the session is merely stale, and + // report failure (rather than entering a false "recording" state) if we + // still have no usable input. + if captureSession?.inputs.isEmpty != false { + setupSession() + } + guard captureSession?.inputs.isEmpty == false else { + print("No usable audio input; not starting recording") + onStarted?(false) + return + } + // 1. Reset flags and stale writer state before any new samples arrive. isStopping = false shouldDiscardCurrentRecordingOutput = false @@ -353,6 +368,7 @@ class AudioRecordingService: NSObject, ObservableObject { audioQueue.async { self.captureSession?.stopRunning() } + onStarted?(false) return } @@ -362,6 +378,12 @@ class AudioRecordingService: NSObject, ObservableObject { self.captureSession?.startRunning() } } + + // Capture is genuinely underway now (usable input + writer created + + // session start scheduled) β€” only here do we tell the caller to + // enter the recording HUD, so a writer failure above can't leave a + // false "recording" state on screen. + onStarted?(true) } } From 0bc6e3e25f2b1994fe8a78b99e0dd7dc26fc2d55 Mon Sep 17 00:00:00 2001 From: s Date: Mon, 6 Jul 2026 04:36:39 +0700 Subject: [PATCH 39/42] Only revert model selection if the failed load is still selected The revert-on-failure added for the pill model menu was unconditional: selecting A then B, with A's load failing later, would revert to A's predecessor and clobber the newer B selection. Revert only when the failed variant is still the active selection. --- speaktype/Views/Overlays/MiniRecorderView.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/speaktype/Views/Overlays/MiniRecorderView.swift b/speaktype/Views/Overlays/MiniRecorderView.swift index 1744bdc..11116fd 100644 --- a/speaktype/Views/Overlays/MiniRecorderView.swift +++ b/speaktype/Views/Overlays/MiniRecorderView.swift @@ -639,7 +639,12 @@ struct MiniRecorderView: View { // Don't leave the app switched to a model that can't // load (not downloaded, load failed/timed out) β€” revert // the selection so dictation keeps using the old model. - await MainActor.run { selectedModel = previousModel } + // Only revert if THIS attempt is still the active + // selection; the user may have picked another model + // since, and reverting would clobber that newer choice. + await MainActor.run { + if selectedModel == model.variant { selectedModel = previousModel } + } } await MainActor.run { isWarmingUp = false } } From 52445cd9403b390926a639b208d8b3b548074d9b Mon Sep 17 00:00:00 2001 From: s Date: Mon, 6 Jul 2026 04:43:48 +0700 Subject: [PATCH 40/42] Chain a retry behind the post-cancel cleanup delete, not just the download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial re-verification found the download-serialization closed download-vs-download but left a residual: the cleanup task's file removal could still run concurrently with a retry's writes (retry awaited the cancelled download task, but the delete happens in a separate cleanup task). Point activeTasks at the cleanup task instead, so a retry captures it as previousTask and awaits BOTH the cancelled download unwinding and the cleanup delete before writing β€” the whole per-variant download/cancel/cleanup pipeline is now strictly serial. Also refresh a stale comment (onStarted is no longer synchronous). --- speaktype/Services/ModelDownloadService.swift | 16 ++++++++++------ speaktype/Views/Overlays/MiniRecorderView.swift | 5 +++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift index d198772..0289082 100644 --- a/speaktype/Services/ModelDownloadService.swift +++ b/speaktype/Services/ModelDownloadService.swift @@ -586,11 +586,6 @@ class ModelDownloadService: ObservableObject { } task.cancel() - // Deliberately do NOT clear activeTasks[variant] here: the next - // downloadModel captures it as `previousTask` and awaits it before - // writing, so a retry can't race this task's still-unwinding writes. - // A completing (non-cancelled) task clears its own entry; this cancelled - // task's entry is overwritten by the next download. print("Cancelled download task for \(variant)") // Tombstone the generation: claiming a new one that no task owns means @@ -608,7 +603,7 @@ class ModelDownloadService: ObservableObject { // actually stopped (or the deletion races WhisperKit's in-flight // writes), and only if no newer download has since claimed the variant // past the tombstone (otherwise we'd delete the retry's files). - Task { + let cleanupTask = Task { _ = await task.value let stillTombstoned = await MainActor.run { self.downloadGeneration[variant] == tombstone @@ -620,6 +615,15 @@ class ModelDownloadService: ObservableObject { let result = await deleteModel(variant: variant, expectedGeneration: tombstone) print("πŸ—‘οΈ Cleaned up partial download: \(result)") } + + // Point activeTasks at the cleanup task (not the cancelled download): the + // next downloadModel captures it as `previousTask` and awaits it before + // writing, so a retry chains behind BOTH the cancelled task unwinding + // AND this cleanup's file removal β€” the retry's writes can't race the + // delete. The cleanup task holds no generation ownership, so it's never + // a valid cancel target (the UI shows "Download", not "Cancel", once + // isDownloading is false); the next download overwrites this entry. + activeTasks[variant] = cleanupTask } // MARK: - Helper Functions diff --git a/speaktype/Views/Overlays/MiniRecorderView.swift b/speaktype/Views/Overlays/MiniRecorderView.swift index 11116fd..5e0468d 100644 --- a/speaktype/Views/Overlays/MiniRecorderView.swift +++ b/speaktype/Views/Overlays/MiniRecorderView.swift @@ -828,8 +828,9 @@ struct MiniRecorderView: View { // Resume on the new device, but only re-enter the recording HUD if // capture actually restarts β€” a device that can't be added (unplugged // in the same instant, grabbed exclusively by another app) must not - // leave the pill showing a live HUD over nothing. Mic is already - // authorized here (we were recording), so onStarted fires synchronously. + // leave the pill showing a live HUD over nothing. onStarted resolves + // once the writer setup task confirms capture, keeping "Switching + // input…" up until then. await MainActor.run { audioRecorder.startRecording { started in isProcessing = false From abf65a194d33923ff11f184dda979d3bc5e4f0e4 Mon Sep 17 00:00:00 2001 From: s Date: Mon, 6 Jul 2026 04:54:13 +0700 Subject: [PATCH 41/42] Don't spin an inputless session on mid-recording device loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectedDeviceId.didSet ignored setupSession()'s Bool result, so when a mic disappears mid-recording with no usable fallback (selectedDeviceId becomes nil), it still started an inputless session β€” the mid-recording twin of the false-HUD/no-capture bug just fixed on the start path. Now the restart only runs when a usable input was installed; otherwise the recording stays active but paused (capture resumes automatically if a mic reconnects, since fetchAvailableDevices reassigns the device), and the partial still finalizes on stop. --- .../Services/AudioRecordingService.swift | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/speaktype/Services/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index cfb98fe..9c1d826 100644 --- a/speaktype/Services/AudioRecordingService.swift +++ b/speaktype/Services/AudioRecordingService.swift @@ -16,17 +16,27 @@ class AudioRecordingService: NSObject, ObservableObject { @Published var availableDevices: [AVCaptureDevice] = [] @Published var selectedDeviceId: String? { didSet { - setupSession() + let sessionReady = setupSession() // If a dictation is in flight (device unplugged mid-recording, or a // switch from settings), restart the rebuilt session so capture // continues β€” setupSession alone leaves the new session stopped and - // the recording would silently go dead until the user stops. + // the recording would silently go dead until the user stops. But + // only when setupSession actually installed a usable input: with no + // input remaining (all mics gone), starting an inputless session + // would just spin a dead capture. Leave the recording active but + // paused β€” fetchAvailableDevices reassigns selectedDeviceId when a + // mic reconnects, which restarts capture here, and the partial + // (plus any resumed audio) still finalizes on the user's stop. if isRecording { - audioQueue.async { - if self.captureSession?.isRunning != true { - print("🎀 Restarting capture session after device change mid-recording") - self.captureSession?.startRunning() + if sessionReady { + audioQueue.async { + if self.captureSession?.isRunning != true { + print("🎀 Restarting capture session after device change mid-recording") + self.captureSession?.startRunning() + } } + } else { + print("🎀 No usable audio input after device change; recording paused until an input returns") } } // Persist so the selection survives app restarts. From e1a3f072d0f7c32df74b821405690635f4bd8ea3 Mon Sep 17 00:00:00 2001 From: s Date: Mon, 6 Jul 2026 05:13:46 +0700 Subject: [PATCH 42/42] Add unit coverage for behavior changed this session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tests for the pure logic introduced/changed in this branch that had no coverage (the reviewer flagged perf/UX-sensitive test gaps): - HotkeyOptionTests: the new Option+Space chord β€” keyCode(49)/.option/ isChord classification the event-tap branches on, plus the bare-modifier options, rawValue round-trip (persistence), and unique display names. - WaveformViewTests: peakSamples downsampling that replaced the random-noise placeholder β€” verifies real bucket count, 0...1 normalization, that peaks track actual loudness (a two-level synthesized WAV), and empty-on-missing. peakSamples changed from private to internal so @testable can reach it. - AIModelEngineRoutingTests: whisper/parakeet engine routing the Parakeet cache-validation fixes depend on β€” every catalog variant maps correctly, the partition is total and disjoint, unknown variants have no engine/size. 52 unit tests pass (was 36). make ci green; Release build clean. --- speaktype/Views/Components/WaveformView.swift | 2 +- .../AIModelEngineRoutingTests.swift | 63 ++++++++++++++++ speaktypeTests/HotkeyOptionTests.swift | 64 +++++++++++++++++ speaktypeTests/WaveformViewTests.swift | 72 +++++++++++++++++++ 4 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 speaktypeTests/AIModelEngineRoutingTests.swift create mode 100644 speaktypeTests/HotkeyOptionTests.swift create mode 100644 speaktypeTests/WaveformViewTests.swift diff --git a/speaktype/Views/Components/WaveformView.swift b/speaktype/Views/Components/WaveformView.swift index f96e778..b40a8ca 100644 --- a/speaktype/Views/Components/WaveformView.swift +++ b/speaktype/Views/Components/WaveformView.swift @@ -75,7 +75,7 @@ struct WaveformView: View { } } - private static func peakSamples(from url: URL, bucketCount: Int) -> [Float] { + static func peakSamples(from url: URL, bucketCount: Int) -> [Float] { guard let file = try? AVAudioFile(forReading: url) else { return [] } let frameCount = AVAudioFrameCount(file.length) diff --git a/speaktypeTests/AIModelEngineRoutingTests.swift b/speaktypeTests/AIModelEngineRoutingTests.swift new file mode 100644 index 0000000..0c8f80a --- /dev/null +++ b/speaktypeTests/AIModelEngineRoutingTests.swift @@ -0,0 +1,63 @@ +import XCTest +@testable import speaktype + +/// Covers modelβ†’engine routing. The Parakeet cache-validation and +/// load-from-cache fixes this session depend on every catalog variant mapping +/// to the correct engine, and on the whisper/parakeet partition being total +/// and consistent. +final class AIModelEngineRoutingTests: XCTestCase { + + func testWhisperVariantsRouteToWhisper() { + for variant in [ + "openai_whisper-large-v3_turbo", "openai_whisper-medium", + "openai_whisper-small.en", "openai_whisper-base.en", "openai_whisper-tiny", + ] { + XCTAssertEqual(AIModel.engineKind(for: variant), .whisper, "\(variant) should be Whisper") + XCTAssertEqual(AIModel.model(for: variant)?.engine, .whisper) + } + } + + func testParakeetCatalogVariantsRouteToParakeet() { + for variant in ParakeetCatalog.variants { + XCTAssertEqual( + AIModel.engineKind(for: variant), .parakeet, + "\(variant) should be Parakeet") + XCTAssertEqual(AIModel.model(for: variant)?.engine, .parakeet) + } + } + + func testUnknownVariantHasNoEngineOrSize() { + XCTAssertNil(AIModel.engineKind(for: "not-a-real-model")) + XCTAssertNil(AIModel.model(for: "not-a-real-model")) + XCTAssertNil(AIModel.expectedSize(for: "not-a-real-model")) + } + + func testEveryAvailableModelIsSelfConsistent() { + for model in AIModel.availableModels { + XCTAssertEqual( + AIModel.engineKind(for: model.variant), model.engine, + "engineKind(for:) disagrees with the model's own engine for \(model.variant)") + XCTAssertEqual(AIModel.model(for: model.variant)?.variant, model.variant) + if let size = AIModel.expectedSize(for: model.variant) { + XCTAssertGreaterThan(size, 0, "\(model.variant) expected size must be positive") + } + } + } + + func testModelsForEnginePartitionIsTotalAndDisjoint() { + let whisper = AIModel.models(for: .whisper) + let parakeet = AIModel.models(for: .parakeet) + XCTAssertFalse(whisper.isEmpty) + XCTAssertFalse(parakeet.isEmpty) + XCTAssertTrue(whisper.allSatisfy { $0.engine == .whisper }) + XCTAssertTrue(parakeet.allSatisfy { $0.engine == .parakeet }) + // The two engine buckets together account for every available model, + // with no variant claimed by both. + let whisperVariants = Set(whisper.map(\.variant)) + let parakeetVariants = Set(parakeet.map(\.variant)) + XCTAssertTrue(whisperVariants.isDisjoint(with: parakeetVariants)) + XCTAssertEqual( + whisperVariants.union(parakeetVariants), + Set(AIModel.availableModels.map(\.variant))) + } +} diff --git a/speaktypeTests/HotkeyOptionTests.swift b/speaktypeTests/HotkeyOptionTests.swift new file mode 100644 index 0000000..afda8d8 --- /dev/null +++ b/speaktypeTests/HotkeyOptionTests.swift @@ -0,0 +1,64 @@ +import XCTest +import AppKit +@testable import speaktype + +/// Covers the hotkey model, focused on the Option+Space chord added this +/// session (previously zero coverage β€” the chord's keyCode/modifier/isChord +/// classification is what the AppDelegate event-tap logic branches on). +final class HotkeyOptionTests: XCTestCase { + + func testOptionSpaceChordProperties() { + let chord = HotkeyOption.optionSpace + XCTAssertEqual(chord.keyCode, 49, "Option+Space chord must key off the Space keycode (49)") + XCTAssertEqual(chord.modifierFlag, .option) + XCTAssertTrue(chord.isChord, "optionSpace is the only modifier+key chord") + XCTAssertEqual(chord.displayName, "βŒ₯ + Space") + } + + func testOnlyOptionSpaceIsAChord() { + for option in HotkeyOption.allCases where option != .optionSpace { + XCTAssertFalse( + option.isChord, + "\(option.rawValue) is a bare modifier, not a chord") + } + } + + func testBareModifierOptionsUseModifierKeyCodes() { + // The non-chord options fire on a modifier's own keyDown/flagsChanged, + // so their keyCode must be a modifier keycode, never Space (49). + for option in HotkeyOption.allCases where !option.isChord { + XCTAssertNotEqual( + option.keyCode, 49, + "\(option.rawValue) should not use the Space keycode") + } + } + + func testModifierFlagMapping() { + XCTAssertEqual(HotkeyOption.fn.modifierFlag, .function) + XCTAssertEqual(HotkeyOption.leftCommand.modifierFlag, .command) + XCTAssertEqual(HotkeyOption.rightCommand.modifierFlag, .command) + XCTAssertEqual(HotkeyOption.leftControl.modifierFlag, .control) + XCTAssertEqual(HotkeyOption.rightControl.modifierFlag, .control) + XCTAssertEqual(HotkeyOption.leftOption.modifierFlag, .option) + XCTAssertEqual(HotkeyOption.rightOption.modifierFlag, .option) + XCTAssertEqual(HotkeyOption.optionSpace.modifierFlag, .option) + } + + func testDefaultIsFn() { + XCTAssertEqual(HotkeyOption.default, .fn) + } + + func testRawValueRoundTripForEveryCase() { + for option in HotkeyOption.allCases { + XCTAssertEqual( + HotkeyOption(rawValue: option.rawValue), option, + "rawValue round-trip failed for \(option.rawValue) β€” persistence would break") + } + } + + func testAllCasesIncludeChordAndHaveUniqueDisplayNames() { + XCTAssertTrue(HotkeyOption.allCases.contains(.optionSpace)) + let names = HotkeyOption.allCases.map(\.displayName) + XCTAssertEqual(Set(names).count, names.count, "hotkey display names must be unique in the picker") + } +} diff --git a/speaktypeTests/WaveformViewTests.swift b/speaktypeTests/WaveformViewTests.swift new file mode 100644 index 0000000..1dc10b2 --- /dev/null +++ b/speaktypeTests/WaveformViewTests.swift @@ -0,0 +1,72 @@ +import AVFoundation +import XCTest +@testable import speaktype + +/// Covers WaveformView.peakSamples, the real-audio downsampling that replaced +/// the previous random-noise placeholder. Verifies it reflects the actual +/// signal (bucket count, 0...1 normalization, real peak detection) rather than +/// decorative values. +final class WaveformViewTests: XCTestCase { + + /// Writes a 1s, 16 kHz mono WAV whose first half is quiet (amp 0.2) and + /// second half is loud (amp 0.9), so peak detection is observable. + private func makeTwoLevelWAV() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("waveform-test-\(UUID().uuidString).wav") + let format = AVAudioFormat( + commonFormat: .pcmFormatFloat32, sampleRate: 16000, channels: 1, interleaved: false)! + let file = try AVAudioFile(forWriting: url, settings: format.settings) + let frames: AVAudioFrameCount = 16000 + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frames)! + buffer.frameLength = frames + let channel = buffer.floatChannelData![0] + for i in 0..