From 9feb971e177aa58e159bb90865dcd6e38aa4a66a Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 10:08:48 +0200 Subject: [PATCH 1/9] test(paste): cover clipboard transaction behavior Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- Sources/OpenWritr/PasteManager.swift | 177 +++++++++++++------ Tests/OpenWritrTests/PasteManagerTests.swift | 170 ++++++++++++++++++ 2 files changed, 291 insertions(+), 56 deletions(-) create mode 100644 Tests/OpenWritrTests/PasteManagerTests.swift diff --git a/Sources/OpenWritr/PasteManager.swift b/Sources/OpenWritr/PasteManager.swift index bc978cf..2c266ea 100644 --- a/Sources/OpenWritr/PasteManager.swift +++ b/Sources/OpenWritr/PasteManager.swift @@ -3,19 +3,105 @@ 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 } + + @discardableResult + func clearContents() -> Int + + func writeItems(_ items: [PasteboardItemContent]) -> Bool +} + +@MainActor +protocol PasteCommandPosting { + func postPasteCommand() +} + +extension NSPasteboardItem: PasteboardItemReading { + var pasteboardTypes: [NSPasteboard.PasteboardType] { + types + } - let representations: [Representation] + func pasteboardData(forType type: NSPasteboard.PasteboardType) -> Data? { + data(forType: type) + } +} + +@MainActor +final class SystemPasteboard: PasteboardManaging { + private let pasteboard: NSPasteboard + + init(_ pasteboard: NSPasteboard = .general) { + self.pasteboard = pasteboard + } + + var changeCount: Int { + pasteboard.changeCount + } + + var pasteboardItems: [any PasteboardItemReading]? { + pasteboard.pasteboardItems + } + + @discardableResult + func clearContents() -> Int { + pasteboard.clearContents() + } + + func writeItems(_ items: [PasteboardItemContent]) -> Bool { + 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 false } + return pasteboard.writeObjects(pasteboardItems) + } +} + +struct SystemPasteCommandPoster: PasteCommandPosting { + func postPasteCommand() { + let source = CGEventSource(stateID: .hidSystemState) + + let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true) + keyDown?.flags = .maskCommand - let items: [Item] + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false) + keyUp?.flags = .maskCommand + + keyDown?.post(tap: .cgAnnotatedSessionEventTap) + keyUp?.post(tap: .cgAnnotatedSessionEventTap) + } +} + +@MainActor +final class PasteManager { + private struct PasteboardSnapshot { + let items: [PasteboardItemContent] } private struct PendingRestore { @@ -25,24 +111,33 @@ final class PasteManager { } private var pendingRestore: PendingRestore? + private let pasteboard: any PasteboardManaging + private let commandPoster: any PasteCommandPosting + + init( + pasteboard: any PasteboardManaging = SystemPasteboard(), + commandPoster: any PasteCommandPosting = SystemPasteCommandPoster() + ) { + self.pasteboard = pasteboard + self.commandPoster = commandPoster + } func pasteText(_ text: String) { guard flushPendingRestore(matching: nil) else { return } - let pasteboard = NSPasteboard.general let originalChangeCount = pasteboard.changeCount guard let snapshot = snapshot(of: pasteboard) else { return } - let transcriptItem = NSPasteboardItem() - guard transcriptItem.setString(text, forType: .string) else { - pasteLog.error("Failed to prepare transcript for the pasteboard") - return - } + 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") @@ -50,7 +145,7 @@ final class PasteManager { } let transcriptOwnershipChangeCount = pasteboard.clearContents() - guard pasteboard.writeObjects([transcriptItem]) else { + guard pasteboard.writeItems([transcriptItem]) else { pasteLog.error("Failed to write transcript to the pasteboard") _ = restore(snapshot, to: pasteboard, ifUnchangedSince: transcriptOwnershipChangeCount) return @@ -63,7 +158,7 @@ final class PasteManager { snapshot: snapshot, expectedChangeCount: transcriptChangeCount ) - simulatePaste() + commandPoster.postPasteCommand() DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { _ = self.flushPendingRestore(matching: transactionID) @@ -85,22 +180,22 @@ 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? { + var snapshotItems: [PasteboardItemContent] = [] for item in pasteboard.pasteboardItems ?? [] { - var representations: [PasteboardSnapshot.Item.Representation] = [] + 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 { + guard let data = item.pasteboardData(forType: type) else { pasteLog.notice("Skipping unreadable clipboard representation \(type.rawValue, privacy: .public)") continue } @@ -109,7 +204,7 @@ final class PasteManager { } if !representations.isEmpty { - snapshotItems.append(.init(representations: representations)) + snapshotItems.append(PasteboardItemContent(representations: representations)) } } @@ -118,50 +213,20 @@ 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 pasteboard.changeCount == expectedChangeCount else { return true } pasteboard.clearContents() - guard restoredItems.isEmpty || pasteboard.writeObjects(restoredItems) else { + guard snapshot.items.isEmpty || pasteboard.writeItems(snapshot.items) 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/Tests/OpenWritrTests/PasteManagerTests.swift b/Tests/OpenWritrTests/PasteManagerTests.swift new file mode 100644 index 0000000..abf7146 --- /dev/null +++ b/Tests/OpenWritrTests/PasteManagerTests.swift @@ -0,0 +1,170 @@ +import AppKit +import Testing +@testable import OpenWritr + +@MainActor +@Suite("PasteManager") +struct PasteManagerTests { + @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 unreadableClipboardDataStillAllowsPaste() { + let pasteboard = FakePasteboard(items: [.unreadable(type: .fileURL)]) + 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) + } + + @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) + } +} + +@MainActor +private final class FakePasteCommandPoster: PasteCommandPosting { + private(set) var postCount = 0 + + func postPasteCommand() { + postCount += 1 + } +} + +@MainActor +private final class FakePasteboard: PasteboardManaging { + private(set) var changeCount = 0 + private(set) var items: [FakePasteboardItem] + var mutateWhenReading: [FakePasteboardItem]? + + init(items: [FakePasteboardItem]) { + self.items = items + } + + var pasteboardItems: [any PasteboardItemReading]? { + 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) + } + + @discardableResult + func clearContents() -> Int { + items = [] + changeCount += 1 + return changeCount + } + + func writeItems(_ items: [PasteboardItemContent]) -> Bool { + self.items = items.map(FakePasteboardItem.init) + changeCount += 1 + return true + } + + func replaceExternally(with items: [FakePasteboardItem]) { + self.items = items + changeCount += 1 + } +} + +@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: [:]) + } + + func pasteboardData(forType type: NSPasteboard.PasteboardType) -> Data? { + data[type] + } +} From f58cb9e230f14472655d77b62532e7abdcce7a12 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 10:58:22 +0200 Subject: [PATCH 2/9] test(dictation): cover app flow with injectable seams Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- .github/conformance.yml | 4 +- Sources/OpenWritr/AudioEngine.swift | 25 +- Sources/OpenWritr/GrammarEnhancer.swift | 14 +- Sources/OpenWritr/OpenWritrApp.swift | 93 ++-- Sources/OpenWritr/OverlayPanel.swift | 9 +- Sources/OpenWritr/PasteManager.swift | 8 +- Sources/OpenWritr/TranscriptionManager.swift | 25 +- .../AppViewModelDictationFlowTests.swift | 442 ++++++++++++++++++ docs/self-assessment.md | 10 +- 9 files changed, 582 insertions(+), 48 deletions(-) create mode 100644 Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift 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/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/OpenWritrApp.swift b/Sources/OpenWritr/OpenWritrApp.swift index 14df523..e71e31f 100644 --- a/Sources/OpenWritr/OpenWritrApp.swift +++ b/Sources/OpenWritr/OpenWritrApp.swift @@ -50,6 +50,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 +81,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 +108,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 +118,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 +334,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 +363,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 { @@ -521,9 +554,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 } @@ -656,7 +690,7 @@ final class AppViewModel { } func refreshInputDevices() { - availableInputDevices = AudioEngine.availableInputDevices() + availableInputDevices = audioEngine.availableInputDevices() } func setInputDevice(_ device: AudioInputDevice?) { @@ -1359,7 +1393,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, @@ -1485,7 +1520,7 @@ final class AppViewModel { overlayPanel.show(state: .done) do { - try await Task.sleep(for: .milliseconds(600)) + try await Task.sleep(for: doneDisplayDuration) } catch is CancellationError { return } catch { 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 2c266ea..74abbe7 100644 --- a/Sources/OpenWritr/PasteManager.swift +++ b/Sources/OpenWritr/PasteManager.swift @@ -35,6 +35,12 @@ protocol PasteCommandPosting { func postPasteCommand() } +@MainActor +protocol TextPasting { + func pasteText(_ text: String) + func flushPendingRestore() +} + extension NSPasteboardItem: PasteboardItemReading { var pasteboardTypes: [NSPasteboard.PasteboardType] { types @@ -99,7 +105,7 @@ struct SystemPasteCommandPoster: PasteCommandPosting { } @MainActor -final class PasteManager { +final class PasteManager: TextPasting { private struct PasteboardSnapshot { let items: [PasteboardItemContent] } 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..738574b --- /dev/null +++ b/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift @@ -0,0 +1,442 @@ +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) + } + + 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] = [] + ) -> DictationDependencies { + DictationDependencies( + audio: FakeAudioCapture(samples: samples), + transcriber: FakeTranscriber(behaviors: transcriptions), + enhancer: FakeEnhancer(results: enhancements), + paster: FakeTextPaster(), + 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 + } +} + +@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] = [] + + func pasteText(_ text: String) { + pastedTexts.append(text) + } + + 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 + } + } + + 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/docs/self-assessment.md b/docs/self-assessment.md index 26ee865..95b0b0e 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 30 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, clipboard save/replace/restore, concurrent clipboard mutation, unreadable clipboard data, 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. | From 901ecab49200678c55567b5eda1eb90ff1204c44 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 11:15:14 +0200 Subject: [PATCH 3/9] fix(paste): preserve unreadable clipboard contents Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- Sources/OpenWritr/PasteManager.swift | 15 ++++++-- Tests/OpenWritrTests/PasteManagerTests.swift | 36 ++++++++++++++++++-- docs/self-assessment.md | 2 +- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/Sources/OpenWritr/PasteManager.swift b/Sources/OpenWritr/PasteManager.swift index 74abbe7..97da178 100644 --- a/Sources/OpenWritr/PasteManager.swift +++ b/Sources/OpenWritr/PasteManager.swift @@ -192,15 +192,19 @@ final class PasteManager: TextPasting { } private func snapshot(of pasteboard: any PasteboardManaging) -> PasteboardSnapshot? { + guard let pasteboardItems = pasteboard.pasteboardItems else { + pasteLog.notice("Clipboard items could not be read; cancelling paste") + return nil + } var snapshotItems: [PasteboardItemContent] = [] - for item in pasteboard.pasteboardItems ?? [] { + for item in pasteboardItems { var representations: [PasteboardItemContent.Representation] = [] 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. + // managed apps) or promised data that never materialises. Skip them + // rather than dropping an otherwise restorable pasteboard item. guard let data = item.pasteboardData(forType: type) else { pasteLog.notice("Skipping unreadable clipboard representation \(type.rawValue, privacy: .public)") continue @@ -214,6 +218,11 @@ final class PasteManager: TextPasting { } } + guard pasteboardItems.isEmpty || !snapshotItems.isEmpty else { + pasteLog.notice("Clipboard contains data but no restorable representations; cancelling paste") + return nil + } + return PasteboardSnapshot(items: snapshotItems) } diff --git a/Tests/OpenWritrTests/PasteManagerTests.swift b/Tests/OpenWritrTests/PasteManagerTests.swift index abf7146..15873ac 100644 --- a/Tests/OpenWritrTests/PasteManagerTests.swift +++ b/Tests/OpenWritrTests/PasteManagerTests.swift @@ -33,7 +33,14 @@ struct PasteManagerTests { } @Test func unreadableClipboardDataStillAllowsPaste() { - let pasteboard = FakePasteboard(items: [.unreadable(type: .fileURL)]) + let pasteboard = FakePasteboard( + items: [ + .mixed( + readableText: "Restorable clipboard", + unreadableType: .fileURL + ) + ] + ) let poster = FakePasteCommandPoster() let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) @@ -44,7 +51,20 @@ struct PasteManagerTests { manager.flushPendingRestore() - #expect(pasteboard.items.isEmpty) + #expect(pasteboard.text == "Restorable clipboard") + #expect(pasteboard.items.first?.pasteboardTypes == [.string]) + } + + @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 secondPasteRestoresOriginalClipboardAfterPendingRestore() { @@ -89,6 +109,7 @@ private final class FakePasteCommandPoster: PasteCommandPosting { @MainActor private final class FakePasteboard: PasteboardManaging { private(set) var changeCount = 0 + private(set) var clearCount = 0 private(set) var items: [FakePasteboardItem] var mutateWhenReading: [FakePasteboardItem]? @@ -113,6 +134,7 @@ private final class FakePasteboard: PasteboardManaging { @discardableResult func clearContents() -> Int { items = [] + clearCount += 1 changeCount += 1 return changeCount } @@ -164,6 +186,16 @@ private struct FakePasteboardItem: PasteboardItemReading { 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 95b0b0e..742c98d 100644 --- a/docs/self-assessment.md +++ b/docs/self-assessment.md @@ -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 | pass | `swift test` runs 30 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, clipboard save/replace/restore, concurrent clipboard mutation, unreadable clipboard data, 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. | +| S02 | pass | `swift test` runs 31 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, clipboard save/replace/restore, concurrent clipboard mutation, mixed readable/unreadable clipboard data, fail-closed handling of unreadable-only clipboard data, 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. | From 2f5b1df685ae449791badb91308ab45a5ed64edd Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 11:29:27 +0200 Subject: [PATCH 4/9] fix(paste): preserve every clipboard item Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- Sources/OpenWritr/PasteManager.swift | 10 ++++------ Tests/OpenWritrTests/PasteManagerTests.swift | 17 +++++++++++++++++ docs/self-assessment.md | 2 +- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Sources/OpenWritr/PasteManager.swift b/Sources/OpenWritr/PasteManager.swift index 97da178..93db44f 100644 --- a/Sources/OpenWritr/PasteManager.swift +++ b/Sources/OpenWritr/PasteManager.swift @@ -213,14 +213,12 @@ final class PasteManager: TextPasting { representations.append(.init(type: type, data: data)) } - if !representations.isEmpty { - snapshotItems.append(PasteboardItemContent(representations: representations)) + guard !representations.isEmpty else { + pasteLog.notice("Clipboard item has no restorable representations; cancelling paste") + return nil } - } - guard pasteboardItems.isEmpty || !snapshotItems.isEmpty else { - pasteLog.notice("Clipboard contains data but no restorable representations; cancelling paste") - return nil + snapshotItems.append(PasteboardItemContent(representations: representations)) } return PasteboardSnapshot(items: snapshotItems) diff --git a/Tests/OpenWritrTests/PasteManagerTests.swift b/Tests/OpenWritrTests/PasteManagerTests.swift index 15873ac..afd2d01 100644 --- a/Tests/OpenWritrTests/PasteManagerTests.swift +++ b/Tests/OpenWritrTests/PasteManagerTests.swift @@ -67,6 +67,23 @@ struct PasteManagerTests { #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() diff --git a/docs/self-assessment.md b/docs/self-assessment.md index 742c98d..c1606bd 100644 --- a/docs/self-assessment.md +++ b/docs/self-assessment.md @@ -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 | pass | `swift test` runs 31 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, clipboard save/replace/restore, concurrent clipboard mutation, mixed readable/unreadable clipboard data, fail-closed handling of unreadable-only clipboard data, 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. | +| S02 | pass | `swift test` runs 32 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, clipboard save/replace/restore, concurrent clipboard mutation, mixed readable/unreadable representations, fail-closed handling when any clipboard item is wholly unreadable, 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. | From 0b1b1e761874c2bc8dbe26d27f99bb17175b5ba6 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 11:43:42 +0200 Subject: [PATCH 5/9] fix(paste): require complete clipboard snapshots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- Sources/OpenWritr/PasteManager.swift | 10 ++++++---- Tests/OpenWritrTests/PasteManagerTests.swift | 11 ++++------- docs/self-assessment.md | 2 +- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/Sources/OpenWritr/PasteManager.swift b/Sources/OpenWritr/PasteManager.swift index 93db44f..8c5fc71 100644 --- a/Sources/OpenWritr/PasteManager.swift +++ b/Sources/OpenWritr/PasteManager.swift @@ -203,11 +203,13 @@ final class PasteManager: TextPasting { for type in item.pasteboardTypes { // Some representations cannot be read: protected content (e.g. from - // managed apps) or promised data that never materialises. Skip them - // rather than dropping an otherwise restorable pasteboard item. + // 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("Skipping unreadable clipboard representation \(type.rawValue, privacy: .public)") - continue + pasteLog.notice( + "Clipboard representation \(type.rawValue, privacy: .public) could not be preserved; cancelling paste" + ) + return nil } representations.append(.init(type: type, data: data)) diff --git a/Tests/OpenWritrTests/PasteManagerTests.swift b/Tests/OpenWritrTests/PasteManagerTests.swift index afd2d01..a7ee175 100644 --- a/Tests/OpenWritrTests/PasteManagerTests.swift +++ b/Tests/OpenWritrTests/PasteManagerTests.swift @@ -32,7 +32,7 @@ struct PasteManagerTests { #expect(poster.postCount == 0) } - @Test func unreadableClipboardDataStillAllowsPaste() { + @Test func unreadableRepresentationCancelsPasteToPreserveWholeItem() { let pasteboard = FakePasteboard( items: [ .mixed( @@ -46,13 +46,10 @@ struct PasteManagerTests { manager.pasteText("Synthetic transcript") - #expect(pasteboard.text == "Synthetic transcript") - #expect(poster.postCount == 1) - - manager.flushPendingRestore() - #expect(pasteboard.text == "Restorable clipboard") - #expect(pasteboard.items.first?.pasteboardTypes == [.string]) + #expect(pasteboard.items.first?.pasteboardTypes == [.string, .fileURL]) + #expect(pasteboard.clearCount == 0) + #expect(poster.postCount == 0) } @Test func unreadableOnlyClipboardCancelsPasteWithoutClearing() { diff --git a/docs/self-assessment.md b/docs/self-assessment.md index c1606bd..9e1a2b8 100644 --- a/docs/self-assessment.md +++ b/docs/self-assessment.md @@ -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 | pass | `swift test` runs 32 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, clipboard save/replace/restore, concurrent clipboard mutation, mixed readable/unreadable representations, fail-closed handling when any clipboard item is wholly unreadable, 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. | +| S02 | pass | `swift test` runs 32 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, clipboard save/replace/restore, 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. | From f40d84f41ed3f1cb90de0fe74c2e59dbc78084e5 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 12:30:57 +0200 Subject: [PATCH 6/9] fix(paste): allow empty clipboard snapshots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- Sources/OpenWritr/PasteManager.swift | 5 +--- Tests/OpenWritrTests/PasteManagerTests.swift | 26 +++++++++++++++++++- docs/self-assessment.md | 2 +- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/Sources/OpenWritr/PasteManager.swift b/Sources/OpenWritr/PasteManager.swift index 8c5fc71..bc7b7ec 100644 --- a/Sources/OpenWritr/PasteManager.swift +++ b/Sources/OpenWritr/PasteManager.swift @@ -192,10 +192,7 @@ final class PasteManager: TextPasting { } private func snapshot(of pasteboard: any PasteboardManaging) -> PasteboardSnapshot? { - guard let pasteboardItems = pasteboard.pasteboardItems else { - pasteLog.notice("Clipboard items could not be read; cancelling paste") - return nil - } + let pasteboardItems = pasteboard.pasteboardItems ?? [] var snapshotItems: [PasteboardItemContent] = [] for item in pasteboardItems { diff --git a/Tests/OpenWritrTests/PasteManagerTests.swift b/Tests/OpenWritrTests/PasteManagerTests.swift index a7ee175..b876f95 100644 --- a/Tests/OpenWritrTests/PasteManagerTests.swift +++ b/Tests/OpenWritrTests/PasteManagerTests.swift @@ -5,6 +5,22 @@ import Testing @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() @@ -126,12 +142,20 @@ private final class FakePasteboard: PasteboardManaging { private(set) var clearCount = 0 private(set) var items: [FakePasteboardItem] var mutateWhenReading: [FakePasteboardItem]? + private let returnsNilItemsWhenEmpty: Bool - init(items: [FakePasteboardItem]) { + 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 diff --git a/docs/self-assessment.md b/docs/self-assessment.md index 9e1a2b8..000b6ef 100644 --- a/docs/self-assessment.md +++ b/docs/self-assessment.md @@ -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 | pass | `swift test` runs 32 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, clipboard save/replace/restore, 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. | +| S02 | pass | `swift test` runs 33 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, empty and populated clipboard save/replace/restore, 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. | From 845a878f5fccb52e5471b3b739d580f52eab88fe Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 13:03:15 +0200 Subject: [PATCH 7/9] fix(paste): prepare clipboard restores before clearing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- Sources/OpenWritr/PasteManager.swift | 56 ++++++++++++++++---- Tests/OpenWritrTests/PasteManagerTests.swift | 44 +++++++++++++-- docs/self-assessment.md | 2 +- 3 files changed, 87 insertions(+), 15 deletions(-) diff --git a/Sources/OpenWritr/PasteManager.swift b/Sources/OpenWritr/PasteManager.swift index bc7b7ec..2a76535 100644 --- a/Sources/OpenWritr/PasteManager.swift +++ b/Sources/OpenWritr/PasteManager.swift @@ -24,10 +24,15 @@ protocol PasteboardManaging: AnyObject { var changeCount: Int { get } var pasteboardItems: [any PasteboardItemReading]? { get } + func prepareWrite(_ items: [PasteboardItemContent]) -> (any PreparedPasteboardWrite)? + @discardableResult func clearContents() -> Int +} - func writeItems(_ items: [PasteboardItemContent]) -> Bool +@MainActor +protocol PreparedPasteboardWrite { + func write() -> Bool } @MainActor @@ -53,6 +58,20 @@ extension NSPasteboardItem: PasteboardItemReading { @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) { @@ -67,12 +86,7 @@ final class SystemPasteboard: PasteboardManaging { pasteboard.pasteboardItems } - @discardableResult - func clearContents() -> Int { - pasteboard.clearContents() - } - - func writeItems(_ items: [PasteboardItemContent]) -> Bool { + func prepareWrite(_ items: [PasteboardItemContent]) -> (any PreparedPasteboardWrite)? { let pasteboardItems = items.compactMap { item -> NSPasteboardItem? in let pasteboardItem = NSPasteboardItem() for representation in item.representations { @@ -83,9 +97,14 @@ final class SystemPasteboard: PasteboardManaging { return pasteboardItem } guard pasteboardItems.count == items.count else { - return false + return nil } - return pasteboard.writeObjects(pasteboardItems) + return PreparedWrite(pasteboard: pasteboard, items: pasteboardItems) + } + + @discardableResult + func clearContents() -> Int { + pasteboard.clearContents() } } @@ -150,8 +169,18 @@ final class PasteManager: TextPasting { return } + guard let preparedTranscript = pasteboard.prepareWrite([transcriptItem]) else { + pasteLog.error("Failed to prepare transcript for the pasteboard") + return + } + + guard pasteboard.changeCount == originalChangeCount else { + pasteLog.notice("Clipboard changed while the transcript was being prepared; cancelling paste") + return + } + let transcriptOwnershipChangeCount = pasteboard.clearContents() - guard pasteboard.writeItems([transcriptItem]) else { + guard preparedTranscript.write() else { pasteLog.error("Failed to write transcript to the pasteboard") _ = restore(snapshot, to: pasteboard, ifUnchangedSince: transcriptOwnershipChangeCount) return @@ -228,13 +257,18 @@ final class PasteManager: TextPasting { to pasteboard: any PasteboardManaging, ifUnchangedSince expectedChangeCount: Int ) -> Bool { + guard let preparedRestore = pasteboard.prepareWrite(snapshot.items) else { + pasteLog.error("Failed to prepare clipboard contents for restoration") + return false + } + guard pasteboard.changeCount == expectedChangeCount else { return true } pasteboard.clearContents() - guard snapshot.items.isEmpty || pasteboard.writeItems(snapshot.items) else { + guard preparedRestore.write() else { pasteLog.error("Failed to restore clipboard contents") return false } diff --git a/Tests/OpenWritrTests/PasteManagerTests.swift b/Tests/OpenWritrTests/PasteManagerTests.swift index b876f95..10ddefe 100644 --- a/Tests/OpenWritrTests/PasteManagerTests.swift +++ b/Tests/OpenWritrTests/PasteManagerTests.swift @@ -125,6 +125,20 @@ struct PasteManagerTests { #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) + } } @MainActor @@ -142,6 +156,7 @@ private final class FakePasteboard: PasteboardManaging { private(set) var clearCount = 0 private(set) var items: [FakePasteboardItem] var mutateWhenReading: [FakePasteboardItem]? + var failNextPreparation = false private let returnsNilItemsWhenEmpty: Bool init( @@ -169,6 +184,19 @@ private final class FakePasteboard: PasteboardManaging { 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 = [] @@ -177,15 +205,25 @@ private final class FakePasteboard: PasteboardManaging { return changeCount } - func writeItems(_ items: [PasteboardItemContent]) -> Bool { + fileprivate func writeItems(_ items: [PasteboardItemContent]) -> Bool { self.items = items.map(FakePasteboardItem.init) changeCount += 1 return true } +} - func replaceExternally(with items: [FakePasteboardItem]) { +@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 - changeCount += 1 + } + + func write() -> Bool { + pasteboard.writeItems(items) } } diff --git a/docs/self-assessment.md b/docs/self-assessment.md index 000b6ef..08c6f45 100644 --- a/docs/self-assessment.md +++ b/docs/self-assessment.md @@ -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 | pass | `swift test` runs 33 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, empty and populated clipboard save/replace/restore, 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. | +| S02 | pass | `swift test` runs 34 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, empty and populated clipboard save/replace/restore, non-destructive restore preparation failure, 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. | From 2f3dc8331dcad235a539240a57ccef66e4672005 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 13:15:32 +0200 Subject: [PATCH 8/9] fix(paste): surface cancelled auto-paste Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- CHANGELOG.md | 5 ++ Sources/OpenWritr/MenuBarView.swift | 2 +- Sources/OpenWritr/OpenWritrApp.swift | 31 +++++-- Sources/OpenWritr/PasteManager.swift | 24 ++++-- .../AppViewModelDictationFlowTests.swift | 85 ++++++++++++++++++- Tests/OpenWritrTests/PasteManagerTests.swift | 19 ++++- docs/self-assessment.md | 2 +- 7 files changed, 147 insertions(+), 21 deletions(-) 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/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 e71e31f..f0169dc 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 { @@ -545,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() } } @@ -569,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 @@ -618,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 @@ -1514,8 +1517,12 @@ 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) @@ -1547,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: "The transcript remains available in OpenWritr. Copy different clipboard content, then try 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/PasteManager.swift b/Sources/OpenWritr/PasteManager.swift index 2a76535..a91471b 100644 --- a/Sources/OpenWritr/PasteManager.swift +++ b/Sources/OpenWritr/PasteManager.swift @@ -42,10 +42,16 @@ protocol PasteCommandPosting { @MainActor protocol TextPasting { - func pasteText(_ text: String) + @discardableResult + func pasteText(_ text: String) -> PasteOutcome func flushPendingRestore() } +enum PasteOutcome: Sendable, Equatable { + case pasted + case cancelled +} + extension NSPasteboardItem: PasteboardItemReading { var pasteboardTypes: [NSPasteboard.PasteboardType] { types @@ -147,15 +153,16 @@ final class PasteManager: TextPasting { self.commandPoster = commandPoster } - func pasteText(_ text: String) { + @discardableResult + func pasteText(_ text: String) -> PasteOutcome { guard flushPendingRestore(matching: nil) else { - return + return .cancelled } let originalChangeCount = pasteboard.changeCount guard let snapshot = snapshot(of: pasteboard) else { - return + return .cancelled } let transcriptItem = PasteboardItemContent( @@ -166,24 +173,24 @@ final class PasteManager: TextPasting { guard pasteboard.changeCount == originalChangeCount else { pasteLog.notice("Clipboard changed while it was being saved; cancelling paste") - return + 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 the transcript was being prepared; cancelling paste") - return + return .cancelled } let transcriptOwnershipChangeCount = pasteboard.clearContents() 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 @@ -198,6 +205,7 @@ final class PasteManager: TextPasting { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { _ = self.flushPendingRestore(matching: transactionID) } + return .pasted } func flushPendingRestore() { diff --git a/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift b/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift index 738574b..9eec67e 100644 --- a/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift +++ b/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift @@ -176,6 +176,64 @@ struct AppViewModelDictationFlowTests { #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.") + + 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 @@ -192,13 +250,14 @@ struct AppViewModelDictationFlowTests { private func makeDependencies( samples: [[Float]], transcriptions: [FakeTranscriber.Behavior], - enhancements: [EnhancementResult] = [] + enhancements: [EnhancementResult] = [], + pasteOutcomes: [PasteOutcome] = [] ) -> DictationDependencies { DictationDependencies( audio: FakeAudioCapture(samples: samples), transcriber: FakeTranscriber(behaviors: transcriptions), enhancer: FakeEnhancer(results: enhancements), - paster: FakeTextPaster(), + paster: FakeTextPaster(outcomes: pasteOutcomes), overlay: FakeOverlayPresenter() ) } @@ -397,9 +456,18 @@ private final class FakeEnhancer: TranscriptEnhancing, @unchecked Sendable { @MainActor private final class FakeTextPaster: TextPasting { private(set) var pastedTexts: [String] = [] + private var outcomes: [PasteOutcome] - func pasteText(_ text: String) { - pastedTexts.append(text) + 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() {} @@ -418,6 +486,15 @@ private final class FakeOverlayPresenter: OverlayPresenting { } } + 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 { diff --git a/Tests/OpenWritrTests/PasteManagerTests.swift b/Tests/OpenWritrTests/PasteManagerTests.swift index 10ddefe..5650413 100644 --- a/Tests/OpenWritrTests/PasteManagerTests.swift +++ b/Tests/OpenWritrTests/PasteManagerTests.swift @@ -60,8 +60,9 @@ struct PasteManagerTests { let poster = FakePasteCommandPoster() let manager = PasteManager(pasteboard: pasteboard, commandPoster: poster) - manager.pasteText("Synthetic transcript") + 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) @@ -139,6 +140,22 @@ struct PasteManagerTests { #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) + } } @MainActor diff --git a/docs/self-assessment.md b/docs/self-assessment.md index 08c6f45..5676e3d 100644 --- a/docs/self-assessment.md +++ b/docs/self-assessment.md @@ -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 | pass | `swift test` runs 34 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, empty and populated clipboard save/replace/restore, non-destructive restore preparation failure, 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. | +| S02 | pass | `swift test` runs 37 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, 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. | From 99de350f2adb8c8edc2b714b62ee2ae9ffa9f680 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 14:01:00 +0200 Subject: [PATCH 9/9] fix(paste): retain delayed restore failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- Sources/OpenWritr/OpenWritrApp.swift | 2 +- Sources/OpenWritr/PasteManager.swift | 34 +++++++++++++-- .../AppViewModelDictationFlowTests.swift | 8 ++++ Tests/OpenWritrTests/PasteManagerTests.swift | 42 +++++++++++++++++++ docs/self-assessment.md | 2 +- 5 files changed, 82 insertions(+), 6 deletions(-) diff --git a/Sources/OpenWritr/OpenWritrApp.swift b/Sources/OpenWritr/OpenWritrApp.swift index f0169dc..fea91a6 100644 --- a/Sources/OpenWritr/OpenWritrApp.swift +++ b/Sources/OpenWritr/OpenWritrApp.swift @@ -1560,7 +1560,7 @@ final class AppViewModel { kind: .paste, title: "Paste Cancelled", message: "OpenWritr could not preserve the clipboard, so the transcript was not pasted.", - recoverySuggestion: "The transcript remains available in OpenWritr. Copy different clipboard content, then try again." + recoverySuggestion: "Replace or clear the clipboard contents, then record again." ), overlayMessage: "Clipboard could not be preserved; paste cancelled" ) diff --git a/Sources/OpenWritr/PasteManager.swift b/Sources/OpenWritr/PasteManager.swift index a91471b..f5e5aae 100644 --- a/Sources/OpenWritr/PasteManager.swift +++ b/Sources/OpenWritr/PasteManager.swift @@ -35,6 +35,11 @@ protocol PreparedPasteboardWrite { func write() -> Bool } +@MainActor +protocol PasteRestoreScheduling { + func scheduleRestore(_ action: @escaping @MainActor () -> Void) +} + @MainActor protocol PasteCommandPosting { func postPasteCommand() @@ -129,6 +134,14 @@ struct SystemPasteCommandPoster: PasteCommandPosting { } } +struct SystemPasteRestoreScheduler: PasteRestoreScheduling { + func scheduleRestore(_ action: @escaping @MainActor () -> Void) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + action() + } + } +} + @MainActor final class PasteManager: TextPasting { private struct PasteboardSnapshot { @@ -142,19 +155,28 @@ final class PasteManager: TextPasting { } 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() + 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 + } + guard flushPendingRestore(matching: nil) else { return .cancelled } @@ -202,14 +224,18 @@ final class PasteManager: TextPasting { ) 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 { diff --git a/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift b/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift index 9eec67e..d2ffb83 100644 --- a/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift +++ b/Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift @@ -198,6 +198,7 @@ struct AppViewModelDictationFlowTests { #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) @@ -301,6 +302,13 @@ struct AppViewModelDictationFlowTests { } return false } + + private func pasteErrorRecoverySuggestion(_ state: AppState) -> String? { + guard case .runtimeError(let presentation) = state, + presentation.kind == .paste + else { return nil } + return presentation.recoverySuggestion + } } @MainActor diff --git a/Tests/OpenWritrTests/PasteManagerTests.swift b/Tests/OpenWritrTests/PasteManagerTests.swift index 5650413..19dc90d 100644 --- a/Tests/OpenWritrTests/PasteManagerTests.swift +++ b/Tests/OpenWritrTests/PasteManagerTests.swift @@ -156,6 +156,33 @@ struct PasteManagerTests { #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 @@ -167,6 +194,21 @@ private final class FakePasteCommandPoster: PasteCommandPosting { } } +@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 diff --git a/docs/self-assessment.md b/docs/self-assessment.md index 5676e3d..ad0780e 100644 --- a/docs/self-assessment.md +++ b/docs/self-assessment.md @@ -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 | pass | `swift test` runs 37 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, 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. | +| 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. |