diff --git a/.gitignore b/.gitignore index f3422e6..345af74 100644 --- a/.gitignore +++ b/.gitignore @@ -128,3 +128,6 @@ __pycache__ AudioWhisper.iconset/ .build-uv/ .beads/ + +# IDE +.idea \ No newline at end of file diff --git a/Sources/App/AppDelegate+Lifecycle.swift b/Sources/App/AppDelegate+Lifecycle.swift index 97717a8..6cf87db 100644 --- a/Sources/App/AppDelegate+Lifecycle.swift +++ b/Sources/App/AppDelegate+Lifecycle.swift @@ -52,7 +52,7 @@ internal extension AppDelegate { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { if AppSetupHelper.checkFirstRun() { - self.showWelcomeAndSettings() + Task { @MainActor in await self.showWelcomeAndSettings() } } } } @@ -76,9 +76,9 @@ internal extension AppDelegate { KeychainService.shared.getQuietly(service: service, account: account) != nil } - func showWelcomeAndSettings() { - let shouldOpenSettings = WelcomeWindow.showWelcomeDialog() - + @MainActor + func showWelcomeAndSettings() async { + let shouldOpenSettings = await WelcomeWindow.showWelcomeDialog() if shouldOpenSettings { DashboardWindowManager.shared.showDashboardWindow() } diff --git a/Sources/App/AppDelegate+Menu.swift b/Sources/App/AppDelegate+Menu.swift index 776bfd1..83c1c97 100644 --- a/Sources/App/AppDelegate+Menu.swift +++ b/Sources/App/AppDelegate+Menu.swift @@ -27,11 +27,7 @@ internal extension AppDelegate { } @objc func showHelp() { - let shouldOpenSettings = WelcomeWindow.showWelcomeDialog() - - if shouldOpenSettings { - DashboardWindowManager.shared.showDashboardWindow() - } + Task { @MainActor in await showWelcomeAndSettings() } } @objc func transcribeAudioFile() { diff --git a/Sources/Managers/Windows/WelcomeWindow.swift b/Sources/Managers/Windows/WelcomeWindow.swift index 1e8eea4..ff488ba 100644 --- a/Sources/Managers/Windows/WelcomeWindow.swift +++ b/Sources/Managers/Windows/WelcomeWindow.swift @@ -2,59 +2,96 @@ import AppKit import SwiftUI internal class WelcomeWindow { - static func showWelcomeDialog() -> Bool { - // Show the new SwiftUI welcome window - let welcomeView = WelcomeView() - let hostingController = NSHostingController(rootView: welcomeView) - - // Get the main screen dimensions for proper centering - let screenFrame = NSScreen.main?.frame ?? NSRect(x: 0, y: 0, width: 1920, height: 1080) - let windowWidth: CGFloat = 600 - let windowHeight: CGFloat = 650 - - let window = NSWindow( - contentRect: NSRect( - x: (screenFrame.width - windowWidth) / 2, - y: (screenFrame.height - windowHeight) / 2, - width: windowWidth, - height: windowHeight - ), - styleMask: [.titled, .closable, .miniaturizable], - backing: .buffered, - defer: false - ) - - window.contentViewController = hostingController - window.title = "Welcome to AudioWhisper" - window.isReleasedWhenClosed = false - - // Add window delegate to handle close button properly - let delegate = WelcomeWindowDelegate() - window.delegate = delegate - - // Ensure proper focus and activation - NSApplication.shared.activate(ignoringOtherApps: true) - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() - - // Force focus after a brief delay - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + // Tracks whether a dialog is already showing so we don't open two at once. + private static var isShowing = false + + /// Shows the welcome dialog as a non-modal window and waits asynchronously for the user to + /// complete or cancel the flow. Returns `true` if the user completed setup, `false` if they + /// closed the window without finishing. + /// + /// Using a regular (non-modal) window is critical: `NSApplication.runModal` blocks the main + /// thread in `NSModalPanelRunLoopMode`, which prevents `DispatchQueue.main` callbacks + /// (including Swift Concurrency's `@MainActor` executor) from firing. That would freeze any + /// `async`/`await` work started from within the welcome view. + @MainActor + static func showWelcomeDialog() async -> Bool { + guard !isShowing else { return false } + isShowing = true + defer { isShowing = false } + + return await withCheckedContinuation { continuation in + let state = WelcomeCompletionState(continuation: continuation) + + let welcomeView = WelcomeView { completed in + state.complete(result: completed) + } + let hostingController = NSHostingController(rootView: welcomeView) + + let screenFrame = NSScreen.main?.frame ?? NSRect(x: 0, y: 0, width: 1920, height: 1080) + let windowWidth: CGFloat = 600 + let windowHeight: CGFloat = 650 + + let window = NSWindow( + contentRect: NSRect( + x: (screenFrame.width - windowWidth) / 2, + y: (screenFrame.height - windowHeight) / 2, + width: windowWidth, + height: windowHeight + ), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, + defer: false + ) + window.contentViewController = hostingController + window.title = "Welcome to AudioWhisper" + window.isReleasedWhenClosed = false + + let delegate = WelcomeWindowDelegate(state: state) + window.delegate = delegate + state.delegate = delegate + state.window = window + NSApplication.shared.activate(ignoringOtherApps: true) - window.makeKey() + window.makeKeyAndOrderFront(nil) } - - // Run the window modally - let response = NSApplication.shared.runModal(for: window) - window.close() - - return response == .OK } } -internal class WelcomeWindowDelegate: NSObject, NSWindowDelegate { +// MARK: - Completion state + +/// Reference type shared between WelcomeWindow, WelcomeView's completion callback, and the +/// window delegate. Guarantees the continuation is resumed exactly once. +private final class WelcomeCompletionState { + private var resumed = false + private var continuation: CheckedContinuation + weak var window: NSWindow? + var delegate: WelcomeWindowDelegate? + + init(continuation: CheckedContinuation) { + self.continuation = continuation + } + + func complete(result: Bool) { + guard !resumed else { return } + resumed = true + window?.close() + window = nil + delegate = nil + continuation.resume(returning: result) + } +} + +// MARK: - Window delegate + +private class WelcomeWindowDelegate: NSObject, NSWindowDelegate { + private let state: WelcomeCompletionState + + init(state: WelcomeCompletionState) { + self.state = state + } + func windowShouldClose(_ sender: NSWindow) -> Bool { - // End the modal session when close button is clicked - NSApplication.shared.stopModal(withCode: .cancel) - return true + state.complete(result: false) + return false // state.complete already closes the window } -} \ No newline at end of file +} diff --git a/Sources/Models/ModelEntry.swift b/Sources/Models/ModelEntry.swift index b1f30f8..48c508a 100644 --- a/Sources/Models/ModelEntry.swift +++ b/Sources/Models/ModelEntry.swift @@ -40,7 +40,8 @@ internal struct LocalWhisperEntry: ModelEntry { guard let stage = stage else { return nil } switch stage { case .failed: return .red - case .preparing, .downloading, .processing, .completing: return .blue + case .preparing, .creatingModelFolder, .checkingExistingModels, .checkingStorageLimit, .checkingFreeSpace, .fetchingFileList, .downloading, .processing, .completing: + return .blue case .ready: return .green } } @@ -68,4 +69,3 @@ internal struct MLXEntry: ModelEntry { return isDownloading ? .blue : nil } } - diff --git a/Sources/Models/WhisperModel+WhisperKit.swift b/Sources/Models/WhisperModel+WhisperKit.swift index 3c05bab..d9f8c29 100644 --- a/Sources/Models/WhisperModel+WhisperKit.swift +++ b/Sources/Models/WhisperModel+WhisperKit.swift @@ -13,5 +13,21 @@ internal extension WhisperModel { return "openai_whisper-large-v3_turbo" } } -} + var openAIWhisperRepoName: String { + switch self { + case .tiny: + return "openai/whisper-tiny" + case .base: + return "openai/whisper-base" + case .small: + return "openai/whisper-small" + case .largeTurbo: + return "openai/whisper-large-v3-turbo" + } + } + + var openAIWhisperRepoURL: URL { + URL(string: "https://huggingface.co/\(openAIWhisperRepoName)")! + } +} diff --git a/Sources/Services/ModelManager.swift b/Sources/Services/ModelManager.swift index 0eb7fe2..3e9585f 100644 --- a/Sources/Services/ModelManager.swift +++ b/Sources/Services/ModelManager.swift @@ -10,6 +10,8 @@ internal class ModelManager { static let shared = ModelManager() var downloadProgress: [WhisperModel: Double] = [:] + var downloadFileProgress: [WhisperModel: DownloadFileProgress] = [:] + var downloadErrors: [WhisperModel: String] = [:] var downloadingModels: Set = [] var downloadStages: [WhisperModel: DownloadStage] = [:] var downloadedModels: Set = [] @@ -20,6 +22,7 @@ internal class ModelManager { private var fileSystemWatcher: DispatchSourceFileSystemObject? private var refreshTimer: Timer? private var isDeleteInProgress: Set = [] + private static let preparationTimeoutSeconds: TimeInterval = 15 init() { // Disable automatic file system watching to prevent unwanted re-downloads @@ -73,6 +76,7 @@ internal class ModelManager { } nonisolated func downloadModel(_ model: WhisperModel) async throws { + // Check if already downloading and mark as downloading let alreadyDownloading = await MainActor.run { if ModelManager.shared.downloadingModels.contains(model) { @@ -80,6 +84,12 @@ internal class ModelManager { } ModelManager.shared.downloadingModels.insert(model) ModelManager.shared.downloadStages[model] = .preparing + ModelManager.shared.downloadFileProgress[model] = DownloadFileProgress( + completedFiles: 0, + totalFiles: nil, + phase: .preparing + ) + ModelManager.shared.downloadErrors.removeValue(forKey: model) return false } @@ -87,49 +97,93 @@ internal class ModelManager { throw ModelError.alreadyDownloading } - // Check storage limits - let requiredSpace = model.estimatedSize - let currentModelsSize = await getTotalModelsSize() - let maxStorageGB = UserDefaults.standard.object(forKey: "maxModelStorageGB") as? Double ?? 5.0 - let maxStorageBytes = Int64(maxStorageGB * 1024 * 1024 * 1024) - - if currentModelsSize + requiredSpace > maxStorageBytes { - await MainActor.run { - ModelManager.shared.downloadingModels.remove(model) - ModelManager.shared.downloadStages.removeValue(forKey: model) + do { + await updateDownloadFileProgress(model, phase: .creatingModelFolder) + if let modelDirectory = WhisperKitStorage.modelDirectory(for: model) { + try FileManager.default.createDirectory(at: modelDirectory, withIntermediateDirectories: true) + } else { } - throw ModelError.storageLimitExceeded - } - - // Check available disk space - let availableSpace = try await getAvailableStorageSpace() - if availableSpace < requiredSpace + (100 * 1024 * 1024) { // Add 100MB buffer - await MainActor.run { - ModelManager.shared.downloadingModels.remove(model) - ModelManager.shared.downloadStages.removeValue(forKey: model) + + // Check storage limits + let requiredSpace = model.estimatedSize + await updateDownloadFileProgress(model, phase: .checkingExistingModels) + let currentModelsSize = try await withTimeout(seconds: Self.preparationTimeoutSeconds) { + await self.getTotalModelsSize() } - throw ModelError.insufficientStorage - } - - do { + + await updateDownloadFileProgress(model, phase: .checkingStorageLimit) + let maxStorageGB = UserDefaults.standard.object(forKey: "maxModelStorageGB") as? Double ?? 5.0 + let maxStorageBytes = Int64(maxStorageGB * 1024 * 1024 * 1024) + + if currentModelsSize + requiredSpace > maxStorageBytes { + throw ModelError.storageLimitExceeded + } + + // Check available disk space + await updateDownloadFileProgress(model, phase: .checkingFreeSpace) + let availableSpace = try await withTimeout(seconds: Self.preparationTimeoutSeconds) { + try await self.getAvailableStorageSpace() + } + if availableSpace < requiredSpace + (100 * 1024 * 1024) { // Add 100MB buffer + throw ModelError.insufficientStorage + } + // Update stage to downloading await MainActor.run { ModelManager.shared.downloadStages[model] = .downloading ModelManager.shared.downloadEstimates[model] = estimateDownloadTime(for: model) + ModelManager.shared.downloadFileProgress[model] = DownloadFileProgress( + completedFiles: 0, + totalFiles: nil, + phase: .fetchingFileList + ) } - - let config = WhisperKitConfig(model: model.whisperKitModelName) - - // Update stage to processing + + let supplementalFileCount = WhisperKitSupplementalFiles.requiredFilenames.count + let coreMLFileCount = try await WhisperKitCoreMLFiles.install( + for: model, + supplementalFileCount: supplementalFileCount + ) { completed, total, filename in + Task { @MainActor in + ModelManager.shared.downloadFileProgress[model] = DownloadFileProgress( + completedFiles: completed, + totalFiles: total, + phase: .coreML, + currentFileName: filename + ) + } + } + await MainActor.run { ModelManager.shared.downloadStages[model] = .processing } - - _ = try await WhisperKit(config) + + try await WhisperKitSupplementalFiles.install(for: model) { completed, total, filename in + Task { @MainActor in + ModelManager.shared.downloadFileProgress[model] = DownloadFileProgress( + completedFiles: coreMLFileCount + completed, + totalFiles: coreMLFileCount + total, + phase: .supplemental, + currentFileName: filename + ) + } + } // Update stage to completing await MainActor.run { ModelManager.shared.downloadStages[model] = .completing + if let currentProgress = ModelManager.shared.downloadFileProgress[model], + let totalFiles = currentProgress.totalFiles { + ModelManager.shared.downloadFileProgress[model] = DownloadFileProgress( + completedFiles: totalFiles, + totalFiles: totalFiles, + phase: .verifying + ) + } + } + + guard WhisperKitStorage.isModelDownloaded(model) else { + throw ModelError.downloadFailed } // Brief delay to show completion stage @@ -139,14 +193,22 @@ internal class ModelManager { await MainActor.run { ModelManager.shared.downloadingModels.remove(model) ModelManager.shared.downloadProgress.removeValue(forKey: model) + ModelManager.shared.downloadFileProgress[model] = DownloadFileProgress( + completedFiles: ModelManager.shared.downloadFileProgress[model]?.totalFiles ?? 0, + totalFiles: ModelManager.shared.downloadFileProgress[model]?.totalFiles, + phase: .ready + ) ModelManager.shared.downloadStages[model] = .ready ModelManager.shared.downloadEstimates.removeValue(forKey: model) + ModelManager.shared.downloadErrors.removeValue(forKey: model) ModelManager.shared.downloadedModels.insert(model) } + // Clear the ready stage after a moment DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { ModelManager.shared.downloadStages.removeValue(forKey: model) + ModelManager.shared.downloadFileProgress.removeValue(forKey: model) } // Send system notification @@ -157,18 +219,32 @@ internal class ModelManager { await MainActor.run { ModelManager.shared.downloadingModels.remove(model) ModelManager.shared.downloadProgress.removeValue(forKey: model) - ModelManager.shared.downloadStages[model] = .failed(error.localizedDescription) + let message = error.localizedDescription.isEmpty ? String(describing: error) : error.localizedDescription + ModelManager.shared.downloadStages[model] = .failed(message) + ModelManager.shared.downloadFileProgress[model] = DownloadFileProgress( + completedFiles: ModelManager.shared.downloadFileProgress[model]?.completedFiles ?? 0, + totalFiles: ModelManager.shared.downloadFileProgress[model]?.totalFiles, + phase: .failed, + errorMessage: message + ) + ModelManager.shared.downloadErrors[model] = message ModelManager.shared.downloadEstimates.removeValue(forKey: model) } - - // Clear the error stage after a moment - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { - ModelManager.shared.downloadStages.removeValue(forKey: model) - } - + throw error } } + + nonisolated private func updateDownloadFileProgress(_ model: WhisperModel, phase: DownloadFilePhase) async { + await MainActor.run { + ModelManager.shared.downloadStages[model] = phase.downloadStage + ModelManager.shared.downloadFileProgress[model] = DownloadFileProgress( + completedFiles: 0, + totalFiles: nil, + phase: phase + ) + } + } @MainActor private func updateDownloadProgress(_ model: WhisperModel, progress: Double) { @@ -372,6 +448,11 @@ internal class ModelManager { internal enum DownloadStage: Equatable { case preparing + case creatingModelFolder + case checkingExistingModels + case checkingStorageLimit + case checkingFreeSpace + case fetchingFileList case downloading case processing case completing @@ -381,6 +462,11 @@ internal enum DownloadStage: Equatable { var displayText: String { switch self { case .preparing: return "Preparing download..." + case .creatingModelFolder: return "Creating model folder..." + case .checkingExistingModels: return "Checking existing models..." + case .checkingStorageLimit: return "Checking storage limit..." + case .checkingFreeSpace: return "Checking free disk space..." + case .fetchingFileList: return "Fetching model file list..." case .downloading: return "Downloading model..." case .processing: return "Processing model files..." case .completing: return "Finalizing installation..." @@ -391,15 +477,125 @@ internal enum DownloadStage: Equatable { var isActive: Bool { switch self { - case .preparing, .downloading, .processing, .completing: return true + case .preparing, .creatingModelFolder, .checkingExistingModels, .checkingStorageLimit, .checkingFreeSpace, .fetchingFileList, .downloading, .processing, .completing: + return true case .ready, .failed: return false } } } -internal enum ModelError: LocalizedError { +internal struct DownloadFileProgress: Equatable, Sendable { + let completedFiles: Int + let totalFiles: Int? + let phase: DownloadFilePhase + let currentFileName: String? + let errorMessage: String? + + init( + completedFiles: Int, + totalFiles: Int?, + phase: DownloadFilePhase, + currentFileName: String? = nil, + errorMessage: String? = nil + ) { + self.completedFiles = completedFiles + self.totalFiles = totalFiles + self.phase = phase + self.currentFileName = currentFileName + self.errorMessage = errorMessage + } + + var displayText: String { + if let totalFiles { + return "\(min(completedFiles, totalFiles)) / \(totalFiles) files downloaded" + } + + return phase.displayText + } + + var detailText: String? { + if let errorMessage { + return errorMessage + } + + guard let currentFileName else { + return nil + } + + return "\(phase.displayText): \(currentFileName)" + } +} + +internal enum DownloadFilePhase: Equatable, Sendable { + case preparing + case creatingModelFolder + case checkingExistingModels + case checkingStorageLimit + case checkingFreeSpace + case fetchingFileList + case coreML + case supplemental + case verifying + case ready + case failed + + var displayText: String { + switch self { + case .preparing: + return "Preparing download..." + case .creatingModelFolder: + return "Creating model folder..." + case .checkingExistingModels: + return "Checking existing models..." + case .checkingStorageLimit: + return "Checking storage limit..." + case .checkingFreeSpace: + return "Checking free disk space..." + case .fetchingFileList: + return "Fetching model file list..." + case .coreML: + return "Downloading model files..." + case .supplemental: + return "Downloading tokenizer files..." + case .verifying: + return "Verifying model files..." + case .ready: + return "Model ready!" + case .failed: + return "Download failed" + } + } + + var downloadStage: DownloadStage { + switch self { + case .preparing: + return .preparing + case .creatingModelFolder: + return .creatingModelFolder + case .checkingExistingModels: + return .checkingExistingModels + case .checkingStorageLimit: + return .checkingStorageLimit + case .checkingFreeSpace: + return .checkingFreeSpace + case .fetchingFileList: + return .fetchingFileList + case .coreML: + return .downloading + case .supplemental, .verifying: + return .processing + case .ready: + return .ready + case .failed: + return .failed("Download failed") + } + } +} + +internal enum ModelError: LocalizedError, Equatable { case alreadyDownloading case downloadFailed + case downloadFileFailed(fileName: String, repo: String, reason: String) case modelNotFound case applicationSupportDirectoryNotFound case deletionNotSupported @@ -414,6 +610,8 @@ internal enum ModelError: LocalizedError { return "Model is already being downloaded" case .downloadFailed: return "Failed to download model" + case .downloadFileFailed(let fileName, let repo, let reason): + return "Failed to download \(fileName) from \(repo): \(reason)" case .modelNotFound: return "Model file not found" case .applicationSupportDirectoryNotFound: diff --git a/Sources/Services/WhisperKitCoreMLFiles.swift b/Sources/Services/WhisperKitCoreMLFiles.swift new file mode 100644 index 0000000..9bf3951 --- /dev/null +++ b/Sources/Services/WhisperKitCoreMLFiles.swift @@ -0,0 +1,146 @@ +import Foundation + +internal enum WhisperKitCoreMLFiles { + private static let repoName = "argmaxinc/whisperkit-coreml" + private static let revision = "main" + private static let requestTimeout: TimeInterval = 30 + + static func install( + for model: WhisperModel, + supplementalFileCount: Int, + progressHandler: @Sendable (_ completedFiles: Int, _ totalFiles: Int, _ currentFileName: String) -> Void + ) async throws -> Int { + guard let storageDirectory = WhisperKitStorage.storageDirectory(), + let modelDirectory = WhisperKitStorage.modelDirectory(for: model) else { + throw ModelError.applicationSupportDirectoryNotFound + } + + try FileManager.default.createDirectory(at: modelDirectory, withIntermediateDirectories: true) + + let files = try await remoteFiles(for: model) + guard !files.isEmpty else { + throw ModelError.downloadFileFailed( + fileName: model.whisperKitModelName, + repo: repoName, + reason: "No matching files found" + ) + } + + let totalFiles = files.count + supplementalFileCount + + for (index, relativeFilename) in files.enumerated() { + let destination = storageDirectory.appendingPathComponent(relativeFilename) + try await install( + relativeFilename: relativeFilename, + destination: destination + ) + progressHandler(index + 1, totalFiles, URL(fileURLWithPath: relativeFilename).lastPathComponent) + } + + return files.count + } + + private static func remoteFiles(for model: WhisperModel) async throws -> [String] { + let url = URL(string: "https://huggingface.co/api/models/\(repoName)/revision/\(revision)")! + var request = URLRequest(url: url, timeoutInterval: requestTimeout) + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw ModelError.downloadFileFailed( + fileName: "model file list", + repo: repoName, + reason: error.localizedDescription + ) + } + + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) else { + throw ModelError.downloadFileFailed( + fileName: "model file list", + repo: repoName, + reason: (response as? HTTPURLResponse).map { "HTTP \($0.statusCode)" } ?? "Invalid response" + ) + } + + do { + let decoded = try JSONDecoder().decode(HuggingFaceModelInfo.self, from: data) + let prefix = "\(model.whisperKitModelName)/" + return decoded.siblings + .map(\.rfilename) + .filter { $0.hasPrefix(prefix) } + .sorted() + } catch { + throw ModelError.downloadFileFailed( + fileName: "model file list", + repo: repoName, + reason: "Could not parse response" + ) + } + } + + private static func install(relativeFilename: String, destination: URL) async throws { + if FileManager.default.fileExists(atPath: destination.path) { + return + } + + try FileManager.default.createDirectory( + at: destination.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + let url = URL(string: "https://huggingface.co/\(repoName)/resolve/\(revision)/\(encodedPath(relativeFilename))")! + let request = URLRequest(url: url, timeoutInterval: requestTimeout) + + let temporaryURL: URL + let response: URLResponse + do { + (temporaryURL, response) = try await URLSession.shared.download(for: request) + } catch { + throw ModelError.downloadFileFailed( + fileName: relativeFilename, + repo: repoName, + reason: error.localizedDescription + ) + } + + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) else { + try? FileManager.default.removeItem(at: temporaryURL) + throw ModelError.downloadFileFailed( + fileName: relativeFilename, + repo: repoName, + reason: (response as? HTTPURLResponse).map { "HTTP \($0.statusCode)" } ?? "Invalid response" + ) + } + + let resourceValues = try temporaryURL.resourceValues(forKeys: [.fileSizeKey]) + guard (resourceValues.fileSize ?? 0) > 0 else { + try? FileManager.default.removeItem(at: temporaryURL) + throw ModelError.downloadFileFailed( + fileName: relativeFilename, + repo: repoName, + reason: "Downloaded file is empty" + ) + } + + try FileManager.default.moveItem(at: temporaryURL, to: destination) + } + + private static func encodedPath(_ path: String) -> String { + path.split(separator: "/") + .map { String($0).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String($0) } + .joined(separator: "/") + } +} + +private struct HuggingFaceModelInfo: Decodable { + let siblings: [HuggingFaceSibling] +} + +private struct HuggingFaceSibling: Decodable { + let rfilename: String +} diff --git a/Sources/Services/WhisperKitStorage.swift b/Sources/Services/WhisperKitStorage.swift index ca2d8cf..0398201 100644 --- a/Sources/Services/WhisperKitStorage.swift +++ b/Sources/Services/WhisperKitStorage.swift @@ -62,25 +62,8 @@ internal enum WhisperKitStorage { return false } - return containsTokenizerJSON(in: modelsDir, fileManager: fileManager) - } - - private static func containsTokenizerJSON(in directory: URL, fileManager: FileManager) -> Bool { - guard let enumerator = fileManager.enumerator( - at: directory, - includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles] - ) else { - return false - } - - for case let fileURL as URL in enumerator { - if fileURL.lastPathComponent == "tokenizer.json" { - return true - } - } - - return false + return fileManager.fileExists(atPath: modelsDir.appendingPathComponent("tokenizer.json").path) + && WhisperKitSupplementalFiles.areInstalled(for: model, fileManager: fileManager) } static func localModelPath(for model: WhisperModel, fileManager: FileManager = .default) -> String? { diff --git a/Sources/Services/WhisperKitSupplementalFiles.swift b/Sources/Services/WhisperKitSupplementalFiles.swift new file mode 100644 index 0000000..811375d --- /dev/null +++ b/Sources/Services/WhisperKitSupplementalFiles.swift @@ -0,0 +1,127 @@ +import Foundation + +internal enum WhisperKitSupplementalFiles { + static let requiredFilenames = [ + "tokenizer_config.json", + "special_tokens_map.json", + "added_tokens.json", + "normalizer.json", + "vocab.json", + "merges.txt", + "preprocessor_config.json", + "tokenizer.json", + ] + + static func areInstalled(for model: WhisperModel, fileManager: FileManager = .default) -> Bool { + guard let modelDirectory = WhisperKitStorage.modelDirectory(for: model, fileManager: fileManager) else { + return false + } + + let modelsDirectory = modelDirectory.appendingPathComponent("models", isDirectory: true) + for filename in requiredFilenames { + guard fileManager.fileExists(atPath: modelDirectory.appendingPathComponent(filename).path), + fileManager.fileExists(atPath: modelsDirectory.appendingPathComponent(filename).path) else { + return false + } + } + + return true + } + + static func install( + for model: WhisperModel, + progressHandler: (@Sendable (_ completedFiles: Int, _ totalFiles: Int, _ currentFileName: String) -> Void)? = nil + ) async throws { + guard let modelDirectory = WhisperKitStorage.modelDirectory(for: model) else { + throw ModelError.applicationSupportDirectoryNotFound + } + + let fileManager = FileManager.default + let modelsDirectory = modelDirectory.appendingPathComponent("models", isDirectory: true) + try fileManager.createDirectory(at: modelDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: modelsDirectory, withIntermediateDirectories: true) + + for (index, filename) in requiredFilenames.enumerated() { + try await install(filename: filename, model: model, modelDirectory: modelDirectory, modelsDirectory: modelsDirectory) + progressHandler?(index + 1, requiredFilenames.count, filename) + } + + try ensureRequiredFilesExist(for: model, fileManager: fileManager) + } + + private static func install(filename: String, model: WhisperModel, modelDirectory: URL, modelsDirectory: URL) async throws { + let topLevelDestination = modelDirectory.appendingPathComponent(filename) + let nestedDestination = modelsDirectory.appendingPathComponent(filename) + + if FileManager.default.fileExists(atPath: topLevelDestination.path) { + try copyIfNeeded(from: topLevelDestination, to: nestedDestination) + return + } + + if FileManager.default.fileExists(atPath: nestedDestination.path) { + try copyIfNeeded(from: nestedDestination, to: topLevelDestination) + return + } + + try await download(filename: filename, for: model, to: topLevelDestination) + try copyIfNeeded(from: topLevelDestination, to: nestedDestination) + } + + private static func ensureRequiredFilesExist(for model: WhisperModel, fileManager: FileManager) throws { + guard areInstalled(for: model, fileManager: fileManager) else { + throw ModelError.downloadFailed + } + } + + private static func download(filename: String, for model: WhisperModel, to destination: URL) async throws { + let url = URL(string: "\(model.openAIWhisperRepoURL.absoluteString)/resolve/main/\(filename)")! + let temporaryURL: URL + let response: URLResponse + + do { + (temporaryURL, response) = try await URLSession.shared.download(from: url) + } catch { + throw ModelError.downloadFileFailed( + fileName: filename, + repo: model.openAIWhisperRepoName, + reason: error.localizedDescription + ) + } + + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) else { + try? FileManager.default.removeItem(at: temporaryURL) + let statusCode = (response as? HTTPURLResponse)?.statusCode + throw ModelError.downloadFileFailed( + fileName: filename, + repo: model.openAIWhisperRepoName, + reason: statusCode.map { "HTTP \($0)" } ?? "Invalid response" + ) + } + + let resourceValues = try temporaryURL.resourceValues(forKeys: [.fileSizeKey]) + guard (resourceValues.fileSize ?? 0) > 0 else { + try? FileManager.default.removeItem(at: temporaryURL) + throw ModelError.downloadFileFailed( + fileName: filename, + repo: model.openAIWhisperRepoName, + reason: "Downloaded file is empty" + ) + } + + let fileManager = FileManager.default + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } + try fileManager.moveItem(at: temporaryURL, to: destination) + } + + private static func copyIfNeeded(from source: URL, to destination: URL) throws { + guard source.path != destination.path else { return } + + let fileManager = FileManager.default + guard !fileManager.fileExists(atPath: destination.path) else { return } + + try fileManager.copyItem(at: source, to: destination) + } +} diff --git a/Sources/Utilities/VersionInfo.swift b/Sources/Utilities/VersionInfo.swift index e9e264c..0cbebda 100644 --- a/Sources/Utilities/VersionInfo.swift +++ b/Sources/Utilities/VersionInfo.swift @@ -2,8 +2,8 @@ import Foundation struct VersionInfo { static let version = "2.1.0" - static let gitHash = "2fa2c747a923c317e366fb6a861750f947704589" - static let buildDate = "2026-02-06" + static let gitHash = "71fed3683d6e62f631c606003e57430622be0b2b" + static let buildDate = "2026-04-21" static var displayVersion: String { if gitHash != "dev-build" && gitHash != "unknown" && !gitHash.isEmpty { diff --git a/Sources/Views/ContentView+Recording.swift b/Sources/Views/ContentView+Recording.swift index 1630ab9..3e70b45 100644 --- a/Sources/Views/ContentView+Recording.swift +++ b/Sources/Views/ContentView+Recording.swift @@ -503,8 +503,8 @@ internal extension ContentView { if let stage { await MainActor.run { switch stage { - case .preparing: - progressMessage = "Preparing \(model.displayName) model…" + case .preparing, .creatingModelFolder, .checkingExistingModels, .checkingStorageLimit, .checkingFreeSpace, .fetchingFileList: + progressMessage = stage.displayText case .downloading: progressMessage = "Downloading \(model.displayName) model…" case .processing: diff --git a/Sources/Views/ContentView+Status.swift b/Sources/Views/ContentView+Status.swift index 07f21f3..102e4c8 100644 --- a/Sources/Views/ContentView+Status.swift +++ b/Sources/Views/ContentView+Status.swift @@ -27,8 +27,8 @@ internal extension ContentView { if let stage = modelManager.downloadStages[model] { switch stage { - case .preparing: - return "Preparing \(model.displayName) model…" + case .preparing, .creatingModelFolder, .checkingExistingModels, .checkingStorageLimit, .checkingFreeSpace, .fetchingFileList: + return stage.displayText case .downloading: return "Downloading \(model.displayName) model…" case .processing: diff --git a/Sources/Views/Dashboard/DashboardProviders+LocalWhisper.swift b/Sources/Views/Dashboard/DashboardProviders+LocalWhisper.swift index 904b420..0acaa93 100644 --- a/Sources/Views/Dashboard/DashboardProviders+LocalWhisper.swift +++ b/Sources/Views/Dashboard/DashboardProviders+LocalWhisper.swift @@ -52,6 +52,8 @@ internal extension DashboardProvidersView { let isDownloaded = modelManager.downloadedModels.contains(model) let stage = modelManager.getDownloadStage(for: model) let isDownloading = stage?.isActive ?? false + let fileProgress = modelManager.downloadFileProgress[model] + let modelError = modelManager.downloadErrors[model] return HStack(spacing: DashboardTheme.Spacing.md) { // Selection indicator @@ -88,6 +90,14 @@ internal extension DashboardProvidersView { Text(model.description) .font(DashboardTheme.Fonts.sans(12, weight: .regular)) .foregroundStyle(DashboardTheme.inkMuted) + + if let modelError, !modelError.isEmpty { + Text(modelError) + .font(DashboardTheme.Fonts.sans(11, weight: .regular)) + .foregroundStyle(Color(red: 0.75, green: 0.30, blue: 0.28)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } } Spacer() @@ -104,13 +114,26 @@ internal extension DashboardProvidersView { ProgressView() .controlSize(.small) - if let stage = stage { - Text(stage.displayText) + VStack(alignment: .leading, spacing: 2) { + Text(fileProgress?.displayText ?? stage?.displayText ?? "Downloading...") .font(DashboardTheme.Fonts.sans(10, weight: .medium)) .foregroundStyle(DashboardTheme.inkMuted) + + if let detailText = fileProgress?.detailText { + Text(detailText) + .font(DashboardTheme.Fonts.sans(9, weight: .regular)) + .foregroundStyle(DashboardTheme.inkFaint) + .lineLimit(1) + .truncationMode(.middle) + } } } - .frame(minWidth: 80) + .frame(minWidth: 140, alignment: .leading) + } else if modelError != nil && !isDownloaded { + Button("Retry") { + downloadModel(model) + } + .buttonStyle(.borderedProminent) } else if isDownloaded { HStack(spacing: 6) { Text("Installed") diff --git a/Sources/Views/WelcomeView.swift b/Sources/Views/WelcomeView.swift index 0ddb2e6..2bfde9b 100644 --- a/Sources/Views/WelcomeView.swift +++ b/Sources/Views/WelcomeView.swift @@ -2,21 +2,29 @@ import SwiftUI import AppKit internal struct WelcomeView: View { + /// Called when the welcome flow is done. `true` = user completed setup, `false` = cancelled. + var onComplete: (Bool) -> Void = { _ in } + + // The welcome flow always downloads the base model, regardless of any previously saved + // preference. UserDefaults survive app reinstalls on macOS, so selectedWhisperModel could + // hold a value the user chose before uninstalling (e.g. largeTurbo). We don't want to + // surprise them by downloading a large model during first-run setup. + private let welcomeModel: WhisperModel = .base + @State private var modelManager = ModelManager.shared @AppStorage(AppDefaults.Keys.transcriptionProvider) private var transcriptionProvider = AppDefaults.defaultTranscriptionProvider.rawValue - @AppStorage(AppDefaults.Keys.selectedWhisperModel) private var selectedWhisperModel = AppDefaults.defaultWhisperModel @State private var isDownloadingModel = false @State private var downloadError: String? @Environment(\.dismiss) private var dismiss - - private var downloadProgress: Double { - modelManager.downloadProgress[selectedWhisperModel] ?? 0 - } - + private var downloadStage: DownloadStage { - modelManager.downloadStages[selectedWhisperModel] ?? .preparing + modelManager.downloadStages[welcomeModel] ?? .preparing } - + + private var fileProgress: DownloadFileProgress? { + modelManager.downloadFileProgress[welcomeModel] + } + var body: some View { VStack(spacing: 0) { headerSection @@ -121,6 +129,11 @@ internal struct WelcomeView: View { .font(.caption) .foregroundStyle(.red) .fixedSize(horizontal: false, vertical: true) + } else if let error = modelManager.downloadErrors[welcomeModel], !error.isEmpty { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) } smartPasteInstructions @@ -151,10 +164,20 @@ internal struct WelcomeView: View { ProgressView() .controlSize(.small) - Text(downloadStageText) - .font(.caption) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 4) { + Text(fileProgress?.displayText ?? downloadStageText) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + + if let detailText = fileProgress?.detailText { + Text(detailText) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + .frame(maxWidth: .infinity, alignment: .leading) Text("The Base model (142MB) provides good accuracy with fast performance.") .font(.caption) @@ -167,6 +190,16 @@ internal struct WelcomeView: View { switch downloadStage { case .preparing: return "Preparing download..." + case .creatingModelFolder: + return "Creating model folder..." + case .checkingExistingModels: + return "Checking existing models..." + case .checkingStorageLimit: + return "Checking storage limit..." + case .checkingFreeSpace: + return "Checking free disk space..." + case .fetchingFileList: + return "Fetching model file list..." case .downloading: return "Downloading model..." case .processing: @@ -240,59 +273,62 @@ internal struct WelcomeView: View { private func startWithLocalWhisper() { - guard !isDownloadingModel && !isDismissing else { return } + + guard !isDownloadingModel && !isDismissing else { + return + } downloadError = nil - // Ensure the default model is available before completing the welcome flow. - let model = selectedWhisperModel + let model = welcomeModel + + // Check synchronously before spawning a task — avoids scheduling on the modal run loop + // when the model is already present, and prevents unnecessary state mutations. if WhisperKitStorage.isModelDownloaded(model) { completeWelcome() return } isDownloadingModel = true - Task { + + // Task.detached runs on the cooperative thread pool — not on @MainActor. + // This is necessary because WelcomeView is presented via NSApplication.runModal(), + // which uses NSModalPanelRunLoopMode. Swift Concurrency's @MainActor executor + // only processes work in the default/event-tracking run loop modes, so a regular + // Task { } (which inherits @MainActor) will never start inside a modal session. + let manager = modelManager + Task.detached(priority: .userInitiated) { do { - try await modelManager.downloadModel(model) - await modelManager.refreshModelStates() + try await manager.downloadModel(model) + await manager.refreshModelStates() await MainActor.run { - isDownloadingModel = false - } - await MainActor.run { - completeWelcome() + self.isDownloadingModel = false + self.completeWelcome() } } catch { await MainActor.run { - isDownloadingModel = false - downloadError = error.localizedDescription.isEmpty ? String(describing: error) : error.localizedDescription + self.isDownloadingModel = false + self.downloadError = error.localizedDescription.isEmpty ? String(describing: error) : error.localizedDescription } } } } + @State private var isDismissing = false + @MainActor private func completeWelcome() { + guard !isDismissing else { return } + isDismissing = true + // Persist defaults so service-layer code that reads UserDefaults directly is deterministic. UserDefaults.standard.set(AppDefaults.defaultTranscriptionProvider.rawValue, forKey: AppDefaults.Keys.transcriptionProvider) UserDefaults.standard.set(AppDefaults.defaultWhisperModel.rawValue, forKey: AppDefaults.Keys.selectedWhisperModel) UserDefaults.standard.set(true, forKey: AppDefaults.Keys.hasCompletedWelcome) UserDefaults.standard.set(AppDefaults.currentWelcomeVersion, forKey: AppDefaults.Keys.lastWelcomeVersion) - dismissWindow() - } - - - @State private var isDismissing = false - - private func dismissWindow() { - // Prevent multiple dismiss attempts - guard !isDismissing else { return } - - isDismissing = true - - // Stop the modal - this will return control to WelcomeWindow.showWelcomeDialog() - NSApplication.shared.stopModal(withCode: .OK) + // Signal completion. WelcomeWindow closes the NSWindow and resumes the async caller. + onComplete(true) } } diff --git a/Tests/ModelDownloadProgressTests.swift b/Tests/ModelDownloadProgressTests.swift new file mode 100644 index 0000000..18e6449 --- /dev/null +++ b/Tests/ModelDownloadProgressTests.swift @@ -0,0 +1,193 @@ +import XCTest +@testable import AudioWhisper + +// MARK: - DownloadFileProgress + +final class DownloadFileProgressTests: XCTestCase { + + // MARK: displayText + + func testDisplayTextShowsPhaseTextWhenTotalFilesUnknown() { + let progress = DownloadFileProgress(completedFiles: 0, totalFiles: nil, phase: .fetchingFileList) + XCTAssertEqual(progress.displayText, DownloadFilePhase.fetchingFileList.displayText) + } + + func testDisplayTextShowsFileCountWhenTotalFilesKnown() { + let progress = DownloadFileProgress(completedFiles: 5, totalFiles: 47, phase: .coreML) + XCTAssertEqual(progress.displayText, "5 / 47 files downloaded") + } + + func testDisplayTextClampedWhenCompletedExceedsTotal() { + // completedFiles can briefly exceed totalFiles during progress callbacks; displayText must not show that + let progress = DownloadFileProgress(completedFiles: 50, totalFiles: 47, phase: .coreML) + XCTAssertEqual(progress.displayText, "47 / 47 files downloaded") + } + + func testDisplayTextAtZeroOfKnownTotal() { + let progress = DownloadFileProgress(completedFiles: 0, totalFiles: 10, phase: .coreML) + XCTAssertEqual(progress.displayText, "0 / 10 files downloaded") + } + + // MARK: detailText + + func testDetailTextShowsErrorMessageWhenPresent() { + let progress = DownloadFileProgress( + completedFiles: 0, totalFiles: nil, + phase: .failed, + errorMessage: "Connection timed out" + ) + XCTAssertEqual(progress.detailText, "Connection timed out") + } + + func testDetailTextShowsPhaseAndFileNameWhenDownloading() { + let progress = DownloadFileProgress( + completedFiles: 3, totalFiles: 47, + phase: .coreML, + currentFileName: "AudioEncoder.mlmodelc" + ) + XCTAssertEqual(progress.detailText, "\(DownloadFilePhase.coreML.displayText): AudioEncoder.mlmodelc") + } + + func testDetailTextIsNilWhenNoFileName() { + let progress = DownloadFileProgress(completedFiles: 0, totalFiles: nil, phase: .checkingFreeSpace) + XCTAssertNil(progress.detailText) + } + + func testErrorMessageTakesPrecedenceOverFileName() { + let progress = DownloadFileProgress( + completedFiles: 1, totalFiles: 10, + phase: .failed, + currentFileName: "some_file.bin", + errorMessage: "HTTP 404" + ) + XCTAssertEqual(progress.detailText, "HTTP 404") + } +} + +// MARK: - DownloadFilePhase + +final class DownloadFilePhaseTests: XCTestCase { + + // MARK: displayText + + func testAllPhasesHaveNonEmptyDisplayText() { + let phases: [DownloadFilePhase] = [ + .preparing, .creatingModelFolder, .checkingExistingModels, + .checkingStorageLimit, .checkingFreeSpace, .fetchingFileList, + .coreML, .supplemental, .verifying, .ready, .failed + ] + for phase in phases { + XCTAssertFalse(phase.displayText.isEmpty, "displayText is empty for phase \(phase)") + } + } + + // MARK: downloadStage mapping + + func testPreparationPhasesMapToMatchingStages() { + XCTAssertEqual(DownloadFilePhase.preparing.downloadStage, .preparing) + XCTAssertEqual(DownloadFilePhase.creatingModelFolder.downloadStage, .creatingModelFolder) + XCTAssertEqual(DownloadFilePhase.checkingExistingModels.downloadStage, .checkingExistingModels) + XCTAssertEqual(DownloadFilePhase.checkingStorageLimit.downloadStage, .checkingStorageLimit) + XCTAssertEqual(DownloadFilePhase.checkingFreeSpace.downloadStage, .checkingFreeSpace) + XCTAssertEqual(DownloadFilePhase.fetchingFileList.downloadStage, .fetchingFileList) + } + + func testDownloadPhasesMapToActiveStages() { + XCTAssertEqual(DownloadFilePhase.coreML.downloadStage, .downloading) + XCTAssertEqual(DownloadFilePhase.supplemental.downloadStage, .processing) + XCTAssertEqual(DownloadFilePhase.verifying.downloadStage, .processing) + } + + func testTerminalPhasesMapToCorrectStages() { + XCTAssertEqual(DownloadFilePhase.ready.downloadStage, .ready) + XCTAssertEqual(DownloadFilePhase.failed.downloadStage, .failed("Download failed")) + } +} + +// MARK: - DownloadStage (new cases) + +final class DownloadStageNewCasesTests: XCTestCase { + + func testNewPreparationStagesAreActive() { + let activePrepStages: [DownloadStage] = [ + .creatingModelFolder, .checkingExistingModels, + .checkingStorageLimit, .checkingFreeSpace, .fetchingFileList + ] + for stage in activePrepStages { + XCTAssertTrue(stage.isActive, "isActive should be true for \(stage)") + } + } + + func testNewPreparationStagesHaveDisplayText() { + let stages: [DownloadStage] = [ + .creatingModelFolder, .checkingExistingModels, + .checkingStorageLimit, .checkingFreeSpace, .fetchingFileList + ] + for stage in stages { + XCTAssertFalse(stage.displayText.isEmpty, "displayText is empty for \(stage)") + } + } + + func testReadyAndFailedAreNotActive() { + XCTAssertFalse(DownloadStage.ready.isActive) + XCTAssertFalse(DownloadStage.failed("some error").isActive) + } +} + +// MARK: - ModelError + +final class ModelErrorDescriptionTests: XCTestCase { + + func testDownloadFileFailedContainsFileName() { + let error = ModelError.downloadFileFailed( + fileName: "tokenizer.json", + repo: "openai/whisper-base", + reason: "HTTP 403" + ) + let description = error.errorDescription ?? "" + XCTAssertTrue(description.contains("tokenizer.json"), "Error description should mention the file name") + XCTAssertTrue(description.contains("openai/whisper-base"), "Error description should mention the repo") + XCTAssertTrue(description.contains("HTTP 403"), "Error description should mention the reason") + } + + func testAlreadyDownloadingHasDescription() { + let error = ModelError.alreadyDownloading + XCTAssertFalse((error.errorDescription ?? "").isEmpty) + } + + func testDownloadFailedHasDescription() { + let error = ModelError.downloadFailed + XCTAssertFalse((error.errorDescription ?? "").isEmpty) + } +} + +// MARK: - WhisperModel OpenAI repo + +final class WhisperModelOpenAIRepoTests: XCTestCase { + + func testOpenAIWhisperRepoNames() { + XCTAssertEqual(WhisperModel.tiny.openAIWhisperRepoName, "openai/whisper-tiny") + XCTAssertEqual(WhisperModel.base.openAIWhisperRepoName, "openai/whisper-base") + XCTAssertEqual(WhisperModel.small.openAIWhisperRepoName, "openai/whisper-small") + XCTAssertEqual(WhisperModel.largeTurbo.openAIWhisperRepoName, "openai/whisper-large-v3-turbo") + } + + func testOpenAIWhisperRepoURLsAreValid() { + for model in WhisperModel.allCases { + let url = model.openAIWhisperRepoURL + XCTAssertEqual(url.scheme, "https", "URL scheme should be https for \(model)") + XCTAssertEqual(url.host, "huggingface.co", "URL host should be huggingface.co for \(model)") + XCTAssertTrue(url.path.hasPrefix("/openai/whisper-"), "URL path should start with /openai/whisper- for \(model)") + } + } + + func testOpenAIWhisperRepoURLMatchesRepoName() { + for model in WhisperModel.allCases { + let url = model.openAIWhisperRepoURL + XCTAssertTrue( + url.absoluteString.contains(model.openAIWhisperRepoName), + "URL should contain repo name for \(model)" + ) + } + } +} diff --git a/Tests/README.md b/Tests/README.md index a01faa1..6f32ac9 100644 --- a/Tests/README.md +++ b/Tests/README.md @@ -11,21 +11,24 @@ This test suite provides thorough coverage of the AudioWhisper application inclu - Keychain security operations - UI components and user interactions - Utility functions and helpers +- Model download progress tracking and error reporting ## Test Structure ``` Tests/ -├── README.md # This documentation -├── Mocks/ # Mock objects for external dependencies -│ ├── MockAVAudioEngine.swift # AVFoundation audio engine mock -│ ├── MockAVAudioRecorder.swift # Audio recorder mock -│ ├── MockKeychain.swift # Keychain operations mock -│ └── MockURLSession.swift # Network session mock -├── AudioRecorderTests.swift # Audio recording functionality tests -├── SpeechToTextServiceTests.swift # API integration and transcription tests -├── SettingsViewTests.swift # Settings and preferences tests -└── UtilityTests.swift # Utility functions and helpers tests +├── README.md # This documentation +├── Mocks/ # Mock objects for external dependencies +│ ├── MockAVAudioEngine.swift # AVFoundation audio engine mock +│ ├── MockAVAudioRecorder.swift # Audio recorder mock +│ ├── MockKeychain.swift # Keychain operations mock +│ └── MockURLSession.swift # Network session mock +├── AudioRecorderTests.swift # Audio recording functionality tests +├── ModelDownloadProgressTests.swift # Download progress types and model metadata tests +├── ModelManagerTests.swift # Model manager state and lifecycle tests +├── SpeechToTextServiceTests.swift # API integration and transcription tests +├── SettingsViewTests.swift # Settings and preferences tests +└── UtilityTests.swift # Utility functions and helpers tests ``` ## Running Tests @@ -115,7 +118,29 @@ swift test --parallel - `testProviderSelectionPersistence()` - Tests preference persistence - `testConcurrentAPIKeyOperations()` - Verifies thread safety -### 4. UtilityTests +### 4. ModelDownloadProgressTests +**Focus**: Value types and enums introduced for granular model download progress + +**Key Test Areas**: +- `DownloadFileProgress` display text with and without known total file count +- Clamping behaviour when `completedFiles` temporarily exceeds `totalFiles` +- `detailText` priority: error message > filename + phase > phase alone +- `DownloadFilePhase` display text completeness and mapping to `DownloadStage` +- New `DownloadStage` preparation cases (`creatingModelFolder`, `checkingExistingModels`, etc.) are active and have display text +- `ModelError.downloadFileFailed` error description contains file name, repo, and reason +- `WhisperModel.openAIWhisperRepoName` and `openAIWhisperRepoURL` correctness + +**Critical Tests**: +- `testDisplayTextClampedWhenCompletedExceedsTotal()` — prevents "50 / 47 files" in the UI +- `testErrorMessageTakesPrecedenceOverFileName()` — error is always shown over in-progress file name +- `testDownloadStageMapping` group — ensures phase→stage mapping stays consistent +- `testNewPreparationStagesAreActive()` — spinner shows during all preparation sub-steps +- `testDownloadFileFailedContainsFileName()` — actionable error messages for debugging + +**Not covered** (network I/O with no injection point): +- `WhisperKitCoreMLFiles.install` and `WhisperKitSupplementalFiles.install` use `URLSession.shared` directly. Testing them would require either a live network call or refactoring the production code to accept a `URLSession` parameter. Both options are out of scope for this change. + +### 5. UtilityTests **Focus**: Helper functions, data conversion, and system utilities **Key Test Areas**: