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: diff --git a/README.md b/README.md index d90ad7c..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 @@ -104,7 +105,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/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.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", diff --git a/speaktype/App/AppDelegate.swift b/speaktype/App/AppDelegate.swift index fe76309..0c42f5e 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 { @@ -14,8 +13,16 @@ 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") + private var updateWindow: NSWindow? func applicationDidFinishLaunching(_ notification: Notification) { + UpdateService.registerDefaults() + miniRecorderController = MiniRecorderWindowController() // Show the always-present resting pill so the recorder lives on screen. miniRecorderController?.showIdleRecorder() @@ -27,6 +34,22 @@ 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() + 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() + } + + schedulePeriodicUpdateChecks() checkForUpdatesOnLaunch() UpdateService.shared.showUpdateWindowPublisher @@ -167,7 +190,42 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Hotkey Monitoring + private func reconfigureHotkeyMonitoringIfNeeded() { + let current = getSelectedHotkey() + // 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() + } + + 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 @@ -183,6 +241,64 @@ 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. + + // 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. + private func installModifierComboMonitors() { + guard globalKeyDownMonitor == nil && localKeyDownMonitor == nil else { return } + globalKeyDownMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in self?.handleModifierComboEvent(event) @@ -195,10 +311,29 @@ 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 } - 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) @@ -238,16 +373,58 @@ 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 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 !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) } @@ -263,6 +440,23 @@ 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 } + + // 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 !event.modifierFlags.contains(currentHotkey.modifierFlag) { + handleHotkeyStateChange(isPressed: false) + } + return + } + guard event.keyCode == currentHotkey.keyCode else { return } let isPressed = event.modifierFlags.contains(currentHotkey.modifierFlag) @@ -275,6 +469,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 @@ -298,6 +493,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } else if !isPressed && isHotkeyPressed { isHotkeyPressed = false + removeModifierComboMonitors() let recordingMode = UserDefaults.standard.integer(forKey: "recordingMode") if recordingMode == 0 { @@ -316,6 +512,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { guard event.keyCode != Self.emojiSuppressionKeyCode else { return } isHotkeyPressed = false + removeModifierComboMonitors() miniRecorderController?.cancelRecording() } @@ -353,21 +550,44 @@ 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 } + // 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) @@ -377,6 +597,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { window.isReleasedWhenClosed = false window.center() window.isMovableByWindowBackground = true + updateWindow = window window.makeKeyAndOrderFront(nil) NSApp.activate() } 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/Controllers/MiniRecorderWindowController.swift b/speaktype/Controllers/MiniRecorderWindowController.swift index 5528388..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 @@ -176,24 +196,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) 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/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/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/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 } } 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/AudioRecordingService.swift b/speaktype/Services/AudioRecordingService.swift index d04d5e4..9c1d826 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 @@ -20,7 +16,29 @@ 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. 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 { + 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. if let selectedDeviceId { UserDefaults.standard.set(selectedDeviceId, forKey: Self.selectedDeviceDefaultsKey) @@ -41,17 +59,24 @@ 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 + // 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 + /// 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( @@ -109,15 +134,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:) @@ -177,21 +193,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 @@ -215,6 +234,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 @@ -244,51 +264,79 @@ class AudioRecordingService: NSObject, ObservableObject { } } - func startRecording() { - requestPermission() + /// 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: 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 + // 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: onStarted) + } + } + default: + print("Microphone access denied") + onStarted?(false) + } + } - 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 liveWaveSamples = [] resetMainWriterState() - resetChunkWriterState() + // 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() + self.pendingWavePeak = 0 + self.lastLevelPublishUptime = 0 + } 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 @@ -330,7 +378,22 @@ class AudioRecordingService: NSObject, ObservableObject { audioQueue.async { self.captureSession?.stopRunning() } + onStarted?(false) + return + } + + audioQueue.async { + if self.captureSession?.isRunning != true { + print("🎤 Starting capture session...") + 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) } } @@ -342,11 +405,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)) } @@ -363,42 +423,29 @@ 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() + let writer = self.assetWriter + let writerInput = self.assetWriterInput - 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() - } + // 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) } } - - // --- Finalize main (full) recording --- - let writer = self.assetWriter - let writerInput = self.assetWriterInput + self.pendingSampleBuffers.removeAll() self.resetMainWriterState() if let writer { @@ -437,17 +484,7 @@ 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") - } - } - - 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, @@ -468,23 +505,8 @@ 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 getRecordingsDirectory() -> URL { + Self.recordingsDirectory() } private func scheduleIdleSessionStop(delay: TimeInterval = 8) { @@ -521,120 +543,36 @@ 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) - - // --- Main writer (full recording) --- - if writer.status == .writing { - if !isSessionStarted { - writer.startSession(atSourceTime: pts) - isSessionStarted = true - } - - if input.isReadyForMoreMediaData { - guard !isStopping else { return } - input.append(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 } - // --- 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) + // --- Main writer (full recording) --- + if !isSessionStarted { + let firstBuffer = pendingSampleBuffers.first ?? sampleBuffer + writer.startSession(atSourceTime: CMSampleBufferGetPresentationTimeStamp(firstBuffer)) + isSessionStarted = true } - guard let cw = chunkAssetWriter, let ci = chunkAssetWriterInput, - cw.status == .writing - else { return } - - if !chunkIsSessionStarted { - cw.startSession(atSourceTime: pts) - chunkIsSessionStarted = true - chunkStartTime = Date() + if !pendingSampleBuffers.isEmpty { + for buffered in pendingSampleBuffers where input.isReadyForMoreMediaData { + input.append(buffered) + } + pendingSampleBuffers.removeAll() } - if ci.isReadyForMoreMediaData { + if input.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) - } + input.append(sampleBuffer) } } @@ -783,10 +721,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) 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..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) { @@ -90,7 +97,9 @@ class HistoryService: ObservableObject { } func clearAll() { + let itemsToDelete = items items.removeAll() + itemsToDelete.forEach(removeAudioFileIfNeeded(for:)) saveHistory() } @@ -132,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) @@ -160,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() { diff --git a/speaktype/Services/ModelDownloadService.swift b/speaktype/Services/ModelDownloadService.swift index 4b3b358..0289082 100644 --- a/speaktype/Services/ModelDownloadService.swift +++ b/speaktype/Services/ModelDownloadService.swift @@ -114,10 +114,43 @@ 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. + @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 + } + + /// 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 @@ -152,20 +185,30 @@ 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") } } await MainActor.run { - // Clear all previous progress - self.downloadProgress.removeAll() + self.hasCompletedInitialScan = true + + // 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 || $0.value >= 1.0 + } // Only mark models that actually exist for variant in foundModels { @@ -222,7 +265,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 +285,22 @@ 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 + } + + // 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 AIModel.engineKind(for: variant) == .parakeet { + if engineKind == .parakeet { downloadParakeetModel(variant: variant) return } @@ -249,15 +308,20 @@ class ModelDownloadService: ObservableObject { isDownloading[variant] = true downloadProgress[variant] = 0.0 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. @@ -273,18 +337,25 @@ 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 { + // 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 self.activeTasks[variant] = nil // Cleanup task } } catch { @@ -298,12 +369,19 @@ 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.downloadError[variant] = "Cleaning duplicates..." + 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 @@ -311,34 +389,41 @@ class ModelDownloadService: ObservableObject { if Task.isCancelled { return } await MainActor.run { - self.downloadError[variant] = "Retrying download..." + self.downloadStatus[variant] = "Retrying download..." } // Retry download once do { let _ = try await WhisperKit.download(variant: variant, downloadBase: ModelStorage.whisperKitBase, progressCallback: { progress in DispatchQueue.main.async { + 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 + self.downloadStatus[variant] = nil self.activeTasks[variant] = nil } } catch { 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] = "Error: \(error.localizedDescription)\n\nTry clicking the trash icon to manually clean cache." + self.downloadError[variant] = + "Download failed: \(error.localizedDescription). Try downloading again." + self.downloadStatus[variant] = nil self.activeTasks[variant] = nil } } @@ -346,9 +431,12 @@ 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] = error.localizedDescription + "\n\n(Try Trash icon to clean cache)" + self.downloadError[variant] = + "Download failed: \(error.localizedDescription). Try downloading again." + self.downloadStatus[variant] = nil self.activeTasks[variant] = nil } } @@ -362,16 +450,24 @@ class ModelDownloadService: ObservableObject { isDownloading[variant] = true downloadProgress[variant] = 0.0 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, progressHandler: { progress in DispatchQueue.main.async { + guard self.isDownloading[variant] == true, + self.ownsDownload(variant, generation: generation) else { return } self.downloadProgress[variant] = progress.fractionCompleted } }) @@ -380,6 +476,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 @@ -391,6 +488,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 @@ -402,14 +500,37 @@ 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." + } + 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) await MainActor.run { + guard expectedGeneration == nil + || self.downloadGeneration[variant] == expectedGeneration else { return } self.downloadProgress[variant] = 0.0 self.isDownloading[variant] = false } @@ -437,12 +558,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 } @@ -452,25 +577,77 @@ class ModelDownloadService: ObservableObject { } func cancelDownload(for variant: String) { - if let task = activeTasks[variant] { - 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 } - + + task.cancel() + 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 - - // Delete any partial download - Task { - let result = await deleteModel(variant: variant) + downloadStatus[variant] = nil + + // 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 + // past the tombstone (otherwise we'd delete the retry's files). + let cleanupTask = Task { + _ = await task.value + let stillTombstoned = await MainActor.run { + self.downloadGeneration[variant] == tombstone + } + guard stillTombstoned else { + print("↩️ Skipping partial-download cleanup for \(variant): a newer download claimed it") + return + } + 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 - + + /// 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 diff --git a/speaktype/Services/Transcription/ParakeetEngine.swift b/speaktype/Services/Transcription/ParakeetEngine.swift index 7111142..4e8eb5c 100644 --- a/speaktype/Services/Transcription/ParakeetEngine.swift +++ b/speaktype/Services/Transcription/ParakeetEngine.swift @@ -42,17 +42,27 @@ 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() {} 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 } 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 { @@ -63,17 +73,51 @@ 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) + + // 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() + } + + WhisperService.markFirstLoadCompletedForUI(variant: variant) self.manager = manager currentModelVariant = variant isInitialized = true loadingStage = "" } + func unloadModelIfCurrent(variant: String) async { + guard currentModelVariant == variant || loadingVariant == variant else { return } + loadingVariant = "" + + 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 84a0a68..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 @@ -83,14 +90,30 @@ 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 } + /// 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 { - 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..efc4cf5 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,14 +136,26 @@ 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 /// 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 +204,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) @@ -199,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 = "" } @@ -209,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 { @@ -283,7 +322,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 +342,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, @@ -325,13 +376,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)") + + do { + try fm.copyItem(at: sourceApp, to: tempURL) - // Remove old app - if fm.fileExists(atPath: destURL.path) { - try fm.removeItem(at: destURL) + 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 { @@ -358,18 +418,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 +504,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) @@ -553,6 +645,7 @@ extension UpdateService: URLSessionDownloadDelegate { enum UpdateError: LocalizedError, Equatable { case downloadFailed(String) + case downloadCancelled case mountFailed case appNotFoundInDMG case copyFailed(String) @@ -565,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/Services/WhisperService.swift b/speaktype/Services/WhisperService.swift index 3e8ce44..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)) @@ -74,6 +96,8 @@ class WhisperService { case fileNotFound case alreadyLoading case loadingTimeout + case unsupportedModelVariant + case modelFilesMissing var errorDescription: String? { switch self { @@ -81,7 +105,12 @@ 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: + return "Model files are missing or incomplete — download the model again in Settings → AI Models." } } } @@ -98,6 +127,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 +166,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 +184,36 @@ class WhisperService { } @MainActor - private func performModelLoad(variant: String) async throws { + func unloadModelIfCurrent(variant: String) { + // 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 + 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 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." ) @@ -207,15 +262,26 @@ 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 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) lastLoadDuration = loadDuration print("⏱️ Model loaded in \(String(format: "%.1f", loadDuration))s") + Self.markFirstLoadCompleted(variant: variant) currentModelVariant = variant isInitialized = true isLoading = false @@ -234,6 +300,53 @@ class WhisperService { } } + /// 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 ? 1200 : 600 + } + + /// 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 } @@ -258,7 +371,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)") @@ -266,32 +379,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: " ")) - - print("🔪 Chunk done: \(text.prefix(40))...") - // 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/Components/ModelRow.swift b/speaktype/Views/Components/ModelRow.swift index 0c6c55c..595aec1 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,9 @@ 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] } + var downloadStatus: String? { downloadService.downloadStatus[model.variant] } + var isFirstLoad: Bool { !WhisperService.hasCompletedFirstLoad(variant: model.variant) } // MARK: - Body @@ -45,6 +49,12 @@ 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) + } + if let downloadStatus { + note(icon: "arrow.triangle.2.circlepath", text: downloadStatus, tint: .textMuted) + } } Spacer(minLength: 8) @@ -73,6 +83,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 +139,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 +161,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))) @@ -177,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 { @@ -211,7 +233,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 } + } } } @@ -227,7 +252,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/Components/UpdateSheet.swift b/speaktype/Views/Components/UpdateSheet.swift index b72e861..3786b20 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) } @@ -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) @@ -144,7 +152,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/Components/WaveformView.swift b/speaktype/Views/Components/WaveformView.swift index d28380c..b40a8ca 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 } } diff --git a/speaktype/Views/Overlays/MiniRecorderView.swift b/speaktype/Views/Overlays/MiniRecorderView.swift index ed97c4a..5e0468d 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,34 @@ 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 { + // 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 = 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)" } + } + return base + } + private var processingContent: some View { Text(statusMessage) .font(Typography.pillLabel) @@ -450,30 +471,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 { @@ -632,6 +636,15 @@ 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. + // 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 } } @@ -719,6 +732,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 +762,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" @@ -751,8 +783,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) { @@ -777,11 +825,21 @@ 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. onStarted resolves + // once the writer setup task confirms capture, keeping "Switching + // input…" up until then. await MainActor.run { - isProcessing = false - isListening = true + audioRecorder.startRecording { started in + isProcessing = false + isListening = started + if !started { + statusMessage = "Couldn't switch input" + onCancel?() + } + } } } } @@ -834,6 +892,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 } @@ -868,20 +965,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 +1002,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/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) } 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/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/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) 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/AIModelsView.swift b/speaktype/Views/Screens/Settings/AIModelsView.swift index 5f87e1b..daac23f 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,20 @@ 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(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) + .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 +236,30 @@ 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 { + // 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 { + await MainActor.run { + recommendedLoadError = error.localizedDescription + isLoadingRecommendedModel = false + } + } + } + } + // MARK: - List private var listSection: some View { 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..1966ea9 --- /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 0885567..1b706f4 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 { @@ -86,12 +85,13 @@ 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 = 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 @@ -212,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") @@ -405,6 +419,8 @@ struct GeneralSettingsTab: View { } .buttonStyle(.plain) .disabled(updateService.isCheckingForUpdates) + + updateCheckStatus } } @@ -455,6 +471,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 +645,7 @@ struct PermissionsSettingsTab: View { NSWorkspace.shared.open(url) } } + } // MARK: - Supporting Components diff --git a/speaktype/Views/TranscribeAudioView.swift b/speaktype/Views/TranscribeAudioView.swift index 6fdf033..ce85cd0 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,43 @@ 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 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 { + 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 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/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/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/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..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", @@ -66,4 +91,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/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.. [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) + } } 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" + ) + } +}