diff --git a/.github/conformance.yml b/.github/conformance.yml index 5064cce..a2046eb 100644 --- a/.github/conformance.yml +++ b/.github/conformance.yml @@ -4,7 +4,7 @@ # Criteria: https://github.com/trsdn/.github/blob/v1.16.0/docs/repository-quality-standard.md standard_version: "1.16.0" -assessed_on: "2026-09-21" +assessed_on: "2026-09-22" state: "Healthy" evidence: "docs/self-assessment.md" @@ -39,7 +39,7 @@ criteria: P12: pass P13: pass S01: pass - S02: partial + S02: pass S03: pass S04: pass S05: pass diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fa1d79..6db42f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ All notable changes to OpenWritr will be documented in this file. Each release's notes on GitHub are generated from its section here, and the release workflow fails when the section for a tag is missing or empty. +## [1.6.6] — 2026-09-22 + +### Fixed +- Auto-paste cancellation now shows a recoverable error instead of success when OpenWritr cannot preserve the existing clipboard + ## [1.6.5] — 2026-09-20 ### Added diff --git a/Sources/OpenWritr/AudioEngine.swift b/Sources/OpenWritr/AudioEngine.swift index b30860a..d702c99 100644 --- a/Sources/OpenWritr/AudioEngine.swift +++ b/Sources/OpenWritr/AudioEngine.swift @@ -221,7 +221,26 @@ struct AudioEngineDebugFaults: Sendable { } #endif -final class AudioEngine: @unchecked Sendable { +protocol AudioCapturing: AnyObject, Sendable { + var onDevicesChanged: (@Sendable () -> Void)? { get set } + var onFailure: (@Sendable (AudioEngineError) -> Void)? { get set } + var onAudioLevel: (@Sendable (Float, UInt64) -> Void)? { get set } + + func availableInputDevices() -> [AudioInputDevice] + func setInputDevice(_ deviceID: AudioDeviceID?) -> Result + func prepare() -> Result + func startCapture() async -> Result + func waitForCaptureToSettle( + handle: CaptureHandle, + idleWindow: Duration, + maxWait: Duration, + pollInterval: Duration + ) async + func stopCapture(handle: CaptureHandle) async -> [Float]? + func shutdown() -> Result +} + +final class AudioEngine: AudioCapturing, @unchecked Sendable { private static let maximumRecoveryAttempts = 3 private let targetSampleRate: Double = 16_000 private let captureClock = ContinuousClock() @@ -1035,6 +1054,10 @@ final class AudioEngine: @unchecked Sendable { } } + func availableInputDevices() -> [AudioInputDevice] { + Self.availableInputDevices() + } + func setInputDevice(_ deviceID: AudioDeviceID?) -> Result { syncOnLifecycleQueue { guard !isShuttingDown else { return .failure(.captureCancelled) } diff --git a/Sources/OpenWritr/GrammarEnhancer.swift b/Sources/OpenWritr/GrammarEnhancer.swift index 88c2240..f9a6f57 100644 --- a/Sources/OpenWritr/GrammarEnhancer.swift +++ b/Sources/OpenWritr/GrammarEnhancer.swift @@ -12,6 +12,18 @@ struct EnhancementResult: Sendable { let warning: String? } +protocol TranscriptEnhancing: Sendable { + func enhance( + text: String, + model: EnhancedModel, + provider: EnhancedProvider, + openAIConfiguration: GrammarEnhancer.OpenAIConfiguration, + prompt: String + ) async -> EnhancementResult + + func cancelActiveEnhancement() +} + enum EnhancedProvider: String, CaseIterable, Identifiable { case copilot = "copilot" case openAICompatible = "openai-compatible" @@ -166,7 +178,7 @@ enum GrammarEnhancementError: Error, LocalizedError, Sendable, Equatable { } } -struct GrammarEnhancer: Sendable { +struct GrammarEnhancer: TranscriptEnhancing, Sendable { static let defaultCleanupPrompt = "Clean up this speech transcript: fix grammar, spelling, and punctuation. Remove fillers, hesitations, and stuttering. Every sentence must end with proper punctuation. Preserve meaning, tone, and language. If the input mixes German and English, keep the original language of each word or phrase and do not translate technical terms, product names, commands, or domain-specific wording. If the input contains only filler words or hesitations with no meaningful content, return an empty string. Return only the corrected text." struct OpenAIConfiguration: Sendable { diff --git a/Sources/OpenWritr/MenuBarView.swift b/Sources/OpenWritr/MenuBarView.swift index 6aad8cf..0fc8b41 100644 --- a/Sources/OpenWritr/MenuBarView.swift +++ b/Sources/OpenWritr/MenuBarView.swift @@ -145,7 +145,7 @@ struct MenuBarView: View { } } Button("Dismiss Error") { viewModel.dismissRuntimeError() } - case .transcription: + case .transcription, .paste: Button("Dismiss Error") { viewModel.dismissRuntimeError() } } default: diff --git a/Sources/OpenWritr/OpenWritrApp.swift b/Sources/OpenWritr/OpenWritrApp.swift index 14df523..fea91a6 100644 --- a/Sources/OpenWritr/OpenWritrApp.swift +++ b/Sources/OpenWritr/OpenWritrApp.swift @@ -11,6 +11,7 @@ enum RuntimeErrorKind: Sendable, Equatable { case audio case transcription case enhancement + case paste } struct AppErrorPresentation: Sendable { @@ -50,6 +51,8 @@ final class AppViewModel { private static let enhancementAPIKeyAccount = "enhancedOpenAIAPIKey" private static let customPromptsPreferenceKey = "enhancementCustomPromptsV1" private static let customPromptsStoreVersion = 1 + static let defaultDoneDisplayDuration: Duration = .milliseconds(600) + static let defaultTransientErrorDisplayDuration: Duration = .seconds(2.5) var state: AppState = .idle var lastTranscription: String = "" var lastRawTranscription: String = "" @@ -79,18 +82,21 @@ final class AppViewModel { private(set) var customEnhancementPrompts: [String: String] = [:] var appleIntelligenceAvailability = AppleIntelligenceEnhancer.currentAvailability() - let transcriptionManager = TranscriptionManager() - let grammarEnhancer: GrammarEnhancer = .init() - @ObservationIgnored lazy var audioEngine = AudioEngine() + let transcriptionManager: any Transcribing + let grammarEnhancer: any TranscriptEnhancing + @ObservationIgnored private let injectedAudioEngine: (any AudioCapturing)? + @ObservationIgnored lazy var audioEngine: any AudioCapturing = injectedAudioEngine ?? AudioEngine() let hotkeyManager = HotkeyManager() - let pasteManager = PasteManager() - let overlayPanel = OverlayPanel() + let pasteManager: any TextPasting + let overlayPanel: any OverlayPresenting let soundManager = SoundManager() let permissionsManager = PermissionsManager() let updateManager = UpdateManager() private let captureDrainIdleDuration: Duration = .milliseconds(70) private let captureDrainTimeout: Duration = .milliseconds(350) + private let doneDisplayDuration: Duration + private let transientErrorDisplayDuration: Duration private var didConfigure = false private var didAttemptInitialSetup = false private var isInitializing = false @@ -103,7 +109,6 @@ final class AppViewModel { @ObservationIgnored private var releaseRequested = false @ObservationIgnored private var pendingStartTask: Task? @ObservationIgnored private var transientErrorDismissTask: Task? - private static let transientErrorDisplayDuration: Duration = .seconds(2.5) @ObservationIgnored private var stoppedCaptureGenerations: Set = [] @ObservationIgnored private var activeProcessingTask: Task? @ObservationIgnored private var activeProcessingOperationID: UUID? @@ -114,6 +119,30 @@ final class AppViewModel { @ObservationIgnored private var microphoneRecoveryTask: Task? @ObservationIgnored private var microphoneRecoveryID: UUID? + init( + audioEngine: (any AudioCapturing)? = nil, + transcriptionManager: any Transcribing = TranscriptionManager(), + grammarEnhancer: any TranscriptEnhancing = GrammarEnhancer(), + pasteManager: any TextPasting = PasteManager(), + overlayPanel: any OverlayPresenting = OverlayPanel(), + startsOperational: Bool = false, + doneDisplayDuration: Duration = AppViewModel.defaultDoneDisplayDuration, + transientErrorDisplayDuration: Duration = AppViewModel.defaultTransientErrorDisplayDuration + ) { + injectedAudioEngine = audioEngine + self.transcriptionManager = transcriptionManager + self.grammarEnhancer = grammarEnhancer + self.pasteManager = pasteManager + self.overlayPanel = overlayPanel + self.doneDisplayDuration = doneDisplayDuration + self.transientErrorDisplayDuration = transientErrorDisplayDuration + if startsOperational { + configureAudioCallbacks() + isOperational = true + state = .ready + } + } + var displayedOpenAIModels: [String] { var models = availableOpenAIModels let selected = selectedOpenAIModel.trimmingCharacters(in: .whitespacesAndNewlines) @@ -306,25 +335,8 @@ final class AppViewModel { self?.quiesceForUpdateInstall() } - audioEngine.onDevicesChanged = { [weak self] in - Task { @MainActor [weak self] in - self?.handleInputDevicesChanged() - } - } - audioEngine.onFailure = { [weak self] error in - Task { @MainActor [weak self] in - self?.handleAudioFailure(error) - } - } - audioEngine.onAudioLevel = { [weak self] level, generation in - Task { @MainActor [weak self] in - guard let self, - case .listening = self.state, - self.captureHandle?.generation == generation - else { return } - self.overlayPanel.updateAudioLevel(level) - } - } + configureAudioCallbacks() + hotkeyManager.onRecordingStarted = { [weak self] mode in Task { @MainActor [weak self] in self?.startListening(triggerMode: mode) @@ -352,6 +364,28 @@ final class AppViewModel { } } + private func configureAudioCallbacks() { + audioEngine.onDevicesChanged = { [weak self] in + Task { @MainActor [weak self] in + self?.handleInputDevicesChanged() + } + } + audioEngine.onFailure = { [weak self] error in + Task { @MainActor [weak self] in + self?.handleAudioFailure(error) + } + } + audioEngine.onAudioLevel = { [weak self] level, generation in + Task { @MainActor [weak self] in + guard let self, + case .listening = self.state, + self.captureHandle?.generation == generation + else { return } + self.overlayPanel.updateAudioLevel(level) + } + } + } + private func restorePreferences() { let defaults = UserDefaults.standard if defaults.object(forKey: "soundEnabled") != nil { @@ -512,7 +546,7 @@ final class AppViewModel { self.recoverableRawTranscription = recoverableRawTranscription transitionToErrorState(.runtimeError(error)) overlayPanel.show(state: .error(overlayMessage)) - if error.kind == .transcription { + if error.kind == .transcription || error.kind == .paste { scheduleTransientErrorDismissal() } } @@ -521,9 +555,10 @@ final class AppViewModel { /// flash the overlay, then return to `.ready` without requiring a menu action. private func scheduleTransientErrorDismissal() { transientErrorDismissTask?.cancel() + let displayDuration = transientErrorDisplayDuration transientErrorDismissTask = Task { @MainActor [weak self] in do { - try await Task.sleep(for: Self.transientErrorDisplayDuration) + try await Task.sleep(for: displayDuration) } catch { return } @@ -535,7 +570,8 @@ final class AppViewModel { } private var isTransientErrorState: Bool { - if case .runtimeError(let error) = state, error.kind == .transcription { + if case .runtimeError(let error) = state, + error.kind == .transcription || error.kind == .paste { return true } return false @@ -584,8 +620,9 @@ final class AppViewModel { lastRawTranscription = "" lastWasEnhanced = false recoverableRawTranscription = nil - if autoPasteEnabled { - pasteManager.pasteText(rawText) + if autoPasteEnabled, pasteManager.pasteText(rawText) == .cancelled { + presentPasteCancelledError() + return } overlayPanel.dismiss() state = .ready @@ -656,7 +693,7 @@ final class AppViewModel { } func refreshInputDevices() { - availableInputDevices = AudioEngine.availableInputDevices() + availableInputDevices = audioEngine.availableInputDevices() } func setInputDevice(_ device: AudioInputDevice?) { @@ -1359,7 +1396,8 @@ final class AppViewModel { } do { - let text = try await transcriptionManager.transcribe(samples: samples) + let transcriptionSamples = TranscriptionInput.paddedIfNeeded(samples) + let text = try await transcriptionManager.transcribe(samples: transcriptionSamples) guard captureOperationIsCurrent( operationID: operationID, handle: handle, @@ -1479,13 +1517,17 @@ final class AppViewModel { lastRawTranscription = rawText ?? "" lastWasEnhanced = wasEnhanced - if autoPasteEnabled { - pasteManager.pasteText(text) + if autoPasteEnabled, pasteManager.pasteText(text) == .cancelled { + if let (operationID, _) = captureOperation { + clearCaptureOperation(ifCurrent: operationID) + } + presentPasteCancelledError() + return } overlayPanel.show(state: .done) do { - try await Task.sleep(for: .milliseconds(600)) + try await Task.sleep(for: doneDisplayDuration) } catch is CancellationError { return } catch { @@ -1512,6 +1554,18 @@ final class AppViewModel { state = .ready } + private func presentPasteCancelledError() { + presentRuntimeError( + AppErrorPresentation( + kind: .paste, + title: "Paste Cancelled", + message: "OpenWritr could not preserve the clipboard, so the transcript was not pasted.", + recoverySuggestion: "Replace or clear the clipboard contents, then record again." + ), + overlayMessage: "Clipboard could not be preserved; paste cancelled" + ) + } + private func returnToReady(operationID: UUID? = nil) { guard isOperational else { return } if let operationID { diff --git a/Sources/OpenWritr/OverlayPanel.swift b/Sources/OpenWritr/OverlayPanel.swift index fba9cef..9a4c8fa 100644 --- a/Sources/OpenWritr/OverlayPanel.swift +++ b/Sources/OpenWritr/OverlayPanel.swift @@ -11,7 +11,14 @@ enum OverlayState: Sendable { } @MainActor -final class OverlayPanel { +protocol OverlayPresenting: AnyObject { + func show(state: OverlayState) + func updateAudioLevel(_ level: Float) + func dismiss() +} + +@MainActor +final class OverlayPanel: OverlayPresenting { private static let panelSize = NSSize(width: 248, height: 66) private var panel: NSPanel? diff --git a/Sources/OpenWritr/PasteManager.swift b/Sources/OpenWritr/PasteManager.swift index bc978cf..f5e5aae 100644 --- a/Sources/OpenWritr/PasteManager.swift +++ b/Sources/OpenWritr/PasteManager.swift @@ -3,19 +3,149 @@ import os.log private let pasteLog = Logger(subsystem: "com.openwritr.app", category: "PasteManager") +struct PasteboardItemContent { + struct Representation { + let type: NSPasteboard.PasteboardType + let data: Data + } + + let representations: [Representation] +} + @MainActor -final class PasteManager { - private struct PasteboardSnapshot { - struct Item { - struct Representation { - let type: NSPasteboard.PasteboardType - let data: Data +protocol PasteboardItemReading { + var pasteboardTypes: [NSPasteboard.PasteboardType] { get } + + func pasteboardData(forType type: NSPasteboard.PasteboardType) -> Data? +} + +@MainActor +protocol PasteboardManaging: AnyObject { + var changeCount: Int { get } + var pasteboardItems: [any PasteboardItemReading]? { get } + + func prepareWrite(_ items: [PasteboardItemContent]) -> (any PreparedPasteboardWrite)? + + @discardableResult + func clearContents() -> Int +} + +@MainActor +protocol PreparedPasteboardWrite { + func write() -> Bool +} + +@MainActor +protocol PasteRestoreScheduling { + func scheduleRestore(_ action: @escaping @MainActor () -> Void) +} + +@MainActor +protocol PasteCommandPosting { + func postPasteCommand() +} + +@MainActor +protocol TextPasting { + @discardableResult + func pasteText(_ text: String) -> PasteOutcome + func flushPendingRestore() +} + +enum PasteOutcome: Sendable, Equatable { + case pasted + case cancelled +} + +extension NSPasteboardItem: PasteboardItemReading { + var pasteboardTypes: [NSPasteboard.PasteboardType] { + types + } + + func pasteboardData(forType type: NSPasteboard.PasteboardType) -> Data? { + data(forType: type) + } +} + +@MainActor +final class SystemPasteboard: PasteboardManaging { + private final class PreparedWrite: PreparedPasteboardWrite { + private let pasteboard: NSPasteboard + private let items: [NSPasteboardItem] + + init(pasteboard: NSPasteboard, items: [NSPasteboardItem]) { + self.pasteboard = pasteboard + self.items = items + } + + func write() -> Bool { + items.isEmpty || pasteboard.writeObjects(items) + } + } + + private let pasteboard: NSPasteboard + + init(_ pasteboard: NSPasteboard = .general) { + self.pasteboard = pasteboard + } + + var changeCount: Int { + pasteboard.changeCount + } + + var pasteboardItems: [any PasteboardItemReading]? { + pasteboard.pasteboardItems + } + + func prepareWrite(_ items: [PasteboardItemContent]) -> (any PreparedPasteboardWrite)? { + let pasteboardItems = items.compactMap { item -> NSPasteboardItem? in + let pasteboardItem = NSPasteboardItem() + for representation in item.representations { + guard pasteboardItem.setData(representation.data, forType: representation.type) else { + return nil + } } + return pasteboardItem + } + guard pasteboardItems.count == items.count else { + return nil + } + return PreparedWrite(pasteboard: pasteboard, items: pasteboardItems) + } - let representations: [Representation] + @discardableResult + func clearContents() -> Int { + pasteboard.clearContents() + } +} + +struct SystemPasteCommandPoster: PasteCommandPosting { + func postPasteCommand() { + let source = CGEventSource(stateID: .hidSystemState) + + let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true) + keyDown?.flags = .maskCommand + + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false) + keyUp?.flags = .maskCommand + + keyDown?.post(tap: .cgAnnotatedSessionEventTap) + keyUp?.post(tap: .cgAnnotatedSessionEventTap) + } +} + +struct SystemPasteRestoreScheduler: PasteRestoreScheduling { + func scheduleRestore(_ action: @escaping @MainActor () -> Void) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + action() } + } +} - let items: [Item] +@MainActor +final class PasteManager: TextPasting { + private struct PasteboardSnapshot { + let items: [PasteboardItemContent] } private struct PendingRestore { @@ -25,35 +155,64 @@ final class PasteManager { } private var pendingRestore: PendingRestore? + private var hasUnreportedRestoreFailure = false + private let pasteboard: any PasteboardManaging + private let commandPoster: any PasteCommandPosting + private let restoreScheduler: any PasteRestoreScheduling + + init( + pasteboard: any PasteboardManaging = SystemPasteboard(), + commandPoster: any PasteCommandPosting = SystemPasteCommandPoster(), + restoreScheduler: any PasteRestoreScheduling = SystemPasteRestoreScheduler() + ) { + self.pasteboard = pasteboard + self.commandPoster = commandPoster + self.restoreScheduler = restoreScheduler + } + + @discardableResult + func pasteText(_ text: String) -> PasteOutcome { + if hasUnreportedRestoreFailure { + hasUnreportedRestoreFailure = false + return .cancelled + } - func pasteText(_ text: String) { guard flushPendingRestore(matching: nil) else { - return + return .cancelled } - let pasteboard = NSPasteboard.general let originalChangeCount = pasteboard.changeCount guard let snapshot = snapshot(of: pasteboard) else { - return + return .cancelled } - let transcriptItem = NSPasteboardItem() - guard transcriptItem.setString(text, forType: .string) else { + let transcriptItem = PasteboardItemContent( + representations: [ + .init(type: .string, data: Data(text.utf8)) + ] + ) + + guard pasteboard.changeCount == originalChangeCount else { + pasteLog.notice("Clipboard changed while it was being saved; cancelling paste") + return .cancelled + } + + guard let preparedTranscript = pasteboard.prepareWrite([transcriptItem]) else { pasteLog.error("Failed to prepare transcript for the pasteboard") - return + return .cancelled } guard pasteboard.changeCount == originalChangeCount else { - pasteLog.notice("Clipboard changed while it was being saved; cancelling paste") - return + pasteLog.notice("Clipboard changed while the transcript was being prepared; cancelling paste") + return .cancelled } let transcriptOwnershipChangeCount = pasteboard.clearContents() - guard pasteboard.writeObjects([transcriptItem]) else { + guard preparedTranscript.write() else { pasteLog.error("Failed to write transcript to the pasteboard") _ = restore(snapshot, to: pasteboard, ifUnchangedSince: transcriptOwnershipChangeCount) - return + return .cancelled } let transcriptChangeCount = pasteboard.changeCount @@ -63,15 +222,20 @@ final class PasteManager { snapshot: snapshot, expectedChangeCount: transcriptChangeCount ) - simulatePaste() + commandPoster.postPasteCommand() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - _ = self.flushPendingRestore(matching: transactionID) + restoreScheduler.scheduleRestore { + if !self.flushPendingRestore(matching: transactionID) { + self.hasUnreportedRestoreFailure = true + } } + return .pasted } func flushPendingRestore() { - _ = flushPendingRestore(matching: nil) + if !flushPendingRestore(matching: nil) { + hasUnreportedRestoreFailure = true + } } private func flushPendingRestore(matching transactionID: UUID?) -> Bool { @@ -85,32 +249,38 @@ final class PasteManager { self.pendingRestore = nil return restore( pendingRestore.snapshot, - to: NSPasteboard.general, + to: pasteboard, ifUnchangedSince: pendingRestore.expectedChangeCount ) } - private func snapshot(of pasteboard: NSPasteboard) -> PasteboardSnapshot? { - var snapshotItems: [PasteboardSnapshot.Item] = [] + private func snapshot(of pasteboard: any PasteboardManaging) -> PasteboardSnapshot? { + let pasteboardItems = pasteboard.pasteboardItems ?? [] + var snapshotItems: [PasteboardItemContent] = [] - for item in pasteboard.pasteboardItems ?? [] { - var representations: [PasteboardSnapshot.Item.Representation] = [] + for item in pasteboardItems { + var representations: [PasteboardItemContent.Representation] = [] - for type in item.types { + for type in item.pasteboardTypes { // Some representations cannot be read: protected content (e.g. from - // managed apps), promised data that never materialises, or an empty - // clipboard. Skip them rather than dropping the paste altogether. - guard let data = item.data(forType: type) else { - pasteLog.notice("Skipping unreadable clipboard representation \(type.rawValue, privacy: .public)") - continue + // managed apps) or promised data that never materialises. Cancel the + // paste rather than restore an incomplete version of the clipboard. + guard let data = item.pasteboardData(forType: type) else { + pasteLog.notice( + "Clipboard representation \(type.rawValue, privacy: .public) could not be preserved; cancelling paste" + ) + return nil } representations.append(.init(type: type, data: data)) } - if !representations.isEmpty { - snapshotItems.append(.init(representations: representations)) + guard !representations.isEmpty else { + pasteLog.notice("Clipboard item has no restorable representations; cancelling paste") + return nil } + + snapshotItems.append(PasteboardItemContent(representations: representations)) } return PasteboardSnapshot(items: snapshotItems) @@ -118,24 +288,12 @@ final class PasteManager { private func restore( _ snapshot: PasteboardSnapshot, - to pasteboard: NSPasteboard, + to pasteboard: any PasteboardManaging, ifUnchangedSince expectedChangeCount: Int ) -> Bool { - var restoredItems: [NSPasteboardItem] = [] - - for snapshotItem in snapshot.items { - let restoredItem = NSPasteboardItem() - - for representation in snapshotItem.representations { - guard restoredItem.setData(representation.data, forType: representation.type) else { - pasteLog.error( - "Failed to prepare clipboard representation \(representation.type.rawValue, privacy: .public)" - ) - return false - } - } - - restoredItems.append(restoredItem) + guard let preparedRestore = pasteboard.prepareWrite(snapshot.items) else { + pasteLog.error("Failed to prepare clipboard contents for restoration") + return false } guard pasteboard.changeCount == expectedChangeCount else { @@ -144,24 +302,11 @@ final class PasteManager { pasteboard.clearContents() - guard restoredItems.isEmpty || pasteboard.writeObjects(restoredItems) else { + guard preparedRestore.write() else { pasteLog.error("Failed to restore clipboard contents") return false } return true } - - private func simulatePaste() { - let source = CGEventSource(stateID: .hidSystemState) - - let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true) // 0x09 = 'v' - keyDown?.flags = .maskCommand - - let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false) - keyUp?.flags = .maskCommand - - keyDown?.post(tap: .cgAnnotatedSessionEventTap) - keyUp?.post(tap: .cgAnnotatedSessionEventTap) - } } diff --git a/Sources/OpenWritr/TranscriptionManager.swift b/Sources/OpenWritr/TranscriptionManager.swift index b4eacd2..996db75 100644 --- a/Sources/OpenWritr/TranscriptionManager.swift +++ b/Sources/OpenWritr/TranscriptionManager.swift @@ -1,7 +1,22 @@ import Foundation import FluidAudio -final class TranscriptionManager: @unchecked Sendable { +protocol Transcribing: Sendable { + func loadModels(progressHandler: @escaping @Sendable (Double) -> Void) async throws + func transcribe(samples: [Float]) async throws -> String +} + +enum TranscriptionInput { + /// The ASR model rejects input shorter than one second of 16 kHz audio. + static let minimumSampleCount = 16_000 + + static func paddedIfNeeded(_ samples: [Float]) -> [Float] { + guard samples.count < minimumSampleCount else { return samples } + return samples + repeatElement(0, count: minimumSampleCount - samples.count) + } +} + +final class TranscriptionManager: Transcribing, @unchecked Sendable { private var asrManager: AsrManager? func loadModels(progressHandler: @escaping @Sendable (Double) -> Void) async throws { @@ -13,17 +28,11 @@ final class TranscriptionManager: @unchecked Sendable { progressHandler(1.0) } - /// The ASR model rejects input shorter than one second of 16 kHz audio. - private static let minimumSampleCount = 16_000 - func transcribe(samples: [Float]) async throws -> String { guard let manager = asrManager else { throw TranscriptionError.notReady } - var input = samples - if input.count < Self.minimumSampleCount { - input.append(contentsOf: repeatElement(0, count: Self.minimumSampleCount - input.count)) - } + let input = TranscriptionInput.paddedIfNeeded(samples) // Every recording is an independent utterance, so decode from a fresh state. var decoderState = TdtDecoderState.make(decoderLayers: 2) let result = try await manager.transcribe(input, decoderState: &decoderState) diff --git a/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift b/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift new file mode 100644 index 0000000..d2ffb83 --- /dev/null +++ b/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift @@ -0,0 +1,527 @@ +import CoreAudio +import Foundation +import Testing +@testable import OpenWritr + +@MainActor +@Suite("AppViewModel dictation flow") +struct AppViewModelDictationFlowTests { + @Test func normalTranscriptionPastesAndShowsDone() async { + let dependencies = makeDependencies( + samples: [audibleSamples(count: 16_000)], + transcriptions: [.success(" Synthetic transcript. ")] + ) + let viewModel = dependencies.makeViewModel() + defer { viewModel.shutdown() } + + await recordAndStop(viewModel) + + #expect(dependencies.paster.pastedTexts == ["Synthetic transcript."]) + #expect(dependencies.overlay.didShowDone) + #expect(viewModel.lastTranscription == "Synthetic transcript.") + #expect(viewModel.state.isReady) + } + + @Test func enhancedFlowAndFallbacksPreserveRawTranscript() async { + let success = makeDependencies( + samples: [audibleSamples(count: 16_000)], + transcriptions: [.success("Synthetic raw transcript.")], + enhancements: [ + enhancement(text: "Synthetic cleaned transcript.", didSucceed: true) + ] + ) + let successfulViewModel = success.makeViewModel() + defer { successfulViewModel.shutdown() } + + await recordAndStop(successfulViewModel, mode: .enhanced) + + #expect(success.paster.pastedTexts == ["Synthetic cleaned transcript."]) + #expect(successfulViewModel.lastRawTranscription == "Synthetic raw transcript.") + #expect(successfulViewModel.lastWasEnhanced) + + let failure = makeDependencies( + samples: [audibleSamples(count: 16_000)], + transcriptions: [.success("Synthetic fallback transcript.")], + enhancements: [ + enhancement(text: "Synthetic fallback transcript.", didSucceed: false) + ] + ) + let failedViewModel = failure.makeViewModel() + defer { failedViewModel.shutdown() } + + await recordAndStop(failedViewModel, mode: .enhanced) + + #expect(failure.paster.pastedTexts.isEmpty) + #expect(isRuntimeError(failedViewModel.state, kind: .enhancement)) + #expect(failedViewModel.recoverableRawTranscription == "Synthetic fallback transcript.") + + failedViewModel.useRawTranscription() + + #expect(failure.paster.pastedTexts == ["Synthetic fallback transcript."]) + #expect(failedViewModel.state.isReady) + + let empty = makeDependencies( + samples: [audibleSamples(count: 16_000)], + transcriptions: [.success("Synthetic filler transcript.")], + enhancements: [enhancement(text: " ", didSucceed: true)] + ) + let emptyViewModel = empty.makeViewModel() + defer { emptyViewModel.shutdown() } + + await recordAndStop(emptyViewModel, mode: .enhanced) + + #expect(empty.paster.pastedTexts.isEmpty) + #expect(emptyViewModel.state.isReady) + #expect(emptyViewModel.lastTranscription.isEmpty) + } + + @Test func shortRecordingIsPaddedBeforeTranscription() async { + let shortSamples = audibleSamples(count: 6_000) + let dependencies = makeDependencies( + samples: [shortSamples], + transcriptions: [.success("Synthetic short recording.")] + ) + let viewModel = dependencies.makeViewModel() + defer { viewModel.shutdown() } + + await recordAndStop(viewModel) + + let received = dependencies.transcriber.receivedSamples.first + #expect(received?.count == 16_000) + #expect(Array(received?.prefix(shortSamples.count) ?? []) == shortSamples) + #expect(received?.suffix(10).allSatisfy { $0 == 0 } == true) + #expect(dependencies.paster.pastedTexts == ["Synthetic short recording."]) + } + + @Test func transcriptionErrorDismissesAndAllowsAnotherRecording() async { + #expect(AppViewModel.defaultTransientErrorDisplayDuration == .seconds(2.5)) + let dependencies = makeDependencies( + samples: [ + audibleSamples(count: 16_000), + audibleSamples(count: 16_000) + ], + transcriptions: [ + .failure(TestFailure.expected), + .success("Synthetic recovered transcript.") + ] + ) + let viewModel = dependencies.makeViewModel(transientErrorDisplayDuration: .milliseconds(10)) + defer { viewModel.shutdown() } + + await recordAndStop(viewModel) + + #expect(isRuntimeError(viewModel.state, kind: .transcription)) + #expect(dependencies.overlay.didShowError("Transcription failed")) + + await waitUntil { viewModel.state.isReady } + + await recordAndStop(viewModel) + + #expect(dependencies.paster.pastedTexts == ["Synthetic recovered transcript."]) + #expect(viewModel.state.isReady) + } + + @Test func staleCaptureGenerationCannotOverwriteNewerOutput() async { + let dependencies = makeDependencies( + samples: [ + audibleSamples(count: 16_000), + audibleSamples(count: 16_000) + ], + transcriptions: [ + .suspended, + .success("Synthetic current transcript.") + ] + ) + dependencies.audio.devices = [ + AudioInputDevice(id: 42, name: "Synthetic microphone", uid: "synthetic-microphone") + ] + let viewModel = dependencies.makeViewModel() + defer { viewModel.shutdown() } + viewModel.selectedInputDeviceID = 42 + + viewModel.startListening() + await waitUntil { isListening(viewModel.state) } + let staleTask = Task { @MainActor in + await viewModel.stopListeningAndTranscribe() + } + await waitUntil { dependencies.transcriber.callCount == 1 } + + dependencies.audio.onFailure?(.inputDeviceUnavailable(42)) + await waitUntil { isRuntimeError(viewModel.state, kind: .audio) } + viewModel.retryMicrophone() + #expect(viewModel.state.isReady) + + await recordAndStop(viewModel) + dependencies.transcriber.resumeSuspended(with: .success("Synthetic stale transcript.")) + await staleTask.value + + #expect(dependencies.paster.pastedTexts == ["Synthetic current transcript."]) + #expect(viewModel.lastTranscription == "Synthetic current transcript.") + #expect(viewModel.state.isReady) + } + + @Test func autoPasteDisabledLeavesPastingUntouched() async { + let dependencies = makeDependencies( + samples: [audibleSamples(count: 16_000)], + transcriptions: [.success("Synthetic transcript without paste.")] + ) + let viewModel = dependencies.makeViewModel() + defer { viewModel.shutdown() } + viewModel.autoPasteEnabled = false + + await recordAndStop(viewModel) + + #expect(dependencies.paster.pastedTexts.isEmpty) + #expect(viewModel.lastTranscription == "Synthetic transcript without paste.") + #expect(dependencies.overlay.didShowDone) + } + + @Test func unreadableClipboardCancellationSurfacesTransientError() async { + let dependencies = makeDependencies( + samples: [ + audibleSamples(count: 16_000), + audibleSamples(count: 16_000) + ], + transcriptions: [ + .success("Synthetic preserved transcript."), + .success("Synthetic recovered transcript.") + ], + pasteOutcomes: [.cancelled, .pasted] + ) + let viewModel = dependencies.makeViewModel() + defer { viewModel.shutdown() } + + await recordAndStop(viewModel) + + #expect(dependencies.paster.pastedTexts.isEmpty) + #expect(!dependencies.overlay.didShowDone) + #expect(isRuntimeError(viewModel.state, kind: .paste)) + #expect(dependencies.overlay.didShowError("Clipboard could not be preserved; paste cancelled")) + #expect(viewModel.lastTranscription == "Synthetic preserved transcript.") + #expect(pasteErrorRecoverySuggestion(viewModel.state) == "Replace or clear the clipboard contents, then record again.") + + await waitUntil { viewModel.state.isReady } + await recordAndStop(viewModel) + + #expect(dependencies.paster.pastedTexts == ["Synthetic recovered transcript."]) + #expect(viewModel.state.isReady) + } + + @Test func priorRestoreFailureDoesNotSilentlyShowDone() async { + let dependencies = makeDependencies( + samples: [ + audibleSamples(count: 16_000), + audibleSamples(count: 16_000) + ], + transcriptions: [ + .success("First synthetic transcript."), + .success("Second synthetic transcript.") + ], + pasteOutcomes: [.pasted, .cancelled] + ) + let viewModel = dependencies.makeViewModel() + defer { viewModel.shutdown() } + + await recordAndStop(viewModel) + let doneCountAfterFirstPaste = dependencies.overlay.doneCount + + await recordAndStop(viewModel) + + #expect(doneCountAfterFirstPaste == 1) + #expect(dependencies.overlay.doneCount == doneCountAfterFirstPaste) + #expect(isRuntimeError(viewModel.state, kind: .paste)) + #expect(dependencies.paster.pastedTexts == ["First synthetic transcript."]) + + await waitUntil { viewModel.state.isReady } + } + + private func recordAndStop( + _ viewModel: AppViewModel, + mode: RecordingShortcutMode = .normal + ) async { + viewModel.soundEnabled = false + if mode == .enhanced { + viewModel.enhancedModeEnabled = true + } + viewModel.startListening(triggerMode: mode) + await waitUntil { isListening(viewModel.state) } + await viewModel.stopListeningAndTranscribe(triggerMode: mode) + } + + private func makeDependencies( + samples: [[Float]], + transcriptions: [FakeTranscriber.Behavior], + enhancements: [EnhancementResult] = [], + pasteOutcomes: [PasteOutcome] = [] + ) -> DictationDependencies { + DictationDependencies( + audio: FakeAudioCapture(samples: samples), + transcriber: FakeTranscriber(behaviors: transcriptions), + enhancer: FakeEnhancer(results: enhancements), + paster: FakeTextPaster(outcomes: pasteOutcomes), + overlay: FakeOverlayPresenter() + ) + } + + private func enhancement(text: String, didSucceed: Bool) -> EnhancementResult { + EnhancementResult( + text: text, + effectiveModel: "synthetic-model", + providerDisplayName: "Synthetic Provider", + didSucceed: didSucceed, + warning: didSucceed ? nil : "Synthetic enhancement failure." + ) + } + + private func audibleSamples(count: Int) -> [Float] { + [Float](repeating: 0.1, count: count) + } + + private func waitUntil( + _ condition: @escaping @MainActor () -> Bool + ) async { + for _ in 0..<1_000 { + if condition() { + return + } + try? await Task.sleep(for: .milliseconds(1)) + } + Issue.record("Timed out waiting for the expected dictation state.") + } + + private func isListening(_ state: AppState) -> Bool { + if case .listening = state { + return true + } + return false + } + + private func isRuntimeError(_ state: AppState, kind: RuntimeErrorKind) -> Bool { + if case .runtimeError(let presentation) = state { + return presentation.kind == kind + } + return false + } + + private func pasteErrorRecoverySuggestion(_ state: AppState) -> String? { + guard case .runtimeError(let presentation) = state, + presentation.kind == .paste + else { return nil } + return presentation.recoverySuggestion + } +} + +@MainActor +private struct DictationDependencies { + let audio: FakeAudioCapture + let transcriber: FakeTranscriber + let enhancer: FakeEnhancer + let paster: FakeTextPaster + let overlay: FakeOverlayPresenter + + func makeViewModel( + transientErrorDisplayDuration: Duration = .milliseconds(10) + ) -> AppViewModel { + AppViewModel( + audioEngine: audio, + transcriptionManager: transcriber, + grammarEnhancer: enhancer, + pasteManager: paster, + overlayPanel: overlay, + startsOperational: true, + doneDisplayDuration: .milliseconds(1), + transientErrorDisplayDuration: transientErrorDisplayDuration + ) + } +} + +private final class FakeAudioCapture: AudioCapturing, @unchecked Sendable { + var onDevicesChanged: (@Sendable () -> Void)? + var onFailure: (@Sendable (AudioEngineError) -> Void)? + var onAudioLevel: (@Sendable (Float, UInt64) -> Void)? + var devices: [AudioInputDevice] = [] + + private var sampleQueue: [[Float]] + private var nextGeneration: UInt64 = 0 + + init(samples: [[Float]]) { + sampleQueue = samples + } + + func availableInputDevices() -> [AudioInputDevice] { + devices + } + + func setInputDevice(_ deviceID: AudioDeviceID?) -> Result { + .success(()) + } + + func prepare() -> Result { + .success(()) + } + + func startCapture() async -> Result { + nextGeneration += 1 + return .success(CaptureHandle(generation: nextGeneration)) + } + + func waitForCaptureToSettle( + handle: CaptureHandle, + idleWindow: Duration, + maxWait: Duration, + pollInterval: Duration + ) async {} + + func stopCapture(handle: CaptureHandle) async -> [Float]? { + guard !sampleQueue.isEmpty else { return nil } + return sampleQueue.removeFirst() + } + + func shutdown() -> Result { + .success(()) + } +} + +private final class FakeTranscriber: Transcribing, @unchecked Sendable { + enum Behavior { + case success(String) + case failure(any Error) + case suspended + } + + private var behaviors: [Behavior] + private var suspendedContinuation: CheckedContinuation? + private(set) var receivedSamples: [[Float]] = [] + + init(behaviors: [Behavior]) { + self.behaviors = behaviors + } + + var callCount: Int { + receivedSamples.count + } + + func loadModels(progressHandler: @escaping @Sendable (Double) -> Void) async throws { + progressHandler(1) + } + + func transcribe(samples: [Float]) async throws -> String { + receivedSamples.append(samples) + guard !behaviors.isEmpty else { + throw TestFailure.missingBehavior + } + + switch behaviors.removeFirst() { + case .success(let text): + return text + case .failure(let error): + throw error + case .suspended: + return try await withCheckedThrowingContinuation { continuation in + suspendedContinuation = continuation + } + } + } + + func resumeSuspended(with result: Result) { + let continuation = suspendedContinuation + suspendedContinuation = nil + continuation?.resume(with: result) + } +} + +private final class FakeEnhancer: TranscriptEnhancing, @unchecked Sendable { + private var results: [EnhancementResult] + private(set) var receivedTexts: [String] = [] + + init(results: [EnhancementResult]) { + self.results = results + } + + func enhance( + text: String, + model: EnhancedModel, + provider: EnhancedProvider, + openAIConfiguration: GrammarEnhancer.OpenAIConfiguration, + prompt: String + ) async -> EnhancementResult { + receivedTexts.append(text) + guard !results.isEmpty else { + return EnhancementResult( + text: text, + effectiveModel: model.rawValue, + providerDisplayName: provider.displayName, + didSucceed: false, + warning: "Missing synthetic enhancement result." + ) + } + return results.removeFirst() + } + + func cancelActiveEnhancement() {} +} + +@MainActor +private final class FakeTextPaster: TextPasting { + private(set) var pastedTexts: [String] = [] + private var outcomes: [PasteOutcome] + + init(outcomes: [PasteOutcome]) { + self.outcomes = outcomes + } + + func pasteText(_ text: String) -> PasteOutcome { + let outcome = outcomes.isEmpty ? .pasted : outcomes.removeFirst() + if outcome == .pasted { + pastedTexts.append(text) + } + return outcome + } + + func flushPendingRestore() {} +} + +@MainActor +private final class FakeOverlayPresenter: OverlayPresenting { + private(set) var shownStates: [OverlayState] = [] + + var didShowDone: Bool { + shownStates.contains { + if case .done = $0 { + return true + } + return false + } + } + + var doneCount: Int { + shownStates.count { + if case .done = $0 { + return true + } + return false + } + } + + func didShowError(_ message: String) -> Bool { + shownStates.contains { + if case .error(let actualMessage) = $0 { + return actualMessage == message + } + return false + } + } + + func show(state: OverlayState) { + shownStates.append(state) + } + + func updateAudioLevel(_ level: Float) {} + + func dismiss() {} +} + +private enum TestFailure: Error { + case expected + case missingBehavior +} diff --git a/Tests/OpenWritrTests/PasteManagerTests.swift b/Tests/OpenWritrTests/PasteManagerTests.swift new file mode 100644 index 0000000..19dc90d --- /dev/null +++ b/Tests/OpenWritrTests/PasteManagerTests.swift @@ -0,0 +1,337 @@ +import AppKit +import Testing +@testable import OpenWritr + +@MainActor +@Suite("PasteManager") +struct PasteManagerTests { + @Test func emptyClipboardPastesAndRestoresEmpty() { + let pasteboard = FakePasteboard(items: [], returnsNilItemsWhenEmpty: true) + let poster = FakePasteCommandPoster() + let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + + manager.pasteText("Synthetic transcript") + + #expect(pasteboard.text == "Synthetic transcript") + #expect(poster.postCount == 1) + + manager.flushPendingRestore() + + #expect(pasteboard.items.isEmpty) + #expect(pasteboard.clearCount == 2) + } + + @Test func savesReplacesPastesAndRestoresClipboard() { + let pasteboard = FakePasteboard(items: [.text("Original clipboard")]) + let poster = FakePasteCommandPoster() + let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + + manager.pasteText("Synthetic transcript") + + #expect(pasteboard.text == "Synthetic transcript") + #expect(poster.postCount == 1) + + manager.flushPendingRestore() + + #expect(pasteboard.text == "Original clipboard") + } + + @Test func clipboardMutationDuringSaveCancelsPaste() { + let pasteboard = FakePasteboard(items: [.text("Original clipboard")]) + pasteboard.mutateWhenReading = [.text("External clipboard")] + let poster = FakePasteCommandPoster() + let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + + manager.pasteText("Synthetic transcript") + + #expect(pasteboard.text == "External clipboard") + #expect(poster.postCount == 0) + } + + @Test func unreadableRepresentationCancelsPasteToPreserveWholeItem() { + let pasteboard = FakePasteboard( + items: [ + .mixed( + readableText: "Restorable clipboard", + unreadableType: .fileURL + ) + ] + ) + let poster = FakePasteCommandPoster() + let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + + let outcome = manager.pasteText("Synthetic transcript") + + #expect(outcome == .cancelled) + #expect(pasteboard.text == "Restorable clipboard") + #expect(pasteboard.items.first?.pasteboardTypes == [.string, .fileURL]) + #expect(pasteboard.clearCount == 0) + #expect(poster.postCount == 0) + } + + @Test func unreadableOnlyClipboardCancelsPasteWithoutClearing() { + let pasteboard = FakePasteboard(items: [.unreadable(type: .fileURL)]) + let poster = FakePasteCommandPoster() + let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + + manager.pasteText("Synthetic transcript") + + #expect(pasteboard.items.first?.pasteboardTypes == [.fileURL]) + #expect(pasteboard.clearCount == 0) + #expect(poster.postCount == 0) + } + + @Test func unreadableItemAmongReadableItemsCancelsPasteWithoutClearing() { + let pasteboard = FakePasteboard( + items: [ + .text("Restorable clipboard"), + .unreadable(type: .fileURL) + ] + ) + let poster = FakePasteCommandPoster() + let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + + manager.pasteText("Synthetic transcript") + + #expect(pasteboard.items.map(\.pasteboardTypes) == [[.string], [.fileURL]]) + #expect(pasteboard.clearCount == 0) + #expect(poster.postCount == 0) + } + + @Test func secondPasteRestoresOriginalClipboardAfterPendingRestore() { + let pasteboard = FakePasteboard(items: [.text("Original clipboard")]) + let poster = FakePasteCommandPoster() + let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + + manager.pasteText("First synthetic transcript") + manager.pasteText("Second synthetic transcript") + + #expect(pasteboard.text == "Second synthetic transcript") + #expect(poster.postCount == 2) + + manager.flushPendingRestore() + + #expect(pasteboard.text == "Original clipboard") + } + + @Test func externalClipboardChangePreventsRestoreOverwrite() { + let pasteboard = FakePasteboard(items: [.text("Original clipboard")]) + let poster = FakePasteCommandPoster() + let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + + manager.pasteText("Synthetic transcript") + pasteboard.replaceExternally(with: [.text("External clipboard")]) + manager.flushPendingRestore() + + #expect(pasteboard.text == "External clipboard") + #expect(poster.postCount == 1) + } + + @Test func restorePreparationFailureLeavesCurrentClipboardUntouched() { + let pasteboard = FakePasteboard(items: [.text("Original clipboard")]) + let poster = FakePasteCommandPoster() + let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + + manager.pasteText("Synthetic transcript") + pasteboard.failNextPreparation = true + manager.flushPendingRestore() + + #expect(pasteboard.text == "Synthetic transcript") + #expect(pasteboard.clearCount == 1) + #expect(poster.postCount == 1) + } + + @Test func priorRestoreFailureCancelsNextPasteWithoutClearing() { + let pasteboard = FakePasteboard(items: [.text("Original clipboard")]) + let poster = FakePasteCommandPoster() + let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) + + #expect(manager.pasteText("First synthetic transcript") == .pasted) + pasteboard.failNextPreparation = true + + let outcome = manager.pasteText("Second synthetic transcript") + + #expect(outcome == .cancelled) + #expect(pasteboard.text == "First synthetic transcript") + #expect(pasteboard.clearCount == 1) + #expect(poster.postCount == 1) + } + + @Test func delayedRestoreFailureCancelsNextPasteThenAllowsRecovery() { + let pasteboard = FakePasteboard(items: [.text("Original clipboard")]) + let poster = FakePasteCommandPoster() + let scheduler = FakePasteRestoreScheduler() + let manager = PasteManager( + pasteboard: pasteboard, + commandPoster: poster, + restoreScheduler: scheduler + ) + + #expect(manager.pasteText("First synthetic transcript") == .pasted) + pasteboard.failNextPreparation = true + scheduler.runScheduledRestore() + + #expect(manager.pasteText("Second synthetic transcript") == .cancelled) + #expect(pasteboard.text == "First synthetic transcript") + #expect(poster.postCount == 1) + + pasteboard.replaceExternally(with: [.text("Replacement clipboard")]) + + #expect(manager.pasteText("Third synthetic transcript") == .pasted) + manager.flushPendingRestore() + + #expect(pasteboard.text == "Replacement clipboard") + #expect(poster.postCount == 2) + } +} + +@MainActor +private final class FakePasteCommandPoster: PasteCommandPosting { + private(set) var postCount = 0 + + func postPasteCommand() { + postCount += 1 + } +} + +@MainActor +private final class FakePasteRestoreScheduler: PasteRestoreScheduling { + private var scheduledAction: (@MainActor () -> Void)? + + func scheduleRestore(_ action: @escaping @MainActor () -> Void) { + scheduledAction = action + } + + func runScheduledRestore() { + let action = scheduledAction + scheduledAction = nil + action?() + } +} + +@MainActor +private final class FakePasteboard: PasteboardManaging { + private(set) var changeCount = 0 + private(set) var clearCount = 0 + private(set) var items: [FakePasteboardItem] + var mutateWhenReading: [FakePasteboardItem]? + var failNextPreparation = false + private let returnsNilItemsWhenEmpty: Bool + + init( + items: [FakePasteboardItem], + returnsNilItemsWhenEmpty: Bool = false + ) { + self.items = items + self.returnsNilItemsWhenEmpty = returnsNilItemsWhenEmpty + } + + var pasteboardItems: [any PasteboardItemReading]? { + if returnsNilItemsWhenEmpty, items.isEmpty { + return nil + } + let currentItems = items + if let mutation = mutateWhenReading { + mutateWhenReading = nil + replaceExternally(with: mutation) + } + return currentItems + } + + var text: String? { + guard let data = items.first?.data[.string] else { return nil } + return String(data: data, encoding: .utf8) + } + + func prepareWrite(_ items: [PasteboardItemContent]) -> (any PreparedPasteboardWrite)? { + if failNextPreparation { + failNextPreparation = false + return nil + } + return FakePreparedPasteboardWrite(pasteboard: self, items: items) + } + + func replaceExternally(with items: [FakePasteboardItem]) { + self.items = items + changeCount += 1 + } + + @discardableResult + func clearContents() -> Int { + items = [] + clearCount += 1 + changeCount += 1 + return changeCount + } + + fileprivate func writeItems(_ items: [PasteboardItemContent]) -> Bool { + self.items = items.map(FakePasteboardItem.init) + changeCount += 1 + return true + } +} + +@MainActor +private final class FakePreparedPasteboardWrite: PreparedPasteboardWrite { + private unowned let pasteboard: FakePasteboard + private let items: [PasteboardItemContent] + + init(pasteboard: FakePasteboard, items: [PasteboardItemContent]) { + self.pasteboard = pasteboard + self.items = items + } + + func write() -> Bool { + pasteboard.writeItems(items) + } +} + +@MainActor +private struct FakePasteboardItem: PasteboardItemReading { + let declaredTypes: [NSPasteboard.PasteboardType] + let data: [NSPasteboard.PasteboardType: Data] + + var pasteboardTypes: [NSPasteboard.PasteboardType] { + declaredTypes + } + + init(_ content: PasteboardItemContent) { + declaredTypes = content.representations.map(\.type) + data = Dictionary( + uniqueKeysWithValues: content.representations.map { ($0.type, $0.data) } + ) + } + + private init( + declaredTypes: [NSPasteboard.PasteboardType], + data: [NSPasteboard.PasteboardType: Data] + ) { + self.declaredTypes = declaredTypes + self.data = data + } + + static func text(_ value: String) -> FakePasteboardItem { + FakePasteboardItem( + declaredTypes: [.string], + data: [.string: Data(value.utf8)] + ) + } + + static func unreadable(type: NSPasteboard.PasteboardType) -> FakePasteboardItem { + FakePasteboardItem(declaredTypes: [type], data: [:]) + } + + static func mixed( + readableText: String, + unreadableType: NSPasteboard.PasteboardType + ) -> FakePasteboardItem { + FakePasteboardItem( + declaredTypes: [.string, unreadableType], + data: [.string: Data(readableText.utf8)] + ) + } + + func pasteboardData(forType type: NSPasteboard.PasteboardType) -> Data? { + data[type] + } +} diff --git a/docs/self-assessment.md b/docs/self-assessment.md index 26ee865..ad0780e 100644 --- a/docs/self-assessment.md +++ b/docs/self-assessment.md @@ -1,9 +1,9 @@ # Assessment of trsdn/OpenWritr against the Repository Quality Standard 1.16.0 -- Assessed on: 2026-09-20, by an AI agent; no maintainer was asked anything. -- Repository read: `main` at `34fdc90` (exported without git metadata), plus the GitHub API through `gh` (read-only) and the latest release, `v1.6.4`, downloaded and inspected. -- State: **Healthy**. Updated 2026-09-20 after `v1.6.5` and the follow-up that closed `B13`, `P08`, `W03`, `W08`, and `X03`. Open: `S02` (Partial). Reassessed on 2026-09-21 against 1.16.0, which adds `P12` and `P13`. -- Result counts: 84 pass, 1 partial, 0 fail, 21 na (106 criteria). +- Assessed on: 2026-09-22, by an AI agent; no maintainer was asked anything. +- Repository read: `main` at `b23cd8f`, plus the changes on `test/dictation-flow`, the GitHub API through `gh` (read-only), and the previously inspected release evidence recorded below. +- State: **Healthy**. Updated 2026-09-22 after adding hardware-free coverage for the main dictation and clipboard flow, closing the remaining partial criterion (`S02`). Reassessed on 2026-09-21 against 1.16.0, which adds `P12` and `P13`. +- Result counts: 85 pass, 0 partial, 0 fail, 21 na (106 criteria). ## What the assessor did and did not read @@ -88,7 +88,7 @@ Automation Availability does not apply: hosted runners are available and used. | ID | Result | Evidence | |---|---|---| | S01 | pass | `Package.resolved` pins every dependency; setup and build commands are in README and `AGENTS.md`. | -| S02 | partial | `Tests/OpenWritrTests` has 10 Swift Testing tests, run in CI and by the assessor; several assert failure paths (`changedNumberIsAnError`, `droppedNegationIsAnError`, `languageDriftIsAnError`). They cover only the cleanup integrity validator and policy. Nothing exercises the dictation flow (audio, hotkey, paste, overlay, update), which `AGENTS.md` itself states. Under the stated reading of "main entry point", that is a material part missing. | +| S02 | pass | `swift test` runs 38 Swift Testing tests, including hardware-free fakes for the main capture, transcription, enhancement, overlay, and paste orchestration. The suite covers normal and enhanced success, enhancement failure/raw recovery, accepted empty enhancement, short-input padding, transcription error recovery, stale-generation isolation, disabled auto-paste, visible recovery from cancelled auto-paste, empty and populated clipboard save/replace/restore, non-destructive restore preparation failure, delayed restore failure propagation and recovery, concurrent clipboard mutation, fail-closed handling when any declared clipboard representation cannot be preserved, overlapping pastes, and external changes before restore. Each new invariant was mutation-checked by deliberately breaking its production branch and observing the named test fail before restoring the implementation. | | S03 | pass | Two kinds of static check run in CI and are the documented commands: the compiler under Swift 6 strict concurrency with warnings as errors, and `swiftlint lint --strict` with the rule set in `.swiftlint.yml` (force casts and tries, unused bindings, empty checks, duplicate imports, and similar). Reading of "where supported": Swift has formatters, but a formatter would rewrite most of the codebase (`swift format lint` reports the whole file set), so the linter covers the checks that can be enforced without that. | | S04 | pass | README and `Package.swift` claim macOS 14+ on Apple Silicon, a range. CI has one job on `macos-latest`, the newest version available to the runner, which covers a range claim under 1.15.0. A job on macOS 14 would be welcome and is not required. | | S05 | pass | `security_and_analysis.secret_scanning` is enabled with push protection; `secret-scan.yml` also runs on pushes and pull requests. |