Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Sources/OpenWritr/ErrorLogger.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import os.log

@MainActor
protocol ErrorLogging {
func logError(_ message: String)
}

struct UnifiedErrorLogger: ErrorLogging {
private let logger: Logger

init(category: String) {
logger = Logger(subsystem: "com.openwritr.app", category: category)
}

func logError(_ message: String) {
logger.error("\(message, privacy: .public)")
}
}
43 changes: 23 additions & 20 deletions Sources/OpenWritr/OpenWritrApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ final class AppViewModel {
private let captureDrainTimeout: Duration = .milliseconds(350)
private let doneDisplayDuration: Duration
private let transientErrorDisplayDuration: Duration
private let errorLogger: any ErrorLogging
private var didConfigure = false
private var didAttemptInitialSetup = false
private var isInitializing = false
Expand Down Expand Up @@ -125,6 +126,7 @@ final class AppViewModel {
grammarEnhancer: any TranscriptEnhancing = GrammarEnhancer(),
pasteManager: any TextPasting = PasteManager(),
overlayPanel: any OverlayPresenting = OverlayPanel(),
errorLogger: any ErrorLogging = UnifiedErrorLogger(category: "AppViewModel"),
startsOperational: Bool = false,
doneDisplayDuration: Duration = AppViewModel.defaultDoneDisplayDuration,
transientErrorDisplayDuration: Duration = AppViewModel.defaultTransientErrorDisplayDuration
Expand All @@ -134,6 +136,7 @@ final class AppViewModel {
self.grammarEnhancer = grammarEnhancer
self.pasteManager = pasteManager
self.overlayPanel = overlayPanel
self.errorLogger = errorLogger
self.doneDisplayDuration = doneDisplayDuration
self.transientErrorDisplayDuration = transientErrorDisplayDuration
if startsOperational {
Expand Down Expand Up @@ -274,8 +277,8 @@ final class AppViewModel {
}
modelsLoaded = true
} catch {
appLog.error(
"Model initialization failed: \(error.localizedDescription, privacy: .public)"
errorLogger.logError(
"Model initialization failed: \(error.localizedDescription)"
)
presentInitializationError(
errorPresentation(
Expand Down Expand Up @@ -303,7 +306,7 @@ final class AppViewModel {

state = .loading
if case .failure(let error) = prepareAudioForStartup() {
appLog.error("Audio initialization failed: \(error.localizedDescription, privacy: .public)")
errorLogger.logError("Audio initialization failed: \(error.localizedDescription)")
presentInitializationError(
errorPresentation(kind: .audio, title: "Microphone Initialization Failed", error: error)
)
Expand All @@ -312,7 +315,7 @@ final class AppViewModel {
updateInputDeviceStatusMessage()

if case .failure(let error) = hotkeyManager.start() {
appLog.error("Hotkey initialization failed: \(error.localizedDescription, privacy: .public)")
errorLogger.logError("Hotkey initialization failed: \(error.localizedDescription)")
presentInitializationError(
errorPresentation(kind: .audio, title: "Push-to-Talk Initialization Failed", error: error)
)
Expand Down Expand Up @@ -721,7 +724,7 @@ final class AppViewModel {
state = .ready
}
case .failure(let error):
appLog.error("Input device validation failed: \(error.localizedDescription, privacy: .public)")
errorLogger.logError("Input device validation failed: \(error.localizedDescription)")
if device == nil {
scheduleSystemDefaultRecovery(showTransientError: true, initialError: error)
} else {
Expand All @@ -736,7 +739,7 @@ final class AppViewModel {
clearSelectedInputDevice()
inputDeviceStatusMessage = "The previous macOS system input could not be restored."
}
appLog.error("Input device selection failed: \(error.localizedDescription, privacy: .public)")
errorLogger.logError("Input device selection failed: \(error.localizedDescription)")
if device == nil {
scheduleSystemDefaultRecovery(showTransientError: true, initialError: error)
} else {
Expand Down Expand Up @@ -776,8 +779,8 @@ final class AppViewModel {
guard !didShutdown, isOperational else { return }

if let operationID = captureOperationID, captureHandle == nil {
appLog.error(
"Runtime audio failure invalidated pending capture: \(error.localizedDescription, privacy: .public)"
errorLogger.logError(
"Runtime audio failure invalidated pending capture: \(error.localizedDescription)"
)
pendingStartTask?.cancel()
invalidateCaptureOperation(ifCurrent: operationID)
Expand All @@ -795,7 +798,7 @@ final class AppViewModel {
guard let operationID = captureOperationID,
let handle = captureHandle
else {
appLog.error("Runtime audio failure: \(error.localizedDescription, privacy: .public)")
errorLogger.logError("Runtime audio failure: \(error.localizedDescription)")
inputDeviceStatusMessage = "The microphone configuration failed: \(error.localizedDescription)"
if selectedInputDeviceID == nil {
scheduleSystemDefaultRecovery(showTransientError: true, initialError: error)
Expand All @@ -808,8 +811,8 @@ final class AppViewModel {
return
}

appLog.error(
"Runtime audio failure invalidated capture generation \(handle.generation): \(error.localizedDescription, privacy: .public)"
errorLogger.logError(
"Runtime audio failure invalidated capture generation \(handle.generation): \(error.localizedDescription)"
)
activeProcessingTask?.cancel()
pendingStartTask?.cancel()
Expand Down Expand Up @@ -847,7 +850,7 @@ final class AppViewModel {
overlayPanel.dismiss()
state = .ready
case .failure(let error):
appLog.error("Microphone retry validation failed: \(error.localizedDescription, privacy: .public)")
errorLogger.logError("Microphone retry validation failed: \(error.localizedDescription)")
if selectedInputDeviceID == nil {
scheduleSystemDefaultRecovery(showTransientError: true, initialError: error)
} else {
Expand Down Expand Up @@ -1110,7 +1113,7 @@ final class AppViewModel {
prompts: customEnhancementPrompts
)
guard let data = try? JSONEncoder().encode(store) else {
appLog.error("Failed to encode custom enhancement prompts")
errorLogger.logError("Failed to encode custom enhancement prompts")
return
}
UserDefaults.standard.set(data, forKey: Self.customPromptsPreferenceKey)
Expand All @@ -1130,8 +1133,8 @@ final class AppViewModel {
}
} catch {
launchAtLogin = SMAppService.mainApp.status == .enabled
appLog.error(
"Launch-at-login update failed: \(error.localizedDescription, privacy: .public)"
errorLogger.logError(
"Launch-at-login update failed: \(error.localizedDescription)"
)
if isOperational {
presentRuntimeError(
Expand Down Expand Up @@ -1272,7 +1275,7 @@ final class AppViewModel {
!Task.isCancelled,
case .preparingMicrophone = state
else { return }
appLog.error("Microphone start failed: \(error.localizedDescription, privacy: .public)")
errorLogger.logError("Microphone start failed: \(error.localizedDescription)")
if selectedInputDeviceID == nil {
scheduleSystemDefaultRecovery(showTransientError: true, initialError: error)
} else {
Expand Down Expand Up @@ -1363,7 +1366,7 @@ final class AppViewModel {
expectedState: expectedState
) else { return }
guard let samples else {
appLog.error("Capture generation \(handle.generation) stopped without samples")
errorLogger.logError("Capture generation \(handle.generation) stopped without samples")
invalidateCaptureOperation(ifCurrent: operationID)
presentRuntimeError(
AppErrorPresentation(
Expand Down Expand Up @@ -1431,8 +1434,8 @@ final class AppViewModel {
handle: handle,
expectedState: .transcribing
) else { return }
appLog.error(
"Transcription failed for \(samples.count) captured samples: \(error.localizedDescription, privacy: .public)"
errorLogger.logError(
"Transcription failed for \(samples.count) captured samples: \(error.localizedDescription)"
)
clearCaptureOperation(ifCurrent: operationID)
presentRuntimeError(
Expand Down Expand Up @@ -1531,7 +1534,7 @@ final class AppViewModel {
} catch is CancellationError {
return
} catch {
appLog.error("Done overlay delay failed: \(error.localizedDescription, privacy: .public)")
errorLogger.logError("Done overlay delay failed: \(error.localizedDescription)")
}

guard isOperational, !didShutdown else { return }
Expand Down
13 changes: 8 additions & 5 deletions Sources/OpenWritr/PasteManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,15 +159,18 @@ final class PasteManager: TextPasting {
private let pasteboard: any PasteboardManaging
private let commandPoster: any PasteCommandPosting
private let restoreScheduler: any PasteRestoreScheduling
private let errorLogger: any ErrorLogging

init(
pasteboard: any PasteboardManaging = SystemPasteboard(),
commandPoster: any PasteCommandPosting = SystemPasteCommandPoster(),
restoreScheduler: any PasteRestoreScheduling = SystemPasteRestoreScheduler()
restoreScheduler: any PasteRestoreScheduling = SystemPasteRestoreScheduler(),
errorLogger: any ErrorLogging = UnifiedErrorLogger(category: "PasteManager")
) {
self.pasteboard = pasteboard
self.commandPoster = commandPoster
self.restoreScheduler = restoreScheduler
self.errorLogger = errorLogger
}

@discardableResult
Expand Down Expand Up @@ -199,7 +202,7 @@ final class PasteManager: TextPasting {
}

guard let preparedTranscript = pasteboard.prepareWrite([transcriptItem]) else {
pasteLog.error("Failed to prepare transcript for the pasteboard")
errorLogger.logError("Failed to prepare transcript for the pasteboard")
return .cancelled
}

Expand All @@ -210,7 +213,7 @@ final class PasteManager: TextPasting {

let transcriptOwnershipChangeCount = pasteboard.clearContents()
guard preparedTranscript.write() else {
pasteLog.error("Failed to write transcript to the pasteboard")
errorLogger.logError("Failed to write transcript to the pasteboard")
_ = restore(snapshot, to: pasteboard, ifUnchangedSince: transcriptOwnershipChangeCount)
return .cancelled
}
Expand Down Expand Up @@ -292,7 +295,7 @@ final class PasteManager: TextPasting {
ifUnchangedSince expectedChangeCount: Int
) -> Bool {
guard let preparedRestore = pasteboard.prepareWrite(snapshot.items) else {
pasteLog.error("Failed to prepare clipboard contents for restoration")
errorLogger.logError("Failed to prepare clipboard contents for restoration")
return false
}

Expand All @@ -303,7 +306,7 @@ final class PasteManager: TextPasting {
pasteboard.clearContents()

guard preparedRestore.write() else {
pasteLog.error("Failed to restore clipboard contents")
errorLogger.logError("Failed to restore clipboard contents")
return false
}

Expand Down
24 changes: 23 additions & 1 deletion Tests/OpenWritrTests/AppViewModelDictationFlowTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ struct AppViewModelDictationFlowTests {

#expect(isRuntimeError(viewModel.state, kind: .transcription))
#expect(dependencies.overlay.didShowError("Transcription failed"))
#expect(
dependencies.errorLogger.messages.contains {
$0.hasPrefix("Transcription failed for 16000 captured samples:")
}
)

await waitUntil { viewModel.state.isReady }

Expand Down Expand Up @@ -148,6 +153,11 @@ struct AppViewModelDictationFlowTests {

dependencies.audio.onFailure?(.inputDeviceUnavailable(42))
await waitUntil { isRuntimeError(viewModel.state, kind: .audio) }
#expect(
dependencies.errorLogger.messages.contains {
$0.hasPrefix("Runtime audio failure invalidated capture generation 1:")
}
)
viewModel.retryMicrophone()
#expect(viewModel.state.isReady)

Expand Down Expand Up @@ -259,7 +269,8 @@ struct AppViewModelDictationFlowTests {
transcriber: FakeTranscriber(behaviors: transcriptions),
enhancer: FakeEnhancer(results: enhancements),
paster: FakeTextPaster(outcomes: pasteOutcomes),
overlay: FakeOverlayPresenter()
overlay: FakeOverlayPresenter(),
errorLogger: RecordingErrorLogger()
)
}

Expand Down Expand Up @@ -318,6 +329,7 @@ private struct DictationDependencies {
let enhancer: FakeEnhancer
let paster: FakeTextPaster
let overlay: FakeOverlayPresenter
let errorLogger: RecordingErrorLogger

func makeViewModel(
transientErrorDisplayDuration: Duration = .milliseconds(10)
Expand All @@ -328,13 +340,23 @@ private struct DictationDependencies {
grammarEnhancer: enhancer,
pasteManager: paster,
overlayPanel: overlay,
errorLogger: errorLogger,
startsOperational: true,
doneDisplayDuration: .milliseconds(1),
transientErrorDisplayDuration: transientErrorDisplayDuration
)
}
}

@MainActor
private final class RecordingErrorLogger: ErrorLogging {
private(set) var messages: [String] = []

func logError(_ message: String) {
messages.append(message)
}
}

private final class FakeAudioCapture: AudioCapturing, @unchecked Sendable {
var onDevicesChanged: (@Sendable () -> Void)?
var onFailure: (@Sendable (AudioEngineError) -> Void)?
Expand Down
Loading
Loading