diff --git a/Sources/Meeting/MeetingSessionController.swift b/Sources/Meeting/MeetingSessionController.swift index 7b1b3bb5..3d852acd 100644 --- a/Sources/Meeting/MeetingSessionController.swift +++ b/Sources/Meeting/MeetingSessionController.swift @@ -28,6 +28,8 @@ import TranscriptedCore @available(macOS 14.0, *) @MainActor final class MeetingSessionController: ObservableObject { + static var runtimeDiagnosticsRecorder: RuntimeDiagnostics? + enum StartTrigger: String { case hotkey = "hotkey" case menu = "menu" @@ -290,6 +292,7 @@ final class MeetingSessionController: ObservableObject { /// the older transcript continues in the background. @discardableResult func startRecording(trigger: StartTrigger = .unknown) async -> Bool { + Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "start_requested") DiagnosticsTrail.record( engine: "meeting", event: "meeting_start_requested", @@ -329,10 +332,12 @@ final class MeetingSessionController: ObservableObject { startDecision.errorMessage ?? "Turn on the required permissions in System Settings before recording a meeting." ) + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "start_blocked_permission") return false } guard await ensureModelsReadyForRecording(trigger: trigger) else { + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "models_unavailable") return false } @@ -359,11 +364,13 @@ final class MeetingSessionController: ObservableObject { properties: failureProperties ) state = .error(failureMessage) + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "start_failed") return false } activeRecordingTrigger = trigger state = .recording + Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "recording") let pipelineSnapshot = capture.pipelineDiagnosticsSnapshot() DiagnosticsTrail.record( engine: "meeting", @@ -495,6 +502,7 @@ final class MeetingSessionController: ObservableObject { guard !isFinishingRecording else { return } isFinishingRecording = true defer { isFinishingRecording = false } + Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "stop_requested") _ = audioInactivityDetector.stopRecording() audioInactivityWarning = nil @@ -524,6 +532,7 @@ final class MeetingSessionController: ObservableObject { let durationMs = Int(finalRecordingDuration * 1000) activeRecordingTrigger = .unknown state = .transcribing + Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "transcribing") DiagnosticsTrail.record( level: finalSystemAudioStatus.isWarning ? .warning : .info, @@ -601,6 +610,15 @@ final class MeetingSessionController: ObservableObject { // from Settings → Meetings, where the pipeline will either succeed // on a now-finalized file or fail cleanly. if stopResult.didTimeOut { + Self.runtimeDiagnosticsRecorder?.recordStall( + kind: "meeting", + stage: "recording_stop_timeout", + durationSeconds: finalRecordingDuration, + extra: [ + "trigger": recordingTrigger.rawValue, + "reason": reason.rawValue + ] + ) failedManager.addFailedTranscription( micAudioURL: micURL, systemAudioURL: files.systemURL, @@ -614,6 +632,7 @@ final class MeetingSessionController: ObservableObject { context: baseDiagnosticsContext(extra: ["reason": reason.rawValue]) ) state = .error("Recording didn't close cleanly. Open Settings → Meetings to retry.") + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "stop_timeout") return } @@ -713,6 +732,7 @@ final class MeetingSessionController: ObservableObject { activeRecordingTrigger = .unknown restoreStateAfterRecordingEndedWithoutNewWork() AppSoundPlayer.shared.play(.dictationCancelled) + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "cancelled") DiagnosticsTrail.record( engine: "meeting", @@ -769,6 +789,7 @@ final class MeetingSessionController: ObservableObject { message: "Imported meeting transcription requested", context: baseDiagnosticsContext(extra: ["trigger": StartTrigger.fileImport.rawValue]) ) + Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "file_import_requested") if case .idle = state { await prepareModels() @@ -802,6 +823,7 @@ final class MeetingSessionController: ObservableObject { context: baseDiagnosticsContext(extra: ["error": error.localizedDescription]) ) state = .error(error.localizedDescription) + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "file_import_failed") return false } @@ -833,6 +855,7 @@ final class MeetingSessionController: ObservableObject { "queue_depth_bucket": AnalyticsReporter.queueDepthBucket(queuedTranscriptionJobs.count), ] ) + Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "transcribing") return true } @@ -866,6 +889,7 @@ final class MeetingSessionController: ObservableObject { message: "Meeting transcription cancelled", context: baseDiagnosticsContext(extra: ["reason": reason.rawValue]) ) + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "transcription_cancelled") } func prepareForTermination() async { @@ -1348,6 +1372,7 @@ final class MeetingSessionController: ObservableObject { uniquingKeysWith: { _, new in new } ) ) + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "transcript_saved") AppSoundPlayer.shared.play(.meetingTranscriptComplete) case .failed(let message): lastTerminalTranscriptionOutcome = .failed(message) @@ -1376,6 +1401,7 @@ final class MeetingSessionController: ObservableObject { uniquingKeysWith: { _, new in new } ) ) + Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "transcript_failed") finalizeBackgroundTranscriptionStateIfNeeded() case .gettingReady: if previousStatus.diagnosticName != status.diagnosticName { diff --git a/Sources/Observability/AnalyticsEventPolicy.swift b/Sources/Observability/AnalyticsEventPolicy.swift index 7a1fd69b..81b80037 100644 --- a/Sources/Observability/AnalyticsEventPolicy.swift +++ b/Sources/Observability/AnalyticsEventPolicy.swift @@ -32,11 +32,67 @@ struct AnalyticsEventPolicy: Equatable { "voice_processing_active", ] + private static let dictationRouteDiagnosticProperties: Set = [ + "default_input_class", + "default_output_class", + "format_ready", + "hfp_suspected", + "input_channels", + "input_device_class", + "input_rate_hz", + "output_channels", + "output_device_class", + "output_rate_hz", + "recovery_latency_bucket", + "recovering", + "route_shape", + "sample_flow_started", + "selection_overrode_default", + "selection_reason", + "selected_input_class", + "was_recording", + ] + + private static let runtimeDiagnosticProperties: Set = [ + "app_version", + "build_version", + "duration_bucket", + "format_ready", + "heartbeat_age_bucket", + "last_event", + "os_major", + "previous_clean_shutdown", + "reason", + "recovering", + "session_active", + "session_kind", + "session_stage", + "stall_kind", + "stall_stage", + "trigger", + ] + private static let allowedPolicies: [String: AnalyticsEventPolicy] = [ "app_launched": .init( name: "app_launched", allowedProperties: [] ), + "app_unclean_shutdown_detected": .init( + name: "app_unclean_shutdown_detected", + allowedProperties: runtimeDiagnosticProperties + ), + "app_session_stall_detected": .init( + name: "app_session_stall_detected", + allowedProperties: runtimeDiagnosticProperties + ), + "support_diagnostics_copied": .init( + name: "support_diagnostics_copied", + allowedProperties: [] + ), + "support_diagnostic_event_sent": .init( + name: "support_diagnostic_event_sent", + allowedProperties: [] + ), "onboarding_shown": .init( name: "onboarding_shown", allowedProperties: [ @@ -297,40 +353,54 @@ struct AnalyticsEventPolicy: Equatable { ), "dictation_started": .init( name: "dictation_started", - allowedProperties: [ + allowedProperties: dictationRouteDiagnosticProperties.union(Set([ "trigger", - ] + ])) ), "dictation_start_failed": .init( name: "dictation_start_failed", - allowedProperties: [ + allowedProperties: dictationRouteDiagnosticProperties.union(Set([ "failure_kind", "trigger", - ] + ])) ), "dictation_completed": .init( name: "dictation_completed", - allowedProperties: [ + allowedProperties: dictationRouteDiagnosticProperties.union(Set([ "auto_send", "delivery", "duration_bucket", "trigger", "word_count_bucket", - ] + ])) ), "dictation_cancelled": .init( name: "dictation_cancelled", - allowedProperties: [ + allowedProperties: dictationRouteDiagnosticProperties.union(Set([ "duration_bucket", "trigger", - ] + ])) ), "dictation_no_speech": .init( name: "dictation_no_speech", - allowedProperties: [ + allowedProperties: dictationRouteDiagnosticProperties.union(Set([ "duration_bucket", "trigger", - ] + ])) + ), + "dictation_audio_route_changed": .init( + name: "dictation_audio_route_changed", + allowedProperties: dictationRouteDiagnosticProperties + ), + "dictation_audio_route_recovery_finished": .init( + name: "dictation_audio_route_recovery_finished", + allowedProperties: dictationRouteDiagnosticProperties.union(Set([ + "outcome", + ])) + ), + "dictation_audio_route_recovery_timeout": .init( + name: "dictation_audio_route_recovery_timeout", + allowedProperties: dictationRouteDiagnosticProperties ), "meeting_recording_started": .init( name: "meeting_recording_started", diff --git a/Sources/Observability/CLAUDE.md b/Sources/Observability/CLAUDE.md index 82de2dca..3021526e 100644 --- a/Sources/Observability/CLAUDE.md +++ b/Sources/Observability/CLAUDE.md @@ -12,6 +12,8 @@ anonymous analytics, and Sparkle update plumbing. - `JSONLWriter.swift` — shared append-only JSONL writer that reuses file handles and falls back cleanly if log files are rotated or recreated - `LockedFileAppender.swift` — cross-process-safe file append helper that serializes writes and uses `flock` so concurrent JSONL/debug-log writers do not interleave records - `DiagnosticsTrail.swift` — lightweight high-signal diagnostics helper +- `RuntimeDiagnostics.swift` — app runtime heartbeat, dirty-shutdown detection, and active session stage tracking for force quits / silent exits +- `RuntimeDiagnosticsStore.swift` — JSON marker persistence and privacy-safe dirty-shutdown context builder - `CrashReporter.swift` — crash reporting setup - `CrashReportingPreferences.swift` — Settings-backed crash reporting preference - `AnalyticsReporter.swift` — privacy-first anonymous usage analytics to PostHog @@ -36,6 +38,7 @@ anonymous analytics, and Sparkle update plumbing. - `SentryRuntimeConfiguration` rejects non-HTTPS DSNs, so insecure local overrides fail closed instead of downgrading crash transport - PostHog config is read from `Info.plist` (`TranscriptedPostHogAPIKey`, `TranscriptedPostHogHost`) or process environment (`POSTHOG_API_KEY`, `POSTHOG_HOST`), and anonymous analytics must stay event-allowlisted and bucketed rather than sending raw payloads - Non-fatal error forwarding to Sentry is allowlisted. New `.error` events should not automatically assume they are safe to send off-device. +- `RuntimeDiagnostics` writes only coarse runtime state under app-owned state. Keep it free of transcript text, raw audio, file paths, device names, meeting titles, and speaker names. - Update telemetry should keep using `UpdateFailureKind` instead of ad hoc string parsing so dashboards stay stable across Sparkle error wording changes. ## Verification @@ -57,6 +60,7 @@ Relevant direct coverage: - `Tests/SentryPayloadSanitizerTests.swift` - `Tests/SentryRuntimeConfigurationTests.swift` - `Tests/ObservabilityLogWriterTests.swift` +- `Tests/RuntimeDiagnosticsStoreTests.swift` - `Tests/UpdateFailureKindTests.swift` Useful files while testing: diff --git a/Sources/Observability/CrashReporter.swift b/Sources/Observability/CrashReporter.swift index bce8f9ec..0d1ac1ca 100644 --- a/Sources/Observability/CrashReporter.swift +++ b/Sources/Observability/CrashReporter.swift @@ -74,6 +74,22 @@ final class CrashReporter { ) } + @discardableResult + func captureSupportDiagnosticEvent(extra: [String: String]) -> String? { + captureMessageEvent( + level: .warning, + title: "support_diagnostic_event", + message: "Manual support diagnostic event from Transcripted Settings", + tags: [ + "source": "support_diagnostics", + "engine": "support", + "event": "diagnostic_event", + ], + extra: extra, + fingerprint: ["support", "diagnostic_event"] + ) + } + func captureObservabilityEvent( level: EventLevel, engine: String, diff --git a/Sources/Observability/RuntimeDiagnostics.swift b/Sources/Observability/RuntimeDiagnostics.swift new file mode 100644 index 00000000..2d7b8531 --- /dev/null +++ b/Sources/Observability/RuntimeDiagnostics.swift @@ -0,0 +1,143 @@ +import Foundation + +@MainActor +final class RuntimeDiagnostics { + private let markerURL: URL + private var marker: RuntimeDiagnosticsMarker? + private var heartbeatTimer: Timer? + + init(markerURL: URL = RuntimeDiagnosticsStore.defaultMarkerURL()) { + self.markerURL = markerURL + } + + func start() { + guard marker == nil else { return } + + if let previous = RuntimeDiagnosticsStore.load(from: markerURL), + !previous.cleanShutdown { + let context = RuntimeDiagnosticsStore.contextForUncleanShutdown(previous: previous) + EventReporter.shared.capture( + level: .error, + engine: "app", + event: "unclean_shutdown_detected", + message: "Previous app session did not shut down cleanly", + context: context + ) + AnalyticsReporter.track("app_unclean_shutdown_detected", properties: context) + } + + marker = RuntimeDiagnosticsStore.makeLaunchMarker( + appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown", + buildVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown", + osMajor: ProcessInfo.processInfo.operatingSystemVersion.majorVersion + ) + persist(event: "app_launched") + startHeartbeatTimer() + } + + func markCleanShutdown() { + guard var marker else { return } + marker.cleanShutdown = true + marker.updatedAt = Date() + marker.lastEvent = "clean_shutdown" + self.marker = marker + RuntimeDiagnosticsStore.save(marker, to: markerURL) + heartbeatTimer?.invalidate() + heartbeatTimer = nil + } + + func recordSession(kind: String, stage: String, active: Bool = true) { + if marker == nil { + start() + } + guard marker != nil else { return } + updateMarker { marker in + marker.sessionKind = kind + marker.sessionStage = stage + marker.sessionActive = active + marker.lastEvent = "\(kind)_\(stage)" + } + } + + func clearSession(kind: String, outcome: String) { + if marker == nil { + start() + } + guard marker != nil else { return } + updateMarker { marker in + marker.sessionKind = kind + marker.sessionStage = outcome + marker.sessionActive = false + marker.lastEvent = "\(kind)_\(outcome)" + } + } + + func recordStall( + kind: String, + stage: String, + durationSeconds: Double, + extra: [String: String] = [:] + ) { + recordSession(kind: kind, stage: stage, active: true) + var context = currentAnalyticsContext() + context["duration_bucket"] = AnalyticsReporter.durationBucket(seconds: durationSeconds) + context["stall_kind"] = kind + context["stall_stage"] = stage + for (key, value) in extra { + context[key] = value + } + + EventReporter.shared.capture( + level: .error, + engine: "app", + event: "session_stall_detected", + message: "Runtime session appears stalled", + context: context + ) + AnalyticsReporter.track("app_session_stall_detected", properties: context) + } + + func currentAnalyticsContext(now: Date = Date()) -> [String: String] { + guard let marker else { + return [ + "last_event": "unknown", + "session_active": "false", + "session_kind": "none", + "session_stage": "idle", + ] + } + + return [ + "heartbeat_age_bucket": RuntimeDiagnosticsStore.heartbeatAgeBucket(previousUpdate: marker.updatedAt, now: now), + "last_event": marker.lastEvent, + "previous_clean_shutdown": "\(marker.cleanShutdown)", + "session_active": "\(marker.sessionActive)", + "session_kind": marker.sessionKind, + "session_stage": marker.sessionStage, + ] + } + + private func startHeartbeatTimer() { + heartbeatTimer?.invalidate() + heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.persist(event: "heartbeat") + } + } + } + + private func persist(event: String) { + updateMarker { marker in + marker.lastEvent = event + } + } + + private func updateMarker(_ update: (inout RuntimeDiagnosticsMarker) -> Void) { + guard var marker else { return } + update(&marker) + marker.cleanShutdown = false + marker.updatedAt = Date() + self.marker = marker + RuntimeDiagnosticsStore.save(marker, to: markerURL) + } +} diff --git a/Sources/Observability/RuntimeDiagnosticsStore.swift b/Sources/Observability/RuntimeDiagnosticsStore.swift new file mode 100644 index 00000000..4c83d512 --- /dev/null +++ b/Sources/Observability/RuntimeDiagnosticsStore.swift @@ -0,0 +1,95 @@ +import Foundation + +struct RuntimeDiagnosticsMarker: Codable, Equatable { + var launchID: String + var appVersion: String + var buildVersion: String + var osMajor: Int + var cleanShutdown: Bool + var startedAt: Date + var updatedAt: Date + var lastEvent: String + var sessionKind: String + var sessionStage: String + var sessionActive: Bool +} + +enum RuntimeDiagnosticsStore { + static let markerFileName = "runtime-diagnostics.json" + + static func defaultMarkerURL(fileManager: FileManager = .default) -> URL { + fileManager.transcriptedStateDir.appendingPathComponent(markerFileName, isDirectory: false) + } + + static func makeLaunchMarker( + launchID: String = UUID().uuidString, + appVersion: String, + buildVersion: String, + osMajor: Int, + now: Date = Date() + ) -> RuntimeDiagnosticsMarker { + RuntimeDiagnosticsMarker( + launchID: launchID, + appVersion: appVersion, + buildVersion: buildVersion, + osMajor: osMajor, + cleanShutdown: false, + startedAt: now, + updatedAt: now, + lastEvent: "app_launched", + sessionKind: "none", + sessionStage: "idle", + sessionActive: false + ) + } + + static func load(from url: URL) -> RuntimeDiagnosticsMarker? { + guard let data = try? Data(contentsOf: url) else { return nil } + return try? JSONDecoder().decode(RuntimeDiagnosticsMarker.self, from: data) + } + + static func save(_ marker: RuntimeDiagnosticsMarker, to url: URL) { + do { + try FileManager.default.createPrivateDirectory(at: url.deletingLastPathComponent()) + let data = try JSONEncoder().encode(marker) + try data.write(to: url, options: .atomic) + FileManager.default.restrictFileToOwnerOnly(at: url) + } catch { + fputs("Runtime diagnostics marker write failed: \(error.localizedDescription)\n", stderr) + } + } + + static func heartbeatAgeBucket(previousUpdate: Date, now: Date = Date()) -> String { + let age = max(0, now.timeIntervalSince(previousUpdate)) + switch age { + case ..<15: + return "lt_15s" + case ..<60: + return "15_59s" + case ..<300: + return "1_4m" + case ..<900: + return "5_14m" + case ..<3600: + return "15_59m" + default: + return "1h_plus" + } + } + + static func contextForUncleanShutdown( + previous marker: RuntimeDiagnosticsMarker, + now: Date = Date() + ) -> [String: String] { + [ + "app_version": marker.appVersion, + "build_version": marker.buildVersion, + "heartbeat_age_bucket": heartbeatAgeBucket(previousUpdate: marker.updatedAt, now: now), + "last_event": marker.lastEvent, + "os_major": "\(marker.osMajor)", + "session_active": "\(marker.sessionActive)", + "session_kind": marker.sessionKind, + "session_stage": marker.sessionStage, + ] + } +} diff --git a/Sources/Observability/SentryEventPolicy.swift b/Sources/Observability/SentryEventPolicy.swift index fc8468b8..5e12f2f5 100644 --- a/Sources/Observability/SentryEventPolicy.swift +++ b/Sources/Observability/SentryEventPolicy.swift @@ -10,6 +10,16 @@ struct SentryEventPolicy: Equatable { } private static let allowedPolicies: [String: SentryEventPolicy] = [ + "app.unclean_shutdown_detected": .init( + engine: "app", + event: "unclean_shutdown_detected", + summary: "Previous app session did not shut down cleanly." + ), + "app.session_stall_detected": .init( + engine: "app", + event: "session_stall_detected", + summary: "Transcripted detected a stalled runtime session." + ), "parakeet.model_init_failed": .init( engine: "parakeet", event: "model_init_failed", @@ -65,6 +75,16 @@ struct SentryEventPolicy: Equatable { event: "microphone_start_timeout", summary: "Dictation microphone start timed out." ), + "parakeet.device_change_recovery_timeout": .init( + engine: "parakeet", + event: "device_change_recovery_timeout", + summary: "Speech engine device-change recovery timed out." + ), + "parakeet.recording_interrupted": .init( + engine: "parakeet", + event: "recording_interrupted", + summary: "Dictation recording was interrupted by audio device recovery." + ), "meeting.meeting_start_failed": .init( engine: "meeting", event: "meeting_start_failed", diff --git a/Sources/Speech/ParakeetEngine.swift b/Sources/Speech/ParakeetEngine.swift index 13346943..1be6908b 100644 --- a/Sources/Speech/ParakeetEngine.swift +++ b/Sources/Speech/ParakeetEngine.swift @@ -421,6 +421,10 @@ class ParakeetEngine: ObservableObject { var isModelLoaded: Bool { asrManagerReady } var inputDeviceName: String { cachedInputDeviceName } + var currentAudioRouteAnalyticsContext: [String: String] { + dictationRouteAnalyticsContext(selection: Self.loadDictationInputDeviceSelection()) + } + init() { scheduleInputDeviceNameRefresh() } @@ -918,6 +922,15 @@ class ParakeetEngine: ObservableObject { cancelConfigRecoveryTimeout() let recoveryGeneration = recoveryState.beginConfigChange() publishRecoveryState() + AnalyticsReporter.track( + "dictation_audio_route_changed", + properties: dictationRouteAnalyticsContext( + selection: Self.loadDictationInputDeviceSelection(), + extra: [ + "was_recording": "\(configChangeWasRecording)" + ] + ) + ) scheduleConfigRecoveryTimeout( generation: recoveryGeneration, wasRecording: configChangeWasRecording @@ -967,6 +980,7 @@ class ParakeetEngine: ObservableObject { let shouldRestartRecording = configChangeWasRecording configChangeWasRecording = false let myGeneration = recoveryState.generation + let recoveryStartedAt = CFAbsoluteTimeGetCurrent() configRecoveryTask = Task { @MainActor [weak self] in // Wait for CoreAudio to finish settling the new device graph. @@ -1015,6 +1029,19 @@ class ParakeetEngine: ObservableObject { guard self.recoveryState.finishRecovery(success: true, generation: myGeneration) else { return } self.cancelConfigRecoveryTimeout() self.publishRecoveryState() + AnalyticsReporter.track( + "dictation_audio_route_recovery_finished", + properties: self.dictationRouteAnalyticsContext( + outputFormat: snapshot.outputFormat, + hwFormat: snapshot.hwFormat, + selection: snapshot.selection, + extra: [ + "outcome": "success", + "recovery_latency_bucket": AnalyticsReporter.durationBucket(seconds: CFAbsoluteTimeGetCurrent() - recoveryStartedAt), + "was_recording": "\(shouldRestartRecording)" + ] + ) + ) // If we were recording, try to restart on the new device. // The watchdog (via isRecoveryAttempt=false) catches silent @@ -1043,10 +1070,18 @@ class ParakeetEngine: ObservableObject { } if !restarted { self.recordingInterrupted = true - EventReporter.shared.capture(level: .warning, engine: "parakeet", + EventReporter.shared.capture(level: .error, engine: "parakeet", event: "recording_interrupted", message: "Recording could not restart after device change within retry budget", - context: ["audio_device": self.inputDeviceName]) + context: self.dictationRouteDiagnosticsContext( + outputFormat: snapshot.outputFormat, + hwFormat: snapshot.hwFormat, + selection: snapshot.selection, + extra: [ + "audio_device": self.inputDeviceName, + "reason": "recording_restart_budget_exhausted" + ] + )) } } } catch { @@ -1054,16 +1089,39 @@ class ParakeetEngine: ObservableObject { self.cancelConfigRecoveryTimeout() self.publishRecoveryState() } + AnalyticsReporter.track( + "dictation_audio_route_recovery_finished", + properties: self.dictationRouteAnalyticsContext( + selection: Self.loadDictationInputDeviceSelection(), + extra: [ + "outcome": "failed", + "recovery_latency_bucket": AnalyticsReporter.durationBucket(seconds: CFAbsoluteTimeGetCurrent() - recoveryStartedAt), + "was_recording": "\(shouldRestartRecording)" + ] + ) + ) if shouldRestartRecording { self.recordingInterrupted = true - EventReporter.shared.capture(level: .warning, engine: "parakeet", + EventReporter.shared.capture(level: .error, engine: "parakeet", event: "recording_interrupted", message: "Recording interrupted — engine rewarm failed after device change", - context: ["audio_device": self.inputDeviceName, "error": error.localizedDescription]) + context: self.dictationRouteDiagnosticsContext( + selection: Self.loadDictationInputDeviceSelection(), + extra: [ + "audio_device": self.inputDeviceName, + "error": error.localizedDescription + ] + )) } EventReporter.shared.capture(level: .error, engine: "parakeet", event: "device_change_rewarm_failed", - message: error.localizedDescription, context: ["audio_device": self.inputDeviceName]) + message: error.localizedDescription, + context: self.dictationRouteDiagnosticsContext( + selection: Self.loadDictationInputDeviceSelection(), + extra: [ + "audio_device": self.inputDeviceName + ] + )) await self.rebuildAudioEngine(reason: "device_change_rewarm_failed") self.prewarmRetryCount = 0 self.schedulePrewarmRetry() @@ -1080,29 +1138,47 @@ class ParakeetEngine: ObservableObject { self.configRecoveryTimeoutTask = nil self.publishRecoveryState() + AnalyticsReporter.track( + "dictation_audio_route_recovery_timeout", + properties: self.dictationRouteAnalyticsContext( + selection: Self.loadDictationInputDeviceSelection(), + extra: [ + "recovery_latency_bucket": AnalyticsReporter.durationBucket( + seconds: Double(TranscriptedConstants.audioDeviceRecoveryTimeout) / 1_000_000_000 + ), + "was_recording": "\(wasRecording)" + ] + ) + ) EventReporter.shared.capture( - level: .warning, + level: .error, engine: "parakeet", event: "device_change_recovery_timeout", message: "Audio device recovery timed out", - context: [ - "recovery_generation": "\(generation)", - "timeout_ms": "\(TranscriptedConstants.audioDeviceRecoveryTimeout / 1_000_000)", - "was_recording": "\(wasRecording)", - "audio_device": self.inputDeviceName - ] + context: self.dictationRouteDiagnosticsContext( + selection: Self.loadDictationInputDeviceSelection(), + extra: [ + "recovery_generation": "\(generation)", + "timeout_ms": "\(TranscriptedConstants.audioDeviceRecoveryTimeout / 1_000_000)", + "was_recording": "\(wasRecording)", + "audio_device": self.inputDeviceName + ] + ) ) if wasRecording { self.recordingInterrupted = true EventReporter.shared.capture( - level: .warning, + level: .error, engine: "parakeet", event: "recording_interrupted", message: "Recording interrupted because audio device recovery timed out", - context: [ - "audio_device": self.inputDeviceName, - "reason": "device_change_recovery_timeout" - ] + context: self.dictationRouteDiagnosticsContext( + selection: Self.loadDictationInputDeviceSelection(), + extra: [ + "audio_device": self.inputDeviceName, + "reason": "device_change_recovery_timeout" + ] + ) ) } await self.rebuildAudioEngine(reason: "device_change_recovery_timeout") @@ -1215,6 +1291,82 @@ class ParakeetEngine: ObservableObject { return context } + private func dictationRouteDiagnosticsContext( + outputFormat: ParakeetAudioFormatSummary? = nil, + hwFormat: ParakeetAudioFormatSummary? = nil, + selection: DictationInputDeviceSelection?, + extra: [String: String] = [:] + ) -> [String: String] { + var context = dictationRouteAnalyticsContext( + outputFormat: outputFormat, + hwFormat: hwFormat, + selection: selection + ) + context["recovering"] = "\(recoveryState.isRecovering)" + context["format_ready"] = "\(recoveryState.inputFormatReady)" + context["generation"] = "\(recoveryState.generation)" + + for (key, value) in extra { + context[key] = value + } + + return context + } + + private func dictationRouteAnalyticsContext( + outputFormat: ParakeetAudioFormatSummary? = nil, + hwFormat: ParakeetAudioFormatSummary? = nil, + selection: DictationInputDeviceSelection?, + extra: [String: String] = [:] + ) -> [String: String] { + let selectedClass = selectedInputClass(for: selection) + let defaultInputClass = selection.map { DictationInputDeviceSelectionPolicy.deviceClass(for: $0.defaultInput) } ?? "unknown" + let defaultOutputClass = selection?.defaultOutput.map { DictationInputDeviceSelectionPolicy.deviceClass(for: $0) } ?? "unknown" + let outputRate = outputFormat?.sampleRate + let inputRate = hwFormat?.sampleRate + + var context: [String: String] = [ + "default_input_class": defaultInputClass, + "default_output_class": defaultOutputClass, + "format_ready": "\(recoveryState.inputFormatReady)", + "hfp_suspected": "\(isLikelyBluetoothHandsFreeProfile(inputClass: selectedClass, inputRate: inputRate, outputRate: outputRate))", + "input_device_class": selectedClass, + "output_device_class": defaultOutputClass, + "recovering": "\(recoveryState.isRecovering)", + "route_shape": "\(selectedClass)_input_to_\(defaultOutputClass)_output", + "sample_flow_started": "\(didReceiveAudioSamples)", + "selection_overrode_default": "\(selection?.didOverrideDefault ?? false)", + "selection_reason": selection?.reason.rawValue ?? "unknown", + "selected_input_class": selectedClass, + ] + + if let outputFormat { + context["output_rate_hz"] = String(format: "%.0f", outputFormat.sampleRate) + context["output_channels"] = "\(outputFormat.channelCount)" + } + + if let hwFormat { + context["input_rate_hz"] = String(format: "%.0f", hwFormat.sampleRate) + context["input_channels"] = "\(hwFormat.channelCount)" + } + + for (key, value) in extra { + context[key] = value + } + + return context + } + + private func isLikelyBluetoothHandsFreeProfile( + inputClass: String, + inputRate: Double?, + outputRate: Double? + ) -> Bool { + guard inputClass == "bluetooth" else { return false } + guard let inputRate, let outputRate else { return false } + return inputRate <= 24_000 && outputRate >= 44_100 + } + private func audioInputSnapshot(operation: String) async throws -> ParakeetAudioInputSnapshot { let selection = Self.loadDictationInputDeviceSelection() if let selection, selection.didOverrideDefault { diff --git a/Sources/Speech/STTRouter.swift b/Sources/Speech/STTRouter.swift index 7e70ba58..694c1aad 100644 --- a/Sources/Speech/STTRouter.swift +++ b/Sources/Speech/STTRouter.swift @@ -28,6 +28,9 @@ class STTRouter: ObservableObject { } var inputDeviceName: String { parakeetEngine.inputDeviceName } + var dictationAudioRouteAnalyticsContext: [String: String] { + parakeetEngine.currentAudioRouteAnalyticsContext + } init() { parakeetEngine.$isRecording.assign(to: &$isRecording) diff --git a/Sources/TranscriptedApp.swift b/Sources/TranscriptedApp.swift index c55ae7aa..1c43a403 100644 --- a/Sources/TranscriptedApp.swift +++ b/Sources/TranscriptedApp.swift @@ -37,7 +37,15 @@ class TranscriptedAppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegat checkForUpdates: { [weak self] in self?.appState.sparkleUpdater.checkForUpdates() }, sendFeedback: { [weak self] in guard let self else { return } - TranscriptedSupportActions.sendFeedback(logger: self.appState.logger) + TranscriptedSupportActions.sendFeedback(appState: self.appState) + }, + copyDiagnostics: { [weak self] in + guard let self else { return false } + return TranscriptedSupportActions.copyDiagnostics(appState: self.appState) + }, + sendDiagnosticEvent: { [weak self] in + guard let self else { return nil } + return TranscriptedSupportActions.sendDiagnosticEvent(appState: self.appState) } ) private lazy var settingsWindowController = TranscriptedSettingsWindowController( diff --git a/Sources/TranscriptedAppState.swift b/Sources/TranscriptedAppState.swift index de0a90d2..3c5858ea 100644 --- a/Sources/TranscriptedAppState.swift +++ b/Sources/TranscriptedAppState.swift @@ -12,6 +12,7 @@ class TranscriptedAppState: ObservableObject { let sparkleUpdater = SparkleUpdaterController() let contextCapture = ContextCaptureEngine() let sttRouter = STTRouter() + let runtimeDiagnostics = RuntimeDiagnostics() /// Meeting-mode pipeline (Lane B). Lazily instantiated so unit tests that /// don't exercise the meeting feature don't pay the construction cost. @@ -79,6 +80,10 @@ class TranscriptedAppState: ObservableObject { logger.log("APP LAUNCHED | modes: dictation + meetings") AnalyticsReporter.track("app_launched") + runtimeDiagnostics.start() + if #available(macOS 14.0, *) { + MeetingSessionController.runtimeDiagnosticsRecorder = runtimeDiagnostics + } // Wire EventReporter with live engine state for context enrichment EventReporter.shared.setEngineStateSummary { [weak self] in @@ -143,6 +148,10 @@ class TranscriptedAppState: ObservableObject { NotificationCenter.default.removeObserver(observer) promptsObserver = nil } + if #available(macOS 14.0, *) { + MeetingSessionController.runtimeDiagnosticsRecorder = nil + } + runtimeDiagnostics.markCleanShutdown() } private func startRuntimeReadinessIfNeeded() { diff --git a/Sources/UI/CLAUDE.md b/Sources/UI/CLAUDE.md index 883abd71..3a4f6a41 100644 --- a/Sources/UI/CLAUDE.md +++ b/Sources/UI/CLAUDE.md @@ -75,10 +75,10 @@ The current agent-connect surfaces should keep one simple mental model: - `Settings/SpeakerNamingSheet.swift` — sheet for reviewing speakers in a completed meeting, grouped into local room speakers vs remote participants, with a "Keep as You" escape hatch for local mic splits - `Settings/SpeakerPeopleSettingsSection.swift` — settings section and view model for browsing, naming, merging, previewing, and deleting saved speaker profiles, plus the toggle for identifying multiple local speakers on the mic track - `Settings/TranscriptedOnboardingWindowController.swift` — dedicated first-launch window that hosts onboarding before users drop into the menubar flow -- `Settings/TranscriptedSettingsActions.swift` — struct of callbacks (start dictation, start meeting, import audio, paste, connect agent, check updates, send feedback) injected into the settings view +- `Settings/TranscriptedSettingsActions.swift` — struct of callbacks (start dictation, start meeting, import audio, paste, connect agent, check updates, send feedback, copy/send diagnostics) injected into the settings view - `Settings/TranscriptedSettingsComponents.swift` — shared SwiftUI building blocks (`SettingsPageIntro`, `SettingsSection`) used across settings pages - `Settings/TranscriptedSettingsNavigationModel.swift` — observable navigation state for the current `TranscriptedSettingsPage` selection -- `Settings/TranscriptedSettingsPage.swift` — enum of settings pages (home, general, models, shortcuts, meetings, dictations, people, storage, connectAgent, privacy, about) with titles, summaries, and SF Symbol names +- `Settings/TranscriptedSettingsPage.swift` — enum of settings pages (home, general, models, shortcuts, meetings, dictations, people, storage, connectAgent, privacy, support, about) with titles, summaries, and SF Symbol names - `Settings/TranscriptedSettingsView.swift` — main settings view - `Settings/TranscriptedSettingsWindowController.swift` — NSWindowController for settings @@ -86,13 +86,14 @@ The current agent-connect surfaces should keep one simple mental model: - `Shared/AgentConnectionGuide.swift` — shared smart-prompt, MCP setup, and folder fallback copy for the agent-connect flow - `Shared/AppSoundPlayer.swift` — UI sound preferences and playback helpers -- `Shared/FeedbackIssueBuilder.swift` — builds sanitized feedback issue payloads and support links from current app state +- `Shared/FeedbackIssueBuilder.swift` — builds sanitized support email payloads and links from current app state - `Shared/FirstRunExperience.swift` — shared first-run menu and onboarding state helpers for permission, local-model, dictation, and meeting CTA copy - `Shared/MeetingAudioArchiveResolver.swift` — resolves retained meeting-audio attachments that belong to a saved transcript for review playback - `Shared/MeetingAudioPlayback.swift` — shared play/pause/resume `NSSound`-backed controller for recent-meeting audio previews in Settings - `Shared/RecentCaptureScanners.swift` — `RecentMeetingsScanner` that loads recent meeting transcripts plus retained audio attachments for the Settings meetings page - `Shared/SpeakerClipPlayback.swift` — reusable audio-preview helper for persisted speaker sample clips -- `Shared/TranscriptedSupportActions.swift` — "Send feedback" flow that builds a GitHub-issue URL seeded with sanitized recent log lines +- `Shared/SupportDiagnosticsBundle.swift` — privacy-safe support summary used for copied diagnostics and manual diagnostic events +- `Shared/TranscriptedSupportActions.swift` — support flows for feedback, copied diagnostics, and manually queued diagnostic events Cross-cutting permission checks now live in `Sources/Support/TranscriptedPermissionAccess.swift` so the meeting prompt detector and the settings/onboarding flows share the same diff --git a/Sources/UI/MenuBar/MenuBarPanelController.swift b/Sources/UI/MenuBar/MenuBarPanelController.swift index 36f19f83..ed2766d2 100644 --- a/Sources/UI/MenuBar/MenuBarPanelController.swift +++ b/Sources/UI/MenuBar/MenuBarPanelController.swift @@ -45,6 +45,7 @@ final class MenuBarPanelController: NSViewController { content.utilityActionsView.onOpenSettings = { [weak self] in self?.openSettingsFromMenu(.home) } content.utilityActionsView.onCheckForUpdates = { [weak self] in self?.performUpdateActionFromMenu() } content.utilityActionsView.onOpenConnectAgent = { [weak self] in self?.openSettingsFromMenu(.connectAgent) } + content.utilityActionsView.onOpenSupport = { [weak self] in self?.openSettingsFromMenu(.support) } content.onUpdateAction = { [weak self] in self?.performUpdateActionFromMenu() } view = content contentView = content diff --git a/Sources/UI/MenuBar/MenuBarSettingsView.swift b/Sources/UI/MenuBar/MenuBarSettingsView.swift index 2fc1c043..b64bd93e 100644 --- a/Sources/UI/MenuBar/MenuBarSettingsView.swift +++ b/Sources/UI/MenuBar/MenuBarSettingsView.swift @@ -24,8 +24,8 @@ final class MenuBarSettingsView: NSView { ) private let feedbackButton = MenuIconButton( symbolName: "bubble.left", - accessibilityLabel: "Send feedback", - toolTip: "Send feedback" + accessibilityLabel: "Submit feedback", + toolTip: "Submit feedback" ) private let quitButton = MenuIconButton( symbolName: "power", @@ -37,6 +37,7 @@ final class MenuBarSettingsView: NSView { var onOpenSettings: (() -> Void)? var onCheckForUpdates: (() -> Void)? var onOpenAgentConnect: (() -> Void)? + var onOpenSupport: (() -> Void)? override init(frame: NSRect) { super.init(frame: frame) @@ -60,7 +61,7 @@ final class MenuBarSettingsView: NSView { updatesButton.target = self updatesButton.action = #selector(checkForUpdates) feedbackButton.target = self - feedbackButton.action = #selector(sendFeedback) + feedbackButton.action = #selector(openSupport) quitButton.target = self quitButton.action = #selector(quitApp) } @@ -94,10 +95,6 @@ final class MenuBarSettingsView: NSView { connectAgentButton.frame = NSRect(x: 0, y: buttonY, width: connectWidth, height: buttonSize) } - @objc private func sendFeedback() { - TranscriptedSupportActions.sendFeedback(logger: appState?.logger) - } - @objc private func quitApp() { NSApplication.shared.terminate(nil) } @@ -114,6 +111,10 @@ final class MenuBarSettingsView: NSView { onOpenAgentConnect?() } + @objc private func openSupport() { + onOpenSupport?() + } + func dismissTransientUI() {} var intrinsicHeight: CGFloat { MenuTokens.secondaryButtonSize + 8 } diff --git a/Sources/UI/MenuBar/MenuBarUtilityActionsView.swift b/Sources/UI/MenuBar/MenuBarUtilityActionsView.swift index 7671c06f..1b4e872f 100644 --- a/Sources/UI/MenuBar/MenuBarUtilityActionsView.swift +++ b/Sources/UI/MenuBar/MenuBarUtilityActionsView.swift @@ -8,6 +8,7 @@ final class MenuBarUtilityActionsView: NSView { var onOpenConnectAgent: (() -> Void)? var onCheckForUpdates: (() -> Void)? var onOpenSettings: (() -> Void)? + var onOpenSupport: (() -> Void)? private let connectAgentRow = MenuBarActionRowView() private let feedbackRow = MenuBarActionRowView() @@ -28,8 +29,7 @@ final class MenuBarUtilityActionsView: NSView { private func setupViews() { connectAgentRow.onPress = { [weak self] in self?.onOpenConnectAgent?() } feedbackRow.onPress = { [weak self] in - self?.trackMenuAction("submit_feedback") - self?.sendFeedback() + self?.onOpenSupport?() } updatesRow.onPress = { [weak self] in self?.onCheckForUpdates?() } settingsRow.onPress = { [weak self] in self?.onOpenSettings?() } @@ -59,7 +59,7 @@ final class MenuBarUtilityActionsView: NSView { feedbackRow.update( symbolName: "bubble.left", - title: "Submit Feedback", + title: "Submit feedback", detail: "", tone: .standard, size: .utility @@ -112,10 +112,6 @@ final class MenuBarUtilityActionsView: NSView { } } - private func sendFeedback() { - TranscriptedSupportActions.sendFeedback(logger: appState?.logger) - } - private func trackMenuAction(_ actionID: String) { AnalyticsReporter.track( "menu_bar_action_clicked", diff --git a/Sources/UI/Overlay/DictationSessionController.swift b/Sources/UI/Overlay/DictationSessionController.swift index 2bd33cd3..a3db7c2f 100644 --- a/Sources/UI/Overlay/DictationSessionController.swift +++ b/Sources/UI/Overlay/DictationSessionController.swift @@ -124,6 +124,7 @@ class DictationSessionController: ObservableObject { sessionStartTime = CFAbsoluteTimeGetCurrent() currentDictationTrigger = trigger lastCompletedText = nil + appState.runtimeDiagnostics.recordSession(kind: "dictation", stage: "start_requested") switch TranscriptedPermissionAccess.microphoneAuthorizationStatus() { case .authorized: @@ -189,19 +190,23 @@ class DictationSessionController: ObservableObject { ) AnalyticsReporter.track( "dictation_started", - properties: [ - "trigger": trigger.rawValue, - ] + properties: dictationAnalyticsProperties( + extra: [ + "trigger": trigger.rawValue, + ] + ) ) } private func trackDictationStartFailed(_ failureKind: String) { AnalyticsReporter.track( "dictation_start_failed", - properties: [ - "failure_kind": failureKind, - "trigger": currentDictationTrigger.rawValue, - ] + properties: dictationAnalyticsProperties( + extra: [ + "failure_kind": failureKind, + "trigger": currentDictationTrigger.rawValue, + ] + ) ) } @@ -265,6 +270,7 @@ class DictationSessionController: ObservableObject { self.recordingStartRetryTask = nil overlayController.state = .listening self.resizePanelToCompact() + appState.runtimeDiagnostics.recordSession(kind: "dictation", stage: "recording") appState.logger.log("DICTATION | started (parakeet, \(appState.sttRouter.inputDeviceName))") AppSoundPlayer.shared.play(.dictationStart) self.installSessionTimeout() @@ -346,6 +352,7 @@ class DictationSessionController: ObservableObject { if started { overlayController.state = .listening resizePanelToCompact() + appState.runtimeDiagnostics.recordSession(kind: "dictation", stage: "recording_after_wait") let waited = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000) appState.logger.log("DICTATION | started after \(waited)ms wait (parakeet, \(appState.sttRouter.inputDeviceName))") DiagnosticsTrail.record( @@ -413,6 +420,16 @@ class DictationSessionController: ObservableObject { ) ) trackDictationStartFailed("microphone_start_timeout") + appState.runtimeDiagnostics.recordStall( + kind: "dictation", + stage: "microphone_start_timeout", + durationSeconds: TranscriptedConstants.dictationRecoveryBudget, + extra: [ + "format_ready": "\(appState.sttRouter.inputFormatReady)", + "recovering": "\(appState.sttRouter.isRecovering)" + ] + ) + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "microphone_start_timeout") isDictating = false overlayController.showError( microphoneTimeoutMessage( @@ -448,6 +465,7 @@ class DictationSessionController: ObservableObject { ) ) trackDictationStartFailed(dictationStartFailureKind(for: status)) + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "start_failed") if !overlayController.isVisible { overlayController.showPanel(near: sourceApp, anchorRect: sessionAnchorRect) } @@ -553,6 +571,7 @@ class DictationSessionController: ObservableObject { streamingTask?.cancel() streamingTask = Task { + appState.runtimeDiagnostics.recordSession(kind: "dictation", stage: "stop_requested") await appState.sttRouter.stopRecording() // Surface model warmup honestly instead of calling it "Transcribing" @@ -570,30 +589,45 @@ class DictationSessionController: ObservableObject { appState.logger.log("DICTATION | voice model failed to load for transcription") overlayController.showError("Voice model failed to load") isDictating = false + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "model_unavailable") return } } overlayController.state = .drafting + appState.runtimeDiagnostics.recordSession(kind: "dictation", stage: "transcribing") let voiceText = await appState.sttRouter.transcribe() guard !Task.isCancelled else { return } guard let text = voiceText, !text.isEmpty else { appState.logger.log("DICTATION | no transcription, cancelling") - EventReporter.shared.capture(level: .warning, engine: "overlay", event: "no_voice_input", - message: "Dictation transcription empty") + EventReporter.shared.capture( + level: .warning, + engine: "overlay", + event: "no_voice_input", + message: "Dictation transcription empty", + context: self.dictationContext( + extra: [ + "duration_ms": "\(Int((CFAbsoluteTimeGetCurrent() - self.sessionStartTime) * 1000))", + "trigger": self.currentDictationTrigger.rawValue + ] + ) + ) AnalyticsReporter.track( "dictation_no_speech", - properties: [ - "duration_bucket": AnalyticsReporter.durationBucket( - seconds: CFAbsoluteTimeGetCurrent() - sessionStartTime - ), - "trigger": currentDictationTrigger.rawValue, - ] + properties: self.dictationAnalyticsProperties( + extra: [ + "duration_bucket": AnalyticsReporter.durationBucket( + seconds: CFAbsoluteTimeGetCurrent() - sessionStartTime + ), + "trigger": currentDictationTrigger.rawValue, + ] + ) ) NotificationCenter.default.post(name: .dictationNoSpeechDetected, object: nil) AppSoundPlayer.shared.play(.noSpeech) overlayController.showNoSpeechAndDismiss() isDictating = false + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "no_speech") return } @@ -649,14 +683,17 @@ class DictationSessionController: ObservableObject { } AnalyticsReporter.track( "dictation_completed", - properties: [ - "delivery": pasteOutcome.delivery.rawValue, - "auto_send": autoSendOutcome.diagnosticName, - "duration_bucket": AnalyticsReporter.durationBucket(seconds: CFAbsoluteTimeGetCurrent() - sessionStartTime), - "trigger": currentDictationTrigger.rawValue, - "word_count_bucket": AnalyticsReporter.wordCountBucket(wordCount), - ] + properties: self.dictationAnalyticsProperties( + extra: [ + "delivery": pasteOutcome.delivery.rawValue, + "auto_send": autoSendOutcome.diagnosticName, + "duration_bucket": AnalyticsReporter.durationBucket(seconds: CFAbsoluteTimeGetCurrent() - sessionStartTime), + "trigger": currentDictationTrigger.rawValue, + "word_count_bucket": AnalyticsReporter.wordCountBucket(wordCount), + ] + ) ) + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "completed") } } @@ -667,6 +704,7 @@ class DictationSessionController: ObservableObject { AppSoundPlayer.shared.play(.dictationCancelled) overlayController.hideWithCancelAnimation() isDictating = false + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "cancelled") appState.logger.log("DICTATION | cancelled") DiagnosticsTrail.record( logger: appState.logger, @@ -686,10 +724,12 @@ class DictationSessionController: ObservableObject { } AnalyticsReporter.track( "dictation_cancelled", - properties: [ - "duration_bucket": AnalyticsReporter.durationBucket(seconds: CFAbsoluteTimeGetCurrent() - sessionStartTime), - "trigger": currentDictationTrigger.rawValue, - ] + properties: dictationAnalyticsProperties( + extra: [ + "duration_bucket": AnalyticsReporter.durationBucket(seconds: CFAbsoluteTimeGetCurrent() - sessionStartTime), + "trigger": currentDictationTrigger.rawValue, + ] + ) ) } @@ -746,6 +786,7 @@ class DictationSessionController: ObservableObject { case .failed(let message): self.startupTask = nil self.isDictating = false + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "model_failed") overlayController.showError( "Dictation couldn't start: \(message)", actionTitle: "Retry Dictation", @@ -768,6 +809,13 @@ class DictationSessionController: ObservableObject { guard !Task.isCancelled else { return } self.startupTask = nil self.isDictating = false + appState.runtimeDiagnostics.recordStall( + kind: "dictation", + stage: "model_load_timeout", + durationSeconds: Double(TranscriptedConstants.modelLoadMaxIterations) + * Double(TranscriptedConstants.modelLoadPollInterval) / 1_000_000_000 + ) + appState.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "model_load_timeout") overlayController.showError( "Dictation is still loading. Please try again in a moment.", actionTitle: "Retry Dictation", @@ -939,6 +987,7 @@ class DictationSessionController: ObservableObject { private func handleDictationInterruption() { cancelActiveTasks(cancelRecording: true) isDictating = false + appState?.runtimeDiagnostics.clearSession(kind: "dictation", outcome: "interrupted") appState?.logger.log("DICTATION | interrupted") DiagnosticsTrail.record( logger: appState?.logger, @@ -1073,6 +1122,11 @@ class DictationSessionController: ObservableObject { var context: [String: String] = [ "audio_device": appState?.sttRouter.inputDeviceName ?? "" ] + if let routeContext = appState?.sttRouter.dictationAudioRouteAnalyticsContext { + for (key, value) in routeContext { + context[key] = value + } + } for (key, value) in extra { context[key] = value @@ -1080,6 +1134,14 @@ class DictationSessionController: ObservableObject { return context } + + private func dictationAnalyticsProperties(extra: [String: String] = [:]) -> [String: String] { + var properties = appState?.sttRouter.dictationAudioRouteAnalyticsContext ?? [:] + for (key, value) in extra { + properties[key] = value + } + return properties + } } private typealias DictationPasteOutcome = TextPasteOutcome diff --git a/Sources/UI/Settings/HomeMeetingPreviewFormatter.swift b/Sources/UI/Settings/HomeMeetingPreviewFormatter.swift new file mode 100644 index 00000000..a7ea806c --- /dev/null +++ b/Sources/UI/Settings/HomeMeetingPreviewFormatter.swift @@ -0,0 +1,153 @@ +import Foundation + +struct HomeMeetingPreviewContent { + let fallbackText: String + let transcriptLines: [HomeMeetingTranscriptLine] + + static func make(from markdown: String) -> HomeMeetingPreviewContent { + let readableLines = readableMarkdownLines(from: markdown) + return HomeMeetingPreviewContent( + fallbackText: readableLines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines), + transcriptLines: parseTranscriptLines(readableLines) + ) + } + + private static func readableMarkdownLines(from markdown: String) -> [String] { + var lines = markdown.components(separatedBy: .newlines) + + if lines.first?.trimmingCharacters(in: .whitespacesAndNewlines) == "---", + let endIndex = lines.dropFirst().firstIndex(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines) == "---" }) { + lines.removeSubrange(...endIndex) + } + + if let transcriptIndex = lines.firstIndex(where: { line in + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed == "## Full Transcript" || trimmed == "## Transcript" + }) { + lines = Array(lines.dropFirst(transcriptIndex + 1)) + } + + return lines.filter { line in + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed != "---" && !trimmed.hasPrefix("*Generated by Transcripted") + } + } + + private static func parseTranscriptLines(_ lines: [String]) -> [HomeMeetingTranscriptLine] { + var parsed: [HomeMeetingTranscriptLine] = [] + var pending: PendingTranscriptLine? + + func flushPending() { + guard let current = pending else { return } + let text = current.textParts + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { + parsed.append(HomeMeetingTranscriptLine( + time: current.time, + speaker: current.speaker, + text: text + )) + } + pending = nil + } + + for rawLine in lines { + let trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + if let marker = parseTranscriptMarker(trimmed) { + flushPending() + pending = PendingTranscriptLine( + time: marker.time, + speaker: marker.speaker, + textParts: marker.remainder.isEmpty ? [] : [marker.remainder] + ) + continue + } + + if pending != nil, !isSectionNoise(trimmed) { + pending?.textParts.append(trimmed) + } + } + + flushPending() + return parsed + } + + private static func parseTranscriptMarker(_ line: String) -> TranscriptMarker? { + if line.hasPrefix("**") { + return parseBoldTimestampMarker(line) + } + if line.hasPrefix("[") { + return parseBracketTimestampMarker(line) + } + return nil + } + + private static func parseBoldTimestampMarker(_ line: String) -> TranscriptMarker? { + let timeStart = line.index(line.startIndex, offsetBy: 2) + guard let timeEnd = line[timeStart...].range(of: "**")?.lowerBound else { return nil } + let time = String(line[timeStart.. TranscriptMarker? { + guard let timeEnd = line.firstIndex(of: "]") else { return nil } + let time = String(line[line.index(after: line.startIndex).. TranscriptMarker { + var speaker = "Speaker" + var text = remainder + + if text.hasPrefix("["), + let speakerEnd = text.firstIndex(of: "]") { + speaker = cleanSpeaker(String(text[text.index(after: text.startIndex).. String { + raw + .replacingOccurrences(of: "[[", with: "") + .replacingOccurrences(of: "]]", with: "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func looksLikeTimestamp(_ value: String) -> Bool { + let parts = value.split(separator: ":") + guard parts.count == 2 || parts.count == 3 else { return false } + return parts.allSatisfy { !$0.isEmpty && $0.allSatisfy(\.isNumber) } + } + + private static func isSectionNoise(_ line: String) -> Bool { + line.hasPrefix("#") || line.hasPrefix("Recorded ") + } +} + +struct HomeMeetingTranscriptLine: Equatable { + let time: String + let speaker: String + let text: String +} + +private struct PendingTranscriptLine { + let time: String + let speaker: String + var textParts: [String] +} + +private struct TranscriptMarker { + let time: String + let speaker: String + let remainder: String +} diff --git a/Sources/UI/Settings/HomeView.swift b/Sources/UI/Settings/HomeView.swift index ff171c28..21970b25 100644 --- a/Sources/UI/Settings/HomeView.swift +++ b/Sources/UI/Settings/HomeView.swift @@ -174,6 +174,66 @@ enum HomeActivityTab: String, CaseIterable, Identifiable { } } +enum HomeHeroMode: String, CaseIterable, Identifiable { + case meeting + case dictation + + var id: String { rawValue } + + static let tabOrder: [HomeHeroMode] = [.meeting, .dictation] + + var switchTitle: String { + switch self { + case .dictation: return "Dictation" + case .meeting: return "Meetings" + } + } + + var title: String { + switch self { + case .dictation: return "Dictate anywhere" + case .meeting: return "Record meetings" + } + } + + var subtitle: String { + switch self { + case .dictation: + return "Speak once. Clean text lands back at your cursor." + case .meeting: + return "Capture the call. Save searchable local notes." + } + } + + var actionTitle: String { + switch self { + case .dictation: return "Start dictation" + case .meeting: return "Record meeting" + } + } + + var learnTitle: String { + switch self { + case .dictation: return "Works anywhere you write." + case .meeting: return "Saved as local Markdown." + } + } + + var symbolName: String { + switch self { + case .dictation: return "mic.fill" + case .meeting: return "waveform" + } + } + + var activityTab: HomeActivityTab { + switch self { + case .dictation: return .dictations + case .meeting: return .meetings + } + } +} + // MARK: - Stats summary struct HomeStatItem: Identifiable { @@ -183,6 +243,100 @@ struct HomeStatItem: Identifiable { let label: String } +enum HomeFeedbackIssueKind: String, CaseIterable, Identifiable { + case wrongWords + case missingText + case badFormatting + case wrongSpeakers + case audioProblem + case other + + var id: String { rawValue } + + var label: String { + switch self { + case .wrongWords: return "Wrong words" + case .missingText: return "Missing text" + case .badFormatting: return "Bad formatting" + case .wrongSpeakers: return "Speaker issue" + case .audioProblem: return "Audio issue" + case .other: return "Other" + } + } +} + +struct HomeFeedbackTarget: Identifiable { + let id: String + let sourceKind: String + let title: String + let createdAt: Date + let referenceID: String + let suggestedIssue: HomeFeedbackIssueKind + + static func dictation(_ entry: SavedDictationEntry) -> HomeFeedbackTarget { + HomeFeedbackTarget( + id: "dictation-\(stableReferenceID(for: entry.id))", + sourceKind: "dictation", + title: "Dictation at \(HomeActivityRowFormatting.timeFormatter.string(from: entry.createdAt))", + createdAt: entry.createdAt, + referenceID: stableReferenceID(for: entry.id), + suggestedIssue: .wrongWords + ) + } + + static func meeting(_ item: RecentMeetingItem) -> HomeFeedbackTarget { + HomeFeedbackTarget( + id: "meeting-\(stableReferenceID(for: item.id))", + sourceKind: "meeting", + title: "Meeting at \(HomeActivityRowFormatting.timeFormatter.string(from: item.date))", + createdAt: item.date, + referenceID: stableReferenceID(for: item.id), + suggestedIssue: .wrongSpeakers + ) + } + + private static func stableReferenceID(for value: String) -> String { + var hash: UInt64 = 1_469_598_103_934_665_603 + for byte in value.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return String(hash, radix: 16) + } +} + +struct HomeFeedbackSubmission { + let target: HomeFeedbackTarget + let issueKind: HomeFeedbackIssueKind + let notes: String + let includeDiagnostics: Bool +} + +struct HomeMeetingPreview: Identifiable { + let id: String + let title: String + let date: Date + let transcriptURL: URL + let markdown: String + let readError: String? + let feedbackTarget: HomeFeedbackTarget + + init(item: RecentMeetingItem, markdown: String, readError: String? = nil) { + id = item.id + title = item.title + date = item.date + transcriptURL = item.transcriptURL + self.markdown = markdown + self.readError = readError + feedbackTarget = HomeFeedbackTarget.meeting(item) + } +} + +enum HomeMeetingMarkdownReadResult { + case success(String) + case failure(String) +} + // MARK: - Welcome header struct HomeWelcomeHeader: View { @@ -203,224 +357,300 @@ struct HomeWelcomeHeader: View { // MARK: - Hero card -struct HomeHeroCard: View { - let primaryTitle: String - let primarySubtitle: String - let primaryAction: () -> Void - let secondaryTitle: String - let secondarySubtitle: String - let secondaryAction: () -> Void +struct HomeHeroCard: View { + @Binding var selectedMode: HomeHeroMode + let onStartDictation: () -> Void + let onStartMeeting: () -> Void + private let activityContent: () -> ActivityContent + + init( + selectedMode: Binding, + onStartDictation: @escaping () -> Void, + onStartMeeting: @escaping () -> Void, + @ViewBuilder activityContent: @escaping () -> ActivityContent + ) { + _selectedMode = selectedMode + self.onStartDictation = onStartDictation + self.onStartMeeting = onStartMeeting + self.activityContent = activityContent + } var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Capture spoken work") - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(Color.primary) + VStack(alignment: .leading, spacing: 0) { + HomeHeroModeTabs(selectedMode: $selectedMode) + .padding(.leading, 36) + .padding(.bottom, -12) + .zIndex(1) - ViewThatFits(in: .horizontal) { - HStack(alignment: .top, spacing: 12) { - HomeActionChoiceCard( - title: primaryTitle, - subtitle: primarySubtitle, - symbolName: "mic.fill", - isPrimary: true, - action: primaryAction - ) + VStack(alignment: .leading, spacing: 18) { + heroCopy - HomeActionChoiceCard( - title: secondaryTitle, - subtitle: secondarySubtitle, - symbolName: "waveform", - isPrimary: false, - action: secondaryAction - ) - } - - VStack(spacing: 12) { - HomeActionChoiceCard( - title: primaryTitle, - subtitle: primarySubtitle, - symbolName: "mic.fill", - isPrimary: true, - action: primaryAction - ) + Divider() + .opacity(0.55) - HomeActionChoiceCard( - title: secondaryTitle, - subtitle: secondarySubtitle, - symbolName: "waveform", - isPrimary: false, - action: secondaryAction - ) - } + activityContent() } + .padding(.top, 30) + .padding(.horizontal, 28) + .padding(.bottom, 22) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(cardFill) + ) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) } - .padding(18) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill( - LinearGradient( - colors: [ - Color(nsColor: .controlBackgroundColor).opacity(0.94), - Color.accentColor.opacity(0.11) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - ) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.accentColor.opacity(0.22), lineWidth: 1) - ) } -} - -private struct HomeActionChoiceCard: View { - let title: String - let subtitle: String - let symbolName: String - let isPrimary: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(alignment: .leading, spacing: 14) { - HStack(alignment: .center, spacing: 10) { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(iconBackground) - Image(systemName: symbolName) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(iconForeground) - } - .frame(width: 34, height: 34) - Text(title) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Color.primary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - } + private var heroCopy: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 9) { + Text(selectedMode.title) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(Color.primary) + .fixedSize(horizontal: false, vertical: true) - Text(subtitle) - .font(.callout) + Text(selectedMode.subtitle) + .font(.system(size: 15)) .foregroundStyle(.secondary) - .lineSpacing(1) + .lineLimit(2) .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 10) { + Button(action: selectedAction) { + Label(selectedMode.actionTitle, systemImage: selectedMode.symbolName) + .font(.system(size: 13, weight: .semibold)) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .help(selectedMode.actionTitle) HStack(spacing: 6) { - Text(title) + Text(selectedMode.learnTitle) Image(systemName: "arrow.right") .font(.system(size: 11, weight: .semibold)) } - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(isPrimary ? Color.white : Color.accentColor) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background( - Capsule(style: .continuous) - .fill(isPrimary ? Color.accentColor : Color.accentColor.opacity(0.13)) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var cardFill: Color { + Color(nsColor: .controlBackgroundColor).opacity(0.82) + } + + private var selectedAction: () -> Void { + switch selectedMode { + case .dictation: return onStartDictation + case .meeting: return onStartMeeting + } + } +} + +private struct HomeHeroModeTabs: View { + @Binding var selectedMode: HomeHeroMode + + var body: some View { + HStack(alignment: .bottom, spacing: 0) { + ForEach(HomeHeroMode.tabOrder) { mode in + HomeHeroModeTab( + mode: mode, + isSelected: selectedMode == mode, + action: { + withAnimation(.easeInOut(duration: 0.16)) { + selectedMode = mode + } + } ) - .padding(.top, 2) } - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(16) + } + .background(alignment: .bottom) { + Rectangle() + .fill(surfaceFill) + .frame(height: 18) + .offset(y: 10) + .padding(.horizontal, -1) + } + } + + private var surfaceFill: Color { + Color(nsColor: .controlBackgroundColor).opacity(0.82) + } +} + +private struct HomeHeroModeTab: View { + let mode: HomeHeroMode + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 7) { + Image(systemName: mode.symbolName) + .font(.system(size: 12, weight: .semibold)) + Text(mode.switchTitle) + .font(.system(size: 13, weight: .semibold)) + } + .foregroundStyle(isSelected ? Color.primary : Color.secondary) + .padding(.horizontal, 18) + .padding(.top, isSelected ? 12 : 10) + .padding(.bottom, isSelected ? 11 : 9) .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(cardBackground) + HomeHeroTabShape(cornerRadius: 12) + .fill(tabFill) ) .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(borderColor, lineWidth: isPrimary ? 1.2 : 1) + HomeHeroTabBorderShape(cornerRadius: 12) + .stroke(tabStroke, lineWidth: 1) ) - .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay(alignment: .bottom) { + if isSelected { + Rectangle() + .fill(surfaceFill) + .frame(height: 14) + .offset(y: 8) + } + } } .buttonStyle(.plain) + .help("Show \(mode.switchTitle.lowercased())") + .zIndex(isSelected ? 1 : 0) } - private var cardBackground: Color { - if isPrimary { - return Color.accentColor.opacity(0.13) + private var tabFill: Color { + if isSelected { + return surfaceFill } - return Color(nsColor: .controlBackgroundColor).opacity(0.72) + return surfaceFill.opacity(0.62) + } + + private var tabStroke: Color { + isSelected ? Color.primary.opacity(0.08) : Color.primary.opacity(0.03) } - private var borderColor: Color { - isPrimary ? Color.accentColor.opacity(0.34) : Color.accentColor.opacity(0.2) + private var surfaceFill: Color { + Color(nsColor: .controlBackgroundColor).opacity(0.82) } +} - private var iconBackground: Color { - isPrimary ? Color.accentColor.opacity(0.2) : Color(nsColor: .textColor).opacity(0.08) +private struct HomeHeroTabShape: Shape { + let cornerRadius: CGFloat + + func path(in rect: CGRect) -> Path { + let radius = min(cornerRadius, rect.width / 2, rect.height) + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + radius)) + path.addQuadCurve( + to: CGPoint(x: rect.minX + radius, y: rect.minY), + control: CGPoint(x: rect.minX, y: rect.minY) + ) + path.addLine(to: CGPoint(x: rect.maxX - radius, y: rect.minY)) + path.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.minY + radius), + control: CGPoint(x: rect.maxX, y: rect.minY) + ) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + path.closeSubpath() + return path } +} - private var iconForeground: Color { - isPrimary ? Color.accentColor : Color.secondary +private struct HomeHeroTabBorderShape: Shape { + let cornerRadius: CGFloat + + func path(in rect: CGRect) -> Path { + let radius = min(cornerRadius, rect.width / 2, rect.height) + var path = Path() + path.move(to: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + radius)) + path.addQuadCurve( + to: CGPoint(x: rect.minX + radius, y: rect.minY), + control: CGPoint(x: rect.minX, y: rect.minY) + ) + path.addLine(to: CGPoint(x: rect.maxX - radius, y: rect.minY)) + path.addQuadCurve( + to: CGPoint(x: rect.maxX, y: rect.minY + radius), + control: CGPoint(x: rect.maxX, y: rect.minY) + ) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + return path } } // MARK: - Stats rail -struct HomeStatsRail: View { - let header: String +struct HomeStatsTopCard: View { let stats: [HomeStatItem] let streak: Int? + private let columns = [ + GridItem(.flexible(minimum: 86), spacing: 12), + GridItem(.flexible(minimum: 86), spacing: 12) + ] + var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text(header) - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - .tracking(0.6) + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Text("Overall") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.6) - VStack(alignment: .leading, spacing: 12) { + Spacer(minLength: 10) + + if let streak, streak > 0 { + HStack(spacing: 5) { + Image(systemName: "flame.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.orange) + Text("\(streak)d") + .font(.caption.weight(.semibold)) + } + .foregroundStyle(Color.primary) + } + } + + LazyVGrid(columns: columns, alignment: .leading, spacing: 10) { ForEach(stats) { stat in - HStack(alignment: .top, spacing: 10) { + HStack(spacing: 8) { ZStack { Circle() .fill(Color.primary.opacity(0.06)) Image(systemName: stat.symbolName) - .font(.system(size: 11, weight: .semibold)) + .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.secondary) } - .frame(width: 26, height: 26) - .padding(.top, 1) + .frame(width: 24, height: 24) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 1) { Text(stat.value) - .font(.system(size: 20, weight: .semibold)) + .font(.system(size: 16, weight: .semibold)) .foregroundStyle(Color.primary) + .lineLimit(1) Text(stat.label) - .font(.caption) + .font(.caption2) .foregroundStyle(.secondary) + .lineLimit(1) } } } } - - if let streak, streak > 0 { - Divider() - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Image(systemName: "flame.fill") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.orange) - Text("\(streak) day streak") - .font(.subheadline.weight(.semibold)) - } - Text(streak == 1 ? "Keep it going tomorrow." : "Nice run.") - .font(.caption) - .foregroundStyle(.secondary) - } - } } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .frame(width: 286, alignment: .leading) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor).opacity(0.78)) + .fill(Color(nsColor: .controlBackgroundColor).opacity(0.72)) ) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) @@ -429,46 +659,6 @@ struct HomeStatsRail: View { } } -// MARK: - Inline stats strip (narrow layout fallback) - -struct HomeStatsStrip: View { - let stats: [HomeStatItem] - let streak: Int? - - var body: some View { - HStack(spacing: 22) { - ForEach(stats) { stat in - VStack(alignment: .leading, spacing: 2) { - Label(stat.value, systemImage: stat.symbolName) - .font(.system(size: 18, weight: .semibold)) - Text(stat.label) - .font(.caption) - .foregroundStyle(.secondary) - } - } - if let streak, streak > 0 { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Image(systemName: "flame.fill") - .foregroundStyle(.orange) - Text("\(streak)") - .font(.system(size: 18, weight: .semibold)) - } - Text("day streak") - .font(.caption) - .foregroundStyle(.secondary) - } - } - Spacer() - } - .padding(14) - .background( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor).opacity(0.6)) - ) - } -} - // MARK: - Row action buttons struct HomeRowMenuItem: Identifiable { @@ -502,7 +692,7 @@ struct HomeRowActionButtons: View { iconButton( systemName: "flag", - help: "Send feedback", + help: "Report issue", action: onFlag ) @@ -610,6 +800,7 @@ private struct HomeActivityRowShell: View { let onCopy: () -> Void let onFlag: () -> Void let menuItems: [HomeRowMenuItem] + var compact: Bool = false @ViewBuilder let content: () -> Content @State private var isHovering = false @@ -641,7 +832,7 @@ private struct HomeActivityRowShell: View { .animation(.easeOut(duration: 0.12), value: isHovering) } .padding(.horizontal, 8) - .padding(.vertical, 9) + .padding(.vertical, compact ? 5 : 9) .background( RoundedRectangle(cornerRadius: 10, style: .continuous) .fill(isHovering ? Color.primary.opacity(0.035) : Color.clear) @@ -671,15 +862,8 @@ struct HomeDictationRow: View { Text(preview) .font(.system(size: 13)) .foregroundStyle(Color.primary) - .lineLimit(3) + .lineLimit(1) .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - - if !entry.sourceAppName.isEmpty, entry.sourceAppName != "Unknown" { - Text(entry.sourceAppName) - .font(.caption2) - .foregroundStyle(.tertiary) - } } } } @@ -706,19 +890,19 @@ struct HomeMeetingRow: View { onOpen: onOpen, onCopy: onCopy, onFlag: onFlag, - menuItems: menuItems + menuItems: menuItems, + compact: true ) { HStack(alignment: .top, spacing: 10) { Image(systemName: "person.2.wave.2.fill") - .font(.system(size: 13, weight: .semibold)) + .font(.system(size: 12, weight: .semibold)) .foregroundStyle(.secondary) .padding(.top, 2) Text(item.title) - .font(.system(size: 13, weight: .medium)) + .font(.system(size: 12.5, weight: .medium)) .foregroundStyle(Color.primary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) + .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) } } @@ -731,6 +915,8 @@ struct HomeDayGroupedList: View { let sections: [HomeDaySection] let emptyMessage: String let getID: (Item) -> AnyHashable + var sectionSpacing: CGFloat = 12 + var headerSpacing: CGFloat = 2 @ViewBuilder let row: (Item) -> Row var body: some View { @@ -744,9 +930,9 @@ struct HomeDayGroupedList: View { .padding(.vertical, 28) .padding(.horizontal, 4) } else { - LazyVStack(alignment: .leading, spacing: 18) { + LazyVStack(alignment: .leading, spacing: sectionSpacing) { ForEach(sections) { section in - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: headerSpacing) { Text(section.label) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) @@ -773,7 +959,7 @@ struct HomeDayGroupedList: View { // MARK: - Activity tabs container struct HomeActivityTabsCard: View { - @Binding var selectedTab: HomeActivityTab + let selectedTab: HomeActivityTab let dictationSections: [HomeDaySection] let meetingSections: [HomeDaySection] let isLoading: Bool @@ -793,33 +979,21 @@ struct HomeActivityTabsCard: View { let onLoadMoreMeetings: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 10) { HStack(alignment: .center, spacing: 16) { VStack(alignment: .leading, spacing: 3) { - Text("Recent activity") + Text(selectedTab.label) .font(.system(size: 15, weight: .semibold)) - Text(activitySubtitle) - .font(.caption) - .foregroundStyle(.secondary) } Spacer(minLength: 12) - - Picker("", selection: $selectedTab) { - ForEach(HomeActivityTab.allCases) { tab in - Text(tab.label).tag(tab) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .frame(width: 190, alignment: .trailing) } if isLoading { HStack { ProgressView() .controlSize(.small) - Text("Loading recent activity") + Text("Loading") .font(.callout) .foregroundStyle(.secondary) Spacer() @@ -866,16 +1040,7 @@ struct HomeActivityTabsCard: View { } } } - .padding(18) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color(nsColor: .controlBackgroundColor).opacity(0.78)) - ) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.primary.opacity(0.08), lineWidth: 1) - ) } @ViewBuilder @@ -888,32 +1053,251 @@ struct HomeActivityTabsCard: View { getID: @escaping (Item) -> AnyHashable, @ViewBuilder row: @escaping (Item) -> Content ) -> some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { HomeDayGroupedList( sections: sections, emptyMessage: emptyMessage, getID: getID, + sectionSpacing: 10, + headerSpacing: 1, row: row ) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} - if canLoadMore { - HomeLoadMoreButton( - title: loadMoreTitle, - isLoading: isLoadingMore, - action: loadMoreAction - ) +// MARK: - Row feedback + +struct HomeFeedbackSheet: View { + let target: HomeFeedbackTarget + let onCancel: () -> Void + let onSubmit: (HomeFeedbackSubmission) -> Void + + @State private var issueKind: HomeFeedbackIssueKind + @State private var notes = "" + @State private var includeDiagnostics = true + + init( + target: HomeFeedbackTarget, + onCancel: @escaping () -> Void, + onSubmit: @escaping (HomeFeedbackSubmission) -> Void + ) { + self.target = target + self.onCancel = onCancel + self.onSubmit = onSubmit + _issueKind = State(initialValue: target.suggestedIssue) + } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 6) { + Text("Report an issue") + .font(.system(size: 22, weight: .semibold)) + Text(target.title) + .font(.callout) + .foregroundStyle(.secondary) + } + + Picker("Issue", selection: $issueKind) { + ForEach(HomeFeedbackIssueKind.allCases) { kind in + Text(kind.label).tag(kind) + } + } + .pickerStyle(.segmented) + + VStack(alignment: .leading, spacing: 8) { + Text("What happened?") + .font(.subheadline.weight(.semibold)) + TextEditor(text: $notes) + .font(.body) + .frame(minHeight: 120) + .scrollContentBackground(.hidden) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.primary.opacity(0.035)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + } + + Toggle("Include safe diagnostics", isOn: $includeDiagnostics) + + Text("Transcripted attaches the capture type, time, app version, a private reference ID, and recent scrubbed logs. It does not attach transcript text, audio, file paths, meeting titles, emails, or raw URLs.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + HStack { + Button("Cancel", action: onCancel) + Spacer() + Button("Review report") { + onSubmit(HomeFeedbackSubmission( + target: target, + issueKind: issueKind, + notes: notes, + includeDiagnostics: includeDiagnostics + )) + } + .buttonStyle(.borderedProminent) } } - .frame(maxWidth: .infinity, alignment: .leading) + .padding(24) + .frame(width: 520) + } +} + +// MARK: - Meeting preview + +struct HomeMeetingPreviewSheet: View { + let preview: HomeMeetingPreview + let onOpenMarkdown: () -> Void + let onCopyForAgent: () -> Void + let onReportIssue: () -> Void + let onDone: () -> Void + private let readableContent: HomeMeetingPreviewContent + + init( + preview: HomeMeetingPreview, + onOpenMarkdown: @escaping () -> Void, + onCopyForAgent: @escaping () -> Void, + onReportIssue: @escaping () -> Void, + onDone: @escaping () -> Void + ) { + self.preview = preview + self.onOpenMarkdown = onOpenMarkdown + self.onCopyForAgent = onCopyForAgent + self.onReportIssue = onReportIssue + self.onDone = onDone + self.readableContent = HomeMeetingPreviewContent.make(from: preview.markdown) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 5) { + Text(preview.title) + .font(.system(size: 22, weight: .semibold)) + .lineLimit(2) + Text(HomeMeetingPreviewSheet.dateFormatter.string(from: preview.date)) + .font(.callout) + .foregroundStyle(.secondary) + } + + Spacer() + + Button("Done", action: onDone) + .keyboardShortcut(.defaultAction) + } + + Group { + if let readError = preview.readError { + VStack(alignment: .leading, spacing: 8) { + Label("Could not preview this meeting", systemImage: "exclamationmark.triangle") + .font(.headline) + Text(readError) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 280, alignment: .topLeading) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text("Transcript") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.6) + + if readableContent.transcriptLines.isEmpty { + Text(readableContent.fallbackText) + .font(.system(size: 13)) + .foregroundStyle(Color.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(Array(readableContent.transcriptLines.enumerated()), id: \.offset) { _, line in + HomeMeetingTranscriptLineView(line: line) + } + } + .textSelection(.enabled) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + } + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.primary.opacity(0.025)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.primary.opacity(0.08), lineWidth: 1) + ) + } + } + + HStack { + Button { + onOpenMarkdown() + } label: { + Label("Open Markdown", systemImage: "doc.text") + } + + Button { + onCopyForAgent() + } label: { + Label("Copy for agent", systemImage: "square.on.square") + } + + Button { + onReportIssue() + } label: { + Label("Report issue", systemImage: "flag") + } + + Spacer() + } + } + .padding(24) + .frame(width: 680, height: 620) } - private var activitySubtitle: String { - switch selectedTab { - case .dictations: - return canLoadMoreDictations ? "Showing the latest 10 dictations" : "Latest dictations" - case .meetings: - return canLoadMoreMeetings ? "Showing the latest 10 meetings" : "Latest meetings" + private static let dateFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = .current + f.dateStyle = .medium + f.timeStyle = .short + return f + }() +} + +private struct HomeMeetingTranscriptLineView: View { + let line: HomeMeetingTranscriptLine + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(line.time) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 44, alignment: .leading) + + Text(line.speaker) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: 92, alignment: .leading) + + Text(line.text) + .font(.system(size: 13)) + .foregroundStyle(Color.primary) + .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) } + .padding(.vertical, 2) } } diff --git a/Sources/UI/Settings/SettingsRecentCaptureRefreshPolicy.swift b/Sources/UI/Settings/SettingsRecentCaptureRefreshPolicy.swift index 69834454..7d0607ea 100644 --- a/Sources/UI/Settings/SettingsRecentCaptureRefreshPolicy.swift +++ b/Sources/UI/Settings/SettingsRecentCaptureRefreshPolicy.swift @@ -13,7 +13,7 @@ enum SettingsRecentCaptureRefreshPolicy { return .homeDashboard case .meetings, .dictations: return .recentLists - case .general, .models, .shortcuts, .people, .storage, .connectAgent, .privacy, .about: + case .general, .models, .shortcuts, .people, .storage, .connectAgent, .privacy, .support, .about: return .none } } diff --git a/Sources/UI/Settings/TranscriptedSettingsActions.swift b/Sources/UI/Settings/TranscriptedSettingsActions.swift index fbf09622..5806e245 100644 --- a/Sources/UI/Settings/TranscriptedSettingsActions.swift +++ b/Sources/UI/Settings/TranscriptedSettingsActions.swift @@ -9,4 +9,6 @@ struct TranscriptedSettingsActions { let openConnectAgent: () -> Void let checkForUpdates: () -> Void let sendFeedback: () -> Void + let copyDiagnostics: () -> Bool + let sendDiagnosticEvent: () -> String? } diff --git a/Sources/UI/Settings/TranscriptedSettingsPage.swift b/Sources/UI/Settings/TranscriptedSettingsPage.swift index aa51749e..86d0bf3d 100644 --- a/Sources/UI/Settings/TranscriptedSettingsPage.swift +++ b/Sources/UI/Settings/TranscriptedSettingsPage.swift @@ -11,6 +11,7 @@ enum TranscriptedSettingsPage: String, CaseIterable, Identifiable { case storage case connectAgent case privacy + case support case about var id: String { rawValue } @@ -36,6 +37,7 @@ enum TranscriptedSettingsPage: String, CaseIterable, Identifiable { case .storage: return "Storage" case .connectAgent: return "Agent" case .privacy: return "Privacy" + case .support: return "Support" case .about: return "About" } } @@ -62,8 +64,10 @@ enum TranscriptedSettingsPage: String, CaseIterable, Identifiable { return "One prompt, plus direct paths." case .privacy: return "Permissions and optional reporting." + case .support: + return "Feedback and diagnostics." case .about: - return "Version, updates, and support." + return "Version and updates." } } @@ -79,6 +83,7 @@ enum TranscriptedSettingsPage: String, CaseIterable, Identifiable { case .storage: return "externaldrive.fill" case .connectAgent: return "sparkles" case .privacy: return "lock.shield.fill" + case .support: return "questionmark.bubble.fill" case .about: return "info.circle.fill" } } diff --git a/Sources/UI/Settings/TranscriptedSettingsView.swift b/Sources/UI/Settings/TranscriptedSettingsView.swift index f4bfaf62..d3b0a991 100644 --- a/Sources/UI/Settings/TranscriptedSettingsView.swift +++ b/Sources/UI/Settings/TranscriptedSettingsView.swift @@ -13,7 +13,7 @@ private struct SettingsSidebarSection: Identifiable { SettingsSidebarSection(id: "home", title: nil, pages: [.home]), SettingsSidebarSection(id: "recording", title: "Recording", pages: [.meetings, .dictations, .people, .shortcuts]), SettingsSidebarSection(id: "setup", title: "Setup", pages: [.general, .models, .storage, .connectAgent]), - SettingsSidebarSection(id: "trust", title: "Trust", pages: [.privacy, .about]) + SettingsSidebarSection(id: "trust", title: "Trust", pages: [.privacy, .support, .about]) ] } @@ -26,6 +26,7 @@ struct TranscriptedSettingsView: View { @ObservedObject private var statsService: StatsService = .shared private let actions: TranscriptedSettingsActions + private let appLogger: AppLogger private let sidebarSections = SettingsSidebarSection.defaultSections @State private var dictationTriggerSystemWarning = PhysicalDictationTriggerPreferences.functionKeyConflictWarning( @@ -48,6 +49,7 @@ struct TranscriptedSettingsView: View { @State private var crashReportingEnabled = CrashReportingPreferences.isEnabled() @State private var anonymousAnalyticsEnabled = AnalyticsPreferences.isEnabled() @State private var sentryTestStatus: String? + @State private var diagnosticsActionStatus: String? @State private var permissionStates = PermissionSnapshot.current() @State private var captureLibraryURL = FileManager.default.transcriptedCaptureLibraryDir @State private var recentMeetings: [RecentMeetingItem] = [] @@ -59,10 +61,14 @@ struct TranscriptedSettingsView: View { @State private var copiedAgentMeetingID: String? @State private var meetingVoiceProcessingEnabled = MicrophoneProcessingPreferences.isVoiceProcessingEnabled() @StateObject private var homeViewModel = HomeViewModel() - @State private var homeActivityTab: HomeActivityTab = .dictations + @State private var homeActivityTab: HomeActivityTab = .meetings + @State private var homeHeroMode: HomeHeroMode = .meeting @State private var homeCopiedRowID: String? @State private var homeDeleteConfirmation: HomeDeleteConfirmation? @State private var homeDeleteFailure: HomeDeleteFailure? + @State private var homeFeedbackTarget: HomeFeedbackTarget? + @State private var homeMeetingPreview: HomeMeetingPreview? + @State private var homeMeetingPreviewLoadTask: Task? init( appState: TranscriptedAppState, @@ -73,6 +79,7 @@ struct TranscriptedSettingsView: View { self.navigation = navigation self.speakerPeopleModel = speakerPeopleModel self.actions = actions + self.appLogger = appState.logger _sttRouter = ObservedObject(wrappedValue: appState.sttRouter) _meetingSession = ObservedObject(wrappedValue: appState.meetingSession) _sparkleUpdater = ObservedObject(wrappedValue: appState.sparkleUpdater) @@ -118,10 +125,13 @@ struct TranscriptedSettingsView: View { VStack(alignment: .leading, spacing: 28) { pageBody } - .padding(28) + .padding(.horizontal, 28) + .padding(.top, settingsContentTopPadding) + .padding(.bottom, 28) .frame(maxWidth: 860, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } .frame(minWidth: 880, minHeight: 640) @@ -160,6 +170,8 @@ struct TranscriptedSettingsView: View { .onDisappear { recentCaptureRefreshTask?.cancel() recentCaptureRefreshTask = nil + homeMeetingPreviewLoadTask?.cancel() + homeMeetingPreviewLoadTask = nil homeViewModel.cancel() } } @@ -238,6 +250,8 @@ struct TranscriptedSettingsView: View { connectAgentPage case .privacy: privacyPage + case .support: + supportPage case .about: aboutPage } @@ -246,121 +260,136 @@ struct TranscriptedSettingsView: View { private var homePage: some View { let stats = homeStatItems let needsAttention = homeNeedsAttentionIssues - let useWideLayout = homeShouldUseWideLayout - return VStack(alignment: .leading, spacing: 20) { + return VStack(alignment: .leading, spacing: 14) { HStack(alignment: .top, spacing: 20) { - VStack(alignment: .leading, spacing: 18) { - HomeWelcomeHeader( - name: homeViewModel.welcomeName, - summary: homeWelcomeSummary - ) + HomeWelcomeHeader( + name: homeViewModel.welcomeName, + summary: homeWelcomeSummary + ) + .frame(maxWidth: .infinity, alignment: .leading) + + HomeStatsTopCard(stats: stats, streak: homeStreak) + } - if !useWideLayout { - HomeStatsStrip(stats: stats, streak: homeStreak) + HomeHeroCard( + selectedMode: homeHeroModeSelection, + onStartDictation: { + trackSettingsAction("start_dictation", page: .home) + actions.startDictation() + }, + onStartMeeting: { + trackSettingsAction("start_meeting", page: .home) + actions.startMeeting() + } + ) { + HomeActivityTabsCard( + selectedTab: homeActivityTab, + dictationSections: homeViewModel.dictationDaySections, + meetingSections: homeViewModel.meetingDaySections, + isLoading: homeViewModel.isLoading, + isLoadingMore: homeViewModel.isLoadingMore, + canLoadMoreDictations: homeViewModel.canLoadMoreDictations, + canLoadMoreMeetings: homeViewModel.canLoadMoreMeetings, + copiedRowID: homeCopiedRowID, + onOpenDictation: { entry in + trackSettingsAction("open_recent_dictation", page: .home) + NSWorkspace.shared.open(entry.url) + }, + onCopyDictation: { entry in + handleCopyDictation(entry) + }, + onFlagDictation: { entry in + trackSettingsAction("flag_dictation", page: .home) + homeFeedbackTarget = HomeFeedbackTarget.dictation(entry) + }, + dictationMenuItems: { entry in + dictationRowMenuItems(for: entry) + }, + onOpenMeeting: { item in + presentHomeMeetingPreview(item) + }, + onCopyMeeting: { item in + handleCopyMeeting(item) + }, + onFlagMeeting: { item in + trackSettingsAction("flag_meeting", page: .home) + homeFeedbackTarget = HomeFeedbackTarget.meeting(item) + }, + meetingMenuItems: { item in + meetingRowMenuItems(for: item) + }, + onLoadMoreDictations: { + trackSettingsAction("load_more_dictations", page: .home) + homeViewModel.loadMoreDictations() + }, + onLoadMoreMeetings: { + trackSettingsAction("load_more_meetings", page: .home) + homeViewModel.loadMoreMeetings() } + ) + } - HomeHeroCard( - primaryTitle: "Start Dictation", - primarySubtitle: "Press your dictation shortcut and speak. Transcripted pastes the text into any app you're using. Capture what you say anywhere you write.", - primaryAction: { - trackSettingsAction("start_dictation", page: .home) - actions.startDictation() - }, - secondaryTitle: "Record a Meeting", - secondarySubtitle: "Record a meeting to capture the conversation, transcribe it, and save notes you can review or reuse later.", - secondaryAction: { - trackSettingsAction("start_meeting", page: .home) - actions.startMeeting() + if let activity = homeTranscriptionActivity { + SettingsActivityCard( + symbolName: activity.symbolName, + title: activity.title, + status: activity.status, + detail: activity.detail, + tone: activity.tone, + progress: activity.progress, + actionTitle: activity.transcriptURL == nil ? nil : "Open Transcript", + action: activity.transcriptURL.map { transcriptURL in + { + trackSettingsAction("open_current_activity", page: .home) + NSWorkspace.shared.open(transcriptURL) } - ) - - if let activity = homeTranscriptionActivity { - SettingsActivityCard( - symbolName: activity.symbolName, - title: activity.title, - status: activity.status, - detail: activity.detail, - tone: activity.tone, - progress: activity.progress, - actionTitle: activity.transcriptURL == nil ? nil : "Open Transcript", - action: activity.transcriptURL.map { transcriptURL in - { - trackSettingsAction("open_current_activity", page: .home) - NSWorkspace.shared.open(transcriptURL) - } - } - ) - .transition(.move(edge: .top).combined(with: .opacity)) } + ) + .transition(.move(edge: .top).combined(with: .opacity)) + } - if !needsAttention.isEmpty { - HomeNeedsAttentionCard( - issues: needsAttention, - onOpenPrivacy: { - trackSettingsAction("open_needs_attention", page: .home) - navigation.selectedPage = .privacy - } - ) + if !needsAttention.isEmpty { + HomeNeedsAttentionCard( + issues: needsAttention, + onOpenPrivacy: { + trackSettingsAction("open_needs_attention", page: .home) + navigation.selectedPage = .privacy } - - } - .frame(maxWidth: .infinity, alignment: .leading) - - if useWideLayout { - HomeStatsRail(header: "Overall", stats: stats, streak: homeStreak) - .frame(width: 200, alignment: .topLeading) - } + ) } - - HomeActivityTabsCard( - selectedTab: $homeActivityTab, - dictationSections: homeViewModel.dictationDaySections, - meetingSections: homeViewModel.meetingDaySections, - isLoading: homeViewModel.isLoading, - isLoadingMore: homeViewModel.isLoadingMore, - canLoadMoreDictations: homeViewModel.canLoadMoreDictations, - canLoadMoreMeetings: homeViewModel.canLoadMoreMeetings, - copiedRowID: homeCopiedRowID, - onOpenDictation: { entry in - trackSettingsAction("open_recent_dictation", page: .home) - NSWorkspace.shared.open(entry.url) - }, - onCopyDictation: { entry in - handleCopyDictation(entry) - }, - onFlagDictation: { _ in - trackSettingsAction("flag_dictation", page: .home) - actions.sendFeedback() - }, - dictationMenuItems: { entry in - dictationRowMenuItems(for: entry) - }, - onOpenMeeting: { item in - trackSettingsAction("open_recent_meeting", page: .home) - NSWorkspace.shared.open(item.transcriptURL) - }, - onCopyMeeting: { item in - handleCopyMeeting(item) + } + .animation(.snappy(duration: 0.22), value: homeTranscriptionActivity) + .sheet(item: $homeFeedbackTarget) { target in + HomeFeedbackSheet( + target: target, + onCancel: { + homeFeedbackTarget = nil }, - onFlagMeeting: { _ in - trackSettingsAction("flag_meeting", page: .home) - actions.sendFeedback() + onSubmit: { submission in + submitHomeFeedback(submission) + } + ) + } + .sheet(item: $homeMeetingPreview) { preview in + HomeMeetingPreviewSheet( + preview: preview, + onOpenMarkdown: { + trackSettingsAction("open_recent_meeting_markdown", page: .home) + NSWorkspace.shared.open(preview.transcriptURL) }, - meetingMenuItems: { item in - meetingRowMenuItems(for: item) + onCopyForAgent: { + handleCopyMeetingPreview(preview) }, - onLoadMoreDictations: { - trackSettingsAction("load_more_dictations", page: .home) - homeViewModel.loadMoreDictations() + onReportIssue: { + homeMeetingPreview = nil + homeFeedbackTarget = preview.feedbackTarget }, - onLoadMoreMeetings: { - trackSettingsAction("load_more_meetings", page: .home) - homeViewModel.loadMoreMeetings() + onDone: { + homeMeetingPreview = nil } ) } - .animation(.snappy(duration: 0.22), value: homeTranscriptionActivity) .onChange(of: homeActivityTab) { _, newValue in trackSettingsAction("home_tab_\(newValue.rawValue)", page: .home) } @@ -383,6 +412,20 @@ struct TranscriptedSettingsView: View { } } + private var settingsContentTopPadding: CGFloat { + navigation.selectedPage == .home ? -34 : 14 + } + + private var homeHeroModeSelection: Binding { + Binding( + get: { homeHeroMode }, + set: { newMode in + homeHeroMode = newMode + homeActivityTab = newMode.activityTab + } + ) + } + private func handleCopyDictation(_ entry: SavedDictationEntry) { trackSettingsAction("copy_dictation", page: .home) let pasteboard = NSPasteboard.general @@ -410,6 +453,82 @@ struct TranscriptedSettingsView: View { flashCopied(rowID: item.id) } + private func handleCopyMeetingPreview(_ preview: HomeMeetingPreview) { + trackSettingsAction("copy_meeting_preview", page: .home) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(preview.markdown, forType: .string) + } + + private func presentHomeMeetingPreview(_ item: RecentMeetingItem) { + trackSettingsAction("preview_recent_meeting", page: .home) + homeMeetingPreviewLoadTask?.cancel() + homeMeetingPreviewLoadTask = Task { @MainActor in + let readResult = await Self.readMeetingMarkdown(at: item.transcriptURL) + guard !Task.isCancelled else { return } + + switch readResult { + case .success(let markdown): + homeMeetingPreview = HomeMeetingPreview(item: item, markdown: markdown) + case .failure(let message): + homeMeetingPreview = HomeMeetingPreview( + item: item, + markdown: "", + readError: message + ) + } + + homeMeetingPreviewLoadTask = nil + } + } + + private static func readMeetingMarkdown(at url: URL) async -> HomeMeetingMarkdownReadResult { + await Task.detached(priority: .userInitiated) { + do { + return .success(try String(contentsOf: url, encoding: .utf8)) + } catch { + return .failure(error.localizedDescription) + } + }.value + } + + private func submitHomeFeedback(_ submission: HomeFeedbackSubmission) { + trackSettingsAction("submit_home_feedback", page: .home) + EventReporter.shared.capture( + level: .info, + engine: "feedback", + event: "capture_feedback_prepared", + message: "User prepared capture feedback", + context: [ + "source_kind": submission.target.sourceKind, + "issue_kind": submission.issueKind.rawValue, + "include_diagnostics": submission.includeDiagnostics ? "true" : "false", + ] + ) + + let report = FeedbackReport( + sourceKind: submission.target.sourceKind, + referenceID: submission.target.referenceID, + occurredAt: submission.target.createdAt, + issueKind: submission.issueKind.label, + userNotes: submission.notes, + appVersion: TranscriptedSupportActions.appVersionDescription, + includeDiagnostics: submission.includeDiagnostics + ) + + guard let url = FeedbackIssueBuilder.emailURL( + report: report, + rawLogLines: submission.includeDiagnostics ? appLogger.entries : nil + ) else { + NSSound.beep() + return + } + + AppSoundPlayer.shared.play(.feedbackSubmitted, respectingPreferences: false) + homeFeedbackTarget = nil + NSWorkspace.shared.open(url) + } + private func flashCopied(rowID: String) { homeCopiedRowID = rowID Task { @MainActor in @@ -508,16 +627,12 @@ struct TranscriptedSettingsView: View { ) } - private var homeShouldUseWideLayout: Bool { - true - } - private var homeWelcomeSummary: String { let dictations = homeViewModel.todayDictationCount let meetings = statsService.todayRecordings let dictationLabel = dictations == 1 ? "dictation" : "dictations" let meetingLabel = meetings == 1 ? "meeting" : "meetings" - return "\(formattedInteger(dictations)) \(dictationLabel) today, \(formattedInteger(meetings)) \(meetingLabel) today." + return "\(formattedInteger(dictations)) \(dictationLabel) · \(formattedInteger(meetings)) \(meetingLabel) today" } private var homeStatItems: [HomeStatItem] { @@ -530,7 +645,7 @@ struct TranscriptedSettingsView: View { HomeStatItem( symbolName: "keyboard", value: formattedTypingTimeSaved(forDictatedWords: homeViewModel.totalDictationWordCount), - label: "typing saved" + label: "saved" ), HomeStatItem( symbolName: "person.2.wave.2.fill", @@ -540,7 +655,7 @@ struct TranscriptedSettingsView: View { HomeStatItem( symbolName: "clock.fill", value: statsService.formattedTotalHours, - label: "meeting hours" + label: "hours" ) ] } @@ -1282,6 +1397,7 @@ struct TranscriptedSettingsView: View { trackSettingsToggle("crash_reporting", enabled: newValue, page: .privacy) CrashReportingPreferences.setEnabled(newValue) sentryTestStatus = nil + diagnosticsActionStatus = nil } )) .disabled(!CrashReporter.isAvailable) @@ -1297,6 +1413,7 @@ struct TranscriptedSettingsView: View { trackSettingsToggle("anonymous_analytics", enabled: false, page: .privacy) AnalyticsPreferences.setEnabled(false) } + diagnosticsActionStatus = nil } )) .disabled(!AnalyticsReporter.isAvailable) @@ -1334,7 +1451,7 @@ struct TranscriptedSettingsView: View { VStack(alignment: .leading, spacing: 24) { SettingsPageIntro( title: "About", - summary: "Version, updates, and support." + summary: "Version and updates." ) SettingsSection( @@ -1385,13 +1502,196 @@ struct TranscriptedSettingsView: View { sparkleUpdater.performUserUpdateAction(surface: "settings_about") } .disabled(!aboutUpdateButtonEnabled) + } + } + } + } + + private var supportPage: some View { + VStack(alignment: .leading, spacing: 20) { + SettingsPageIntro( + title: "Support", + summary: "Need help, found a bug, or want to send feedback? Email is the best way to reach the team building Transcripted." + ) + + SupportActionCard( + symbolName: "envelope.fill", + title: "Email support", + detail: "Send feedback, ask for help, or tell us what felt broken. This opens a prefilled email to help@transcripted.app.", + buttonTitle: "Email support", + buttonSymbolName: "paperplane.fill", + tone: .primary, + status: nil, + isEnabled: true + ) { + trackSettingsAction("submit_feedback", page: .support) + actions.sendFeedback() + } + + SupportActionCard( + symbolName: "waveform.path.ecg", + title: "Send diagnostics", + detail: "Had an error or something felt broken? Send a privacy-safe diagnostic event so we can investigate and try to fix it.", + buttonTitle: "One-click send diagnostics", + buttonSymbolName: "bolt.fill", + tone: .secondary, + status: diagnosticsActionStatus, + isEnabled: CrashReporter.isAvailable && crashReportingEnabled + ) { + trackSettingsAction("send_diagnostic_event", page: .support) + sendDiagnosticEvent() + } + + SupportPrivacyNote() + } + } + + private struct SupportActionCard: View { + enum Tone { + case primary + case secondary + } + + let symbolName: String + let title: String + let detail: String + let buttonTitle: String + let buttonSymbolName: String + let tone: Tone + let status: String? + let isEnabled: Bool + let action: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: symbolName) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(iconForeground) + .frame(width: 34, height: 34) + .background(iconBackground, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.primary) + + Text(detail) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) + } + + Button(action: action) { + Label(buttonTitle, systemImage: buttonSymbolName) + .font(.callout.weight(.semibold)) + .labelStyle(.titleAndIcon) + .lineLimit(1) + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background(buttonBackground, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .foregroundStyle(buttonForeground) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .opacity(isEnabled ? 1 : 0.55) - Button("Submit Feedback") { - trackSettingsAction("submit_feedback", page: .about) - actions.sendFeedback() + if let status, !status.isEmpty { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color(nsColor: .systemGreen)) + + Text(status) + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } } } + .padding(18) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(cardBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(cardStroke, lineWidth: 1) + ) + } + + private var iconForeground: Color { + switch tone { + case .primary: + return Color(nsColor: .systemGreen) + case .secondary: + return Color.accentColor + } + } + + private var iconBackground: Color { + switch tone { + case .primary: + return Color(nsColor: .systemGreen).opacity(0.16) + case .secondary: + return Color.accentColor.opacity(0.14) + } + } + + private var cardBackground: Color { + switch tone { + case .primary: + return Color(nsColor: .controlBackgroundColor).opacity(0.9) + case .secondary: + return Color(nsColor: .controlBackgroundColor).opacity(0.72) + } + } + + private var cardStroke: Color { + switch tone { + case .primary: + return Color(nsColor: .systemGreen).opacity(0.25) + case .secondary: + return Color.primary.opacity(0.08) + } + } + + private var buttonBackground: Color { + switch tone { + case .primary: + return Color(nsColor: .systemGreen) + case .secondary: + return Color.secondary.opacity(0.16) + } + } + + private var buttonForeground: Color { + switch tone { + case .primary: + return .white + case .secondary: + return .primary + } + } + } + + private struct SupportPrivacyNote: View { + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "lock.shield.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 20) + + Text("Never sent: transcript text, audio, names, emails, file paths, raw URLs, or meeting titles.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 2) } } @@ -1795,6 +2095,25 @@ struct TranscriptedSettingsView: View { sentryTestStatus = "Queued test event \(eventID.prefix(8)). Check Sentry in a few seconds." } + private func sendDiagnosticEvent() { + guard CrashReporter.isAvailable else { + diagnosticsActionStatus = "Sentry is not configured in this build yet." + return + } + + guard crashReportingEnabled else { + diagnosticsActionStatus = "Turn on crash and error reports first." + return + } + + guard let eventID = actions.sendDiagnosticEvent() else { + diagnosticsActionStatus = "Diagnostic event could not be queued." + return + } + + diagnosticsActionStatus = "Queued diagnostic event \(eventID.prefix(8))." + } + private func chooseCaptureLibrary() { let panel = NSOpenPanel() panel.canChooseDirectories = true diff --git a/Sources/UI/Shared/FeedbackIssueBuilder.swift b/Sources/UI/Shared/FeedbackIssueBuilder.swift index 4e4af8c6..a238e2a2 100644 --- a/Sources/UI/Shared/FeedbackIssueBuilder.swift +++ b/Sources/UI/Shared/FeedbackIssueBuilder.swift @@ -1,30 +1,87 @@ import Foundation +struct FeedbackReport { + let sourceKind: String + let referenceID: String + let occurredAt: Date + let issueKind: String + let userNotes: String + let appVersion: String + let includeDiagnostics: Bool +} + enum FeedbackIssueBuilder { - static let issueURLString = "https://github.com/r3dbars/transcripted/issues/new" - static let maxIssueURLCharacterCount = 6_000 + static let supportEmailAddress = "help@transcripted.app" + static let emailURLString = "mailto:\(supportEmailAddress)" + static let maxEmailURLCharacterCount = 6_000 private static let title = "Transcripted Feedback" private static let maxLogLines = 80 - private static let omittedLogsNotice = "[Older logs omitted because GitHub rejects very long feedback URLs.]" + private static let maxUserNotesCharacters = 1_500 + private static let maxDiagnosticsCharacters = 2_500 + private static let omittedLogsNotice = "[Older logs omitted because the feedback email got too long.]" + private static let omittedDiagnosticsNotice = "[Older diagnostics omitted because the feedback email got too long.]" private static let noLogsMessage = "No in-app logs attached." - static func issueURL(rawLogLines: [String]?) -> URL? { + static func emailURL(rawLogLines: [String]?, diagnostics: String? = nil) -> URL? { let rawLogs = rawLogLines?.suffix(maxLogLines).joined(separator: "\n") ?? noLogsMessage let sanitizedLogs = AnalyticsPayloadSanitizer.redact(rawLogs) - return issueURL(sanitizedLogs: sanitizedLogs.isEmpty ? noLogsMessage : sanitizedLogs) + let sanitizedDiagnostics = diagnostics.map { fittingDiagnostics(from: AnalyticsPayloadSanitizer.redact($0)) } + return emailURL( + sanitizedLogs: sanitizedLogs.isEmpty ? noLogsMessage : sanitizedLogs, + diagnostics: sanitizedDiagnostics + ) + } + + static func emailURL(report: FeedbackReport, rawLogLines: [String]?) -> URL? { + let rawLogs = report.includeDiagnostics + ? rawLogLines?.suffix(maxLogLines).joined(separator: "\n") ?? noLogsMessage + : "User chose not to attach diagnostics." + let sanitizedLogs = AnalyticsPayloadSanitizer.redact(rawLogs) + return emailURL( + report: report, + sanitizedLogs: sanitizedLogs.isEmpty ? noLogsMessage : sanitizedLogs + ) + } + + static func emailURL(sanitizedLogs: String, diagnostics: String? = nil) -> URL? { + cappedEmailURL(subject: title, sanitizedLogs: sanitizedLogs) { logs in + body(logs: logs, diagnostics: diagnostics) + } } - static func issueURL(sanitizedLogs: String) -> URL? { - if let url = uncappedIssueURL(sanitizedLogs: sanitizedLogs), - url.absoluteString.count <= maxIssueURLCharacterCount { + private static func emailURL(report: FeedbackReport, sanitizedLogs: String) -> URL? { + let reportTitle = "Transcripted \(report.sourceKind.capitalized) Feedback" + let notes = sanitizedNotes(report.userNotes) + return cappedEmailURL(subject: reportTitle, sanitizedLogs: sanitizedLogs) { logs in + contextualBody(report: report, notes: notes, logs: logs) + } + } + + private static func cappedEmailURL( + subject: String, + sanitizedLogs: String, + bodyForLogs: (String) -> String + ) -> URL? { + let uncappedBody = bodyForLogs(sanitizedLogs) + if let url = uncappedEmailURL(subject: subject, body: uncappedBody), + url.absoluteString.count <= maxEmailURLCharacterCount { return url } - return uncappedIssueURL(sanitizedLogs: fittingTrimmedLogs(from: sanitizedLogs)) + let trimmedLogs = fittingTrimmedLogs( + from: sanitizedLogs, + subject: subject, + bodyForLogs: bodyForLogs + ) + return uncappedEmailURL(subject: subject, body: bodyForLogs(trimmedLogs)) } - private static func fittingTrimmedLogs(from sanitizedLogs: String) -> String { + private static func fittingTrimmedLogs( + from sanitizedLogs: String, + subject: String, + bodyForLogs: (String) -> String + ) -> String { var lowerBound = 0 var upperBound = sanitizedLogs.count var best = omittedLogsNotice @@ -33,12 +90,12 @@ enum FeedbackIssueBuilder { let middle = (lowerBound + upperBound) / 2 let candidate = trimmedLogs(from: sanitizedLogs, maxTailCharacters: middle) - guard let url = uncappedIssueURL(sanitizedLogs: candidate) else { + guard let url = uncappedEmailURL(subject: subject, body: bodyForLogs(candidate)) else { upperBound = middle - 1 continue } - if url.absoluteString.count <= maxIssueURLCharacterCount { + if url.absoluteString.count <= maxEmailURLCharacterCount { best = candidate lowerBound = middle + 1 } else { @@ -62,23 +119,77 @@ enum FeedbackIssueBuilder { return "\(omittedLogsNotice)\n\(tail)" } - private static func uncappedIssueURL(sanitizedLogs: String) -> URL? { - var components = URLComponents(string: issueURLString) - components?.queryItems = [ - URLQueryItem(name: "title", value: title), - URLQueryItem(name: "body", value: body(logs: sanitizedLogs)) + private static func fittingDiagnostics(from diagnostics: String) -> String { + guard diagnostics.count > maxDiagnosticsCharacters else { return diagnostics } + var tail = String(diagnostics.suffix(maxDiagnosticsCharacters)) + if let firstNewline = tail.firstIndex(of: "\n") { + tail = String(tail[tail.index(after: firstNewline)...]) + } + return "\(omittedDiagnosticsNotice)\n\(tail)" + } + + private static func uncappedEmailURL(subject: String, body: String) -> URL? { + var components = URLComponents() + components.scheme = "mailto" + components.path = supportEmailAddress + components.queryItems = [ + URLQueryItem(name: "subject", value: subject), + URLQueryItem(name: "body", value: body) ] - return components?.url + return components.url } - private static func body(logs: String) -> String { - """ + private static func sanitizedNotes(_ notes: String) -> String { + let trimmed = notes.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "[No notes provided.]" } + let sanitized = AnalyticsPayloadSanitizer.redact(trimmed) + guard sanitized.count > maxUserNotesCharacters else { return sanitized } + return "\(sanitized.prefix(maxUserNotesCharacters))\n[Feedback text truncated for URL length.]" + } + + private static func body(logs: String, diagnostics: String?) -> String { + let diagnosticsText: String + if let diagnostics, !diagnostics.isEmpty { + diagnosticsText = diagnostics + } else { + diagnosticsText = "No diagnostics attached." + } + return """ What happened: [describe the issue here] + --- + Diagnostics: + \(diagnosticsText) + --- Logs: \(logs) """ } + + private static func contextualBody(report: FeedbackReport, notes: String, logs: String) -> String { + """ + What went wrong: + \(notes) + + --- + Capture: + Type: \(report.sourceKind) + Issue: \(report.issueKind) + Reference: \(report.referenceID) + Created: \(feedbackDateFormatter.string(from: report.occurredAt)) + App: \(report.appVersion) + + --- + Diagnostics: + \(logs) + """ + } + + private static let feedbackDateFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() } diff --git a/Sources/UI/Shared/SupportDiagnosticsBundle.swift b/Sources/UI/Shared/SupportDiagnosticsBundle.swift new file mode 100644 index 00000000..7bcac2d0 --- /dev/null +++ b/Sources/UI/Shared/SupportDiagnosticsBundle.swift @@ -0,0 +1,114 @@ +import Foundation + +struct SupportDiagnosticsSnapshot: Equatable { + var appVersion: String + var buildVersion: String + var osVersion: String + var crashReportingAvailable: Bool + var crashReportingEnabled: Bool + var analyticsAvailable: Bool + var analyticsEnabled: Bool + var microphoneStatus: String + var systemAudioRecordingGranted: Bool + var pastebackGranted: Bool + var calendarGranted: Bool + var audioRoute: [String: String] + var runtime: [String: String] + var meetingState: String + var meetingRecording: Bool + var meetingDurationBucket: String + var recentLogLines: [String] +} + +enum SupportDiagnosticsBundle { + static let maxRecentLogLines = 20 + + static func text(snapshot: SupportDiagnosticsSnapshot, now: Date = Date()) -> String { + let recentLogs = snapshot.recentLogLines + .suffix(maxRecentLogLines) + .map(AnalyticsPayloadSanitizer.redact) + .filter { !$0.isEmpty } + + return """ + Transcripted diagnostics + Generated: \(ISO8601DateFormatter().string(from: now)) + + App + Version: \(snapshot.appVersion) + Build: \(snapshot.buildVersion) + macOS: \(snapshot.osVersion) + + Reporting + Crash reporting: \(status(available: snapshot.crashReportingAvailable, enabled: snapshot.crashReportingEnabled)) + Anonymous analytics: \(status(available: snapshot.analyticsAvailable, enabled: snapshot.analyticsEnabled)) + + Permissions + Microphone: \(snapshot.microphoneStatus) + System audio recording: \(bool(snapshot.systemAudioRecordingGranted)) + Paste-back accessibility: \(bool(snapshot.pastebackGranted)) + Calendar: \(bool(snapshot.calendarGranted)) + + Runtime + \(render(snapshot.runtime)) + + Audio Route + \(render(snapshot.audioRoute)) + + Meeting + State: \(snapshot.meetingState) + Recording: \(bool(snapshot.meetingRecording)) + Duration: \(snapshot.meetingDurationBucket) + + Recent Events + \(recentLogs.isEmpty ? "No recent in-app events." : recentLogs.joined(separator: "\n")) + + Privacy + This diagnostic summary is designed to exclude transcript text, raw audio, file paths, device names, meeting titles, speaker names, emails, tokens, and raw URLs. + """ + } + + static func sentryContext(snapshot: SupportDiagnosticsSnapshot) -> [String: String] { + var context: [String: String] = [ + "analytics_available": bool(snapshot.analyticsAvailable), + "analytics_enabled": bool(snapshot.analyticsEnabled), + "app_version": snapshot.appVersion, + "build_version": snapshot.buildVersion, + "calendar_granted": bool(snapshot.calendarGranted), + "crash_reporting_available": bool(snapshot.crashReportingAvailable), + "crash_reporting_enabled": bool(snapshot.crashReportingEnabled), + "meeting_duration_bucket": snapshot.meetingDurationBucket, + "meeting_recording": bool(snapshot.meetingRecording), + "meeting_state": snapshot.meetingState, + "microphone_status": snapshot.microphoneStatus, + "pasteback_granted": bool(snapshot.pastebackGranted), + "system_audio_recording_granted": bool(snapshot.systemAudioRecordingGranted), + ] + + for (key, value) in snapshot.audioRoute { + context["route_\(key)"] = value + } + + for (key, value) in snapshot.runtime { + context["runtime_\(key)"] = value + } + + return context + } + + private static func render(_ values: [String: String]) -> String { + guard !values.isEmpty else { return "Unavailable" } + return values + .sorted { $0.key < $1.key } + .map { "\($0.key): \($0.value)" } + .joined(separator: "\n") + } + + private static func status(available: Bool, enabled: Bool) -> String { + guard available else { return "unavailable" } + return enabled ? "enabled" : "disabled" + } + + private static func bool(_ value: Bool) -> String { + value ? "true" : "false" + } +} diff --git a/Sources/UI/Shared/TranscriptedSupportActions.swift b/Sources/UI/Shared/TranscriptedSupportActions.swift index 2aad692b..a16443ef 100644 --- a/Sources/UI/Shared/TranscriptedSupportActions.swift +++ b/Sources/UI/Shared/TranscriptedSupportActions.swift @@ -1,16 +1,53 @@ import AppKit +import AVFoundation import Foundation @MainActor enum TranscriptedSupportActions { + static func sendFeedback(appState: TranscriptedAppState) { + guard let url = feedbackEmailURL(appState: appState) else { return } + AppSoundPlayer.shared.play(.feedbackSubmitted, respectingPreferences: false) + NSWorkspace.shared.open(url) + } + static func sendFeedback(logger: AppLogger?) { - guard let url = feedbackIssueURL(logger: logger) else { return } + guard let url = FeedbackIssueBuilder.emailURL(rawLogLines: logger?.entries) else { return } AppSoundPlayer.shared.play(.feedbackSubmitted, respectingPreferences: false) NSWorkspace.shared.open(url) } - static func feedbackIssueURL(logger: AppLogger?) -> URL? { - FeedbackIssueBuilder.issueURL(rawLogLines: logger?.entries) + static func copyDiagnostics(appState: TranscriptedAppState) -> Bool { + let text = diagnosticsText(appState: appState) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + let copied = pasteboard.setString(text, forType: .string) + if copied { + AnalyticsReporter.track("support_diagnostics_copied") + } + return copied + } + + static func sendDiagnosticEvent(appState: TranscriptedAppState) -> String? { + let snapshot = diagnosticsSnapshot(appState: appState) + let context = SupportDiagnosticsBundle.sentryContext(snapshot: snapshot) + + AnalyticsReporter.track("support_diagnostic_event_sent") + return CrashReporter.shared.captureSupportDiagnosticEvent(extra: context) + } + + static func feedbackEmailURL(logger: AppLogger?) -> URL? { + FeedbackIssueBuilder.emailURL(rawLogLines: logger?.entries) + } + + static func feedbackEmailURL(appState: TranscriptedAppState) -> URL? { + FeedbackIssueBuilder.emailURL( + rawLogLines: appState.logger.entries, + diagnostics: diagnosticsText(appState: appState) + ) + } + + static func diagnosticsText(appState: TranscriptedAppState) -> String { + SupportDiagnosticsBundle.text(snapshot: diagnosticsSnapshot(appState: appState)) } static var appVersionDescription: String { @@ -28,4 +65,66 @@ enum TranscriptedSupportActions { return "Version unavailable" } } + + private static func diagnosticsSnapshot(appState: TranscriptedAppState) -> SupportDiagnosticsSnapshot { + let meetingState: String + let meetingRecording: Bool + let meetingDurationBucket: String + if #available(macOS 14.0, *) { + meetingState = meetingStateName(appState.meetingSession.state) + meetingRecording = appState.meetingSession.isRecording + meetingDurationBucket = AnalyticsReporter.durationBucket(seconds: appState.meetingSession.recordingDuration) + } else { + meetingState = "unavailable" + meetingRecording = false + meetingDurationBucket = "lt_10s" + } + + return SupportDiagnosticsSnapshot( + appVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown", + buildVersion: Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown", + osVersion: ProcessInfo.processInfo.operatingSystemVersionString, + crashReportingAvailable: CrashReporter.isAvailable, + crashReportingEnabled: CrashReportingPreferences.isEnabled(), + analyticsAvailable: AnalyticsReporter.isAvailable, + analyticsEnabled: AnalyticsPreferences.isEnabled(), + microphoneStatus: microphoneStatusName(TranscriptedPermissionAccess.microphoneAuthorizationStatus()), + systemAudioRecordingGranted: TranscriptedPermissionAccess.isGranted(.systemAudioRecording), + pastebackGranted: TranscriptedPermissionAccess.isGranted(.accessibility), + calendarGranted: TranscriptedPermissionAccess.isGranted(.calendar), + audioRoute: appState.sttRouter.dictationAudioRouteAnalyticsContext, + runtime: appState.runtimeDiagnostics.currentAnalyticsContext(), + meetingState: meetingState, + meetingRecording: meetingRecording, + meetingDurationBucket: meetingDurationBucket, + recentLogLines: appState.logger.entries + ) + } + + private static func microphoneStatusName(_ status: AVAuthorizationStatus) -> String { + switch status { + case .authorized: + return "authorized" + case .denied: + return "denied" + case .restricted: + return "restricted" + case .notDetermined: + return "not_determined" + @unknown default: + return "unknown" + } + } + + @available(macOS 14.0, *) + private static func meetingStateName(_ state: MeetingSessionController.State) -> String { + switch state { + case .idle: return "idle" + case .loadingModels: return "loading_models" + case .ready: return "ready" + case .recording: return "recording" + case .transcribing: return "transcribing" + case .error: return "error" + } + } } diff --git a/Tests/AnalyticsEventPolicyTests.swift b/Tests/AnalyticsEventPolicyTests.swift index 249433ac..2eee8436 100644 --- a/Tests/AnalyticsEventPolicyTests.swift +++ b/Tests/AnalyticsEventPolicyTests.swift @@ -94,9 +94,39 @@ func testAnalyticsEventPolicy() { assertEqual(sanitized["surface"], "settings_about", "update surface should survive sanitization") } + runSuite("AnalyticsEventPolicy allows runtime diagnostic events") { + let unclean = AnalyticsEventPolicy.policy(forEvent: "app_unclean_shutdown_detected") + let stall = AnalyticsEventPolicy.policy(forEvent: "app_session_stall_detected") + let copied = AnalyticsEventPolicy.policy(forEvent: "support_diagnostics_copied") + let sent = AnalyticsEventPolicy.policy(forEvent: "support_diagnostic_event_sent") + + assertEqual(unclean?.allowedProperties.contains("session_stage"), true, "unclean shutdown should preserve last session stage") + assertEqual(unclean?.allowedProperties.contains("heartbeat_age_bucket"), true, "unclean shutdown should preserve heartbeat age bucket") + assertEqual(stall?.allowedProperties.contains("stall_stage"), true, "session stall should preserve stall stage") + assertEqual(stall?.allowedProperties.contains("duration_bucket"), true, "session stall should preserve duration bucket") + assertNotNil(copied, "copy diagnostics event should be allowlisted") + assertNotNil(sent, "send diagnostic event should be allowlisted") + + let sanitized = AnalyticsPayloadSanitizer.sanitizeProperties( + [ + "duration_bucket": "30_119s", + "heartbeat_age_bucket": "1_4m", + "session_kind": "dictation", + "session_stage": "recording", + "stall_stage": "microphone_start_timeout", + ], + allowedKeys: stall?.allowedProperties ?? [] + ) + assertEqual(sanitized["session_stage"], "recording", "session stage should survive sanitization") + assertEqual(sanitized["stall_stage"], "microphone_start_timeout", "stall stage should survive sanitization") + } + runSuite("AnalyticsEventPolicy preserves dictation auto-send attribution") { let dictationCompleted = AnalyticsEventPolicy.policy(forEvent: "dictation_completed") assertEqual(dictationCompleted?.allowedProperties.contains("auto_send"), true, "dictation completion should allow the existing auto_send property") + assertEqual(dictationCompleted?.allowedProperties.contains("input_device_class"), true, "dictation completion should preserve coarse input device class") + assertEqual(dictationCompleted?.allowedProperties.contains("hfp_suspected"), true, "dictation completion should preserve Bluetooth HFP suspicion only as a boolean") + assertEqual(dictationCompleted?.allowedProperties.contains("sample_flow_started"), true, "dictation completion should preserve whether audio samples ever flowed") } runSuite("AnalyticsEventPolicy allows dictation start failures with coarse attribution") { @@ -104,18 +134,65 @@ func testAnalyticsEventPolicy() { assertEqual(dictationStartFailed?.allowedProperties.contains("failure_kind"), true, "dictation start failures should preserve normalized failure kinds") assertEqual(dictationStartFailed?.allowedProperties.contains("trigger"), true, "dictation start failures should preserve trigger attribution") + assertEqual(dictationStartFailed?.allowedProperties.contains("route_shape"), true, "dictation start failures should preserve safe route shape") + assertEqual(dictationStartFailed?.allowedProperties.contains("selection_reason"), true, "dictation start failures should preserve coarse device-selection reason") let sanitized = AnalyticsPayloadSanitizer.sanitizeProperties( [ + "default_input_class": "bluetooth", + "default_output_class": "bluetooth", "failure_kind": "microphone_start_timeout", + "format_ready": "false", + "hfp_suspected": "true", + "input_channels": "1", + "input_device_class": "bluetooth", + "input_rate_hz": "24000", + "output_channels": "1", + "output_device_class": "bluetooth", + "output_rate_hz": "48000", + "recovering": "true", + "route_shape": "bluetooth_input_to_bluetooth_output", + "sample_flow_started": "false", + "selection_reason": "noBuiltInFallbackAvailable", "trigger": "hotkey", ], - allowedKeys: ["failure_kind", "trigger"] + allowedKeys: dictationStartFailed?.allowedProperties ?? [] ) assertEqual(sanitized["failure_kind"], "microphone_start_timeout", "dictation start failure kind should survive sanitization") + assertEqual(sanitized["input_device_class"], "bluetooth", "coarse dictation input class should survive sanitization") + assertEqual(sanitized["input_rate_hz"], "24000", "safe dictation input rate should survive sanitization") + assertEqual(sanitized["route_shape"], "bluetooth_input_to_bluetooth_output", "safe route shape should survive sanitization") + assertEqual(sanitized["hfp_suspected"], "true", "Bluetooth HFP suspicion should survive as a boolean") + assertEqual(sanitized["sample_flow_started"], "false", "sample flow state should survive as a boolean") assertEqual(sanitized["trigger"], "hotkey", "dictation start trigger should survive sanitization") } + runSuite("AnalyticsEventPolicy allows dictation audio route lifecycle events") { + let changed = AnalyticsEventPolicy.policy(forEvent: "dictation_audio_route_changed") + let finished = AnalyticsEventPolicy.policy(forEvent: "dictation_audio_route_recovery_finished") + let timeout = AnalyticsEventPolicy.policy(forEvent: "dictation_audio_route_recovery_timeout") + + assertEqual(changed?.allowedProperties.contains("was_recording"), true, "route change should preserve whether an active recording was interrupted") + assertEqual(changed?.allowedProperties.contains("selected_input_class"), true, "route change should preserve selected input class") + assertEqual(finished?.allowedProperties.contains("outcome"), true, "route recovery should preserve success/failure") + assertEqual(finished?.allowedProperties.contains("recovery_latency_bucket"), true, "route recovery should preserve latency as a bucket") + assertEqual(timeout?.allowedProperties.contains("hfp_suspected"), true, "route timeout should preserve Bluetooth HFP suspicion only as a boolean") + + let sanitized = AnalyticsPayloadSanitizer.sanitizeProperties( + [ + "outcome": "failed", + "recovery_latency_bucket": "2_9m", + "selected_input_class": "built_in", + "was_recording": "true", + ], + allowedKeys: finished?.allowedProperties ?? [] + ) + assertEqual(sanitized["outcome"], "failed", "route recovery outcome should survive sanitization") + assertEqual(sanitized["recovery_latency_bucket"], "2_9m", "route recovery latency bucket should survive sanitization") + assertEqual(sanitized["selected_input_class"], "built_in", "selected input class should survive sanitization") + assertEqual(sanitized["was_recording"], "true", "recording interruption state should survive sanitization") + } + runSuite("AnalyticsEventPolicy only permits reviewed analytics events") { let dictationStartFailed = AnalyticsEventPolicy.policy(forEvent: "dictation_start_failed") let dictationCompleted = AnalyticsEventPolicy.policy(forEvent: "dictation_completed") diff --git a/Tests/FastTests.manifest b/Tests/FastTests.manifest index 44d9bb1f..4eddd543 100644 --- a/Tests/FastTests.manifest +++ b/Tests/FastTests.manifest @@ -17,6 +17,8 @@ AnalyticsReporterTests.swift:testAnalyticsReporter MeetingFailureKindTests.swift:testMeetingFailureKind ObservabilityPreferencesTests.swift:testObservabilityPreferences ObservabilityLogWriterTests.swift:testObservabilityLogWriter +RuntimeDiagnosticsStoreTests.swift:testRuntimeDiagnosticsStore +SupportDiagnosticsBundleTests.swift:testSupportDiagnosticsBundle MeetingTranscriptStylerTests.swift:testMeetingTranscriptStyler MeetingAudioArchiveResolverTests.swift:testMeetingAudioArchiveResolver MeetingPromptDetectorTests.swift:testMeetingPromptDetector @@ -43,6 +45,7 @@ SentryPayloadSanitizerTests.swift:testSentryPayloadSanitizer SentryEventPolicyTests.swift:testSentryEventPolicy SentryRuntimeConfigurationTests.swift:testSentryRuntimeConfiguration SettingsRecentCaptureRefreshPolicyTests.swift:testSettingsRecentCaptureRefreshPolicy +HomeMeetingPreviewFormatterTests.swift:testHomeMeetingPreviewFormatter TranscriptedConstantsTests.swift:testTranscriptedConstants ParakeetShortAudioGateTests.swift:testParakeetShortAudioGate DictationAudioRecoveryTests.swift:testDictationAudioRecovery diff --git a/Tests/FeedbackIssueBuilderTests.swift b/Tests/FeedbackIssueBuilderTests.swift index 0f2ffda3..815eefc7 100644 --- a/Tests/FeedbackIssueBuilderTests.swift +++ b/Tests/FeedbackIssueBuilderTests.swift @@ -1,47 +1,89 @@ import Foundation func testFeedbackIssueBuilder() { - runSuite("FeedbackIssueBuilder builds a normal GitHub issue URL") { - let url = FeedbackIssueBuilder.issueURL(rawLogLines: [ + runSuite("FeedbackIssueBuilder builds a normal support email URL") { + let url = FeedbackIssueBuilder.emailURL(rawLogLines: [ "[12:00:00.000] APP LAUNCHED | modes: dictation + meetings", "[12:01:00.000] ERROR | wrote /Users/redbars/private.txt for person@example.com via https://example.com/log" ]) - assertNotNil(url, "feedback issue URL should be created") - assertTrue(url?.absoluteString.hasPrefix(FeedbackIssueBuilder.issueURLString) == true, "URL should target GitHub issues/new") - assertTrue((url?.absoluteString.count ?? 0) <= FeedbackIssueBuilder.maxIssueURLCharacterCount, "normal URL should stay under the cap") + assertNotNil(url, "feedback email URL should be created") + assertTrue(url?.absoluteString.hasPrefix(FeedbackIssueBuilder.emailURLString) == true, "URL should target the support email address") + assertTrue((url?.absoluteString.count ?? 0) <= FeedbackIssueBuilder.maxEmailURLCharacterCount, "normal URL should stay under the cap") let body = feedbackBody(from: url) - assertTrue(body.contains("What happened:"), "issue body should include the feedback prompt") - assertTrue(body.contains("APP LAUNCHED"), "short logs should remain in the issue body") + let subject = feedbackSubject(from: url) + assertEqual(subject, "Transcripted Feedback", "email subject should be set") + assertTrue(body.contains("What happened:"), "email body should include the feedback prompt") + assertTrue(body.contains("Diagnostics:"), "email body should include a diagnostics section") + assertTrue(body.contains("APP LAUNCHED"), "short logs should remain in the email body") assertFalse(body.contains("/Users/redbars/"), "paths should be redacted before the URL is built") assertFalse(body.contains("person@example.com"), "emails should be redacted before the URL is built") assertFalse(body.contains("https://example.com/log"), "URLs should be redacted before the URL is built") } - runSuite("FeedbackIssueBuilder trims huge logs before GitHub rejects the URL") { + runSuite("FeedbackIssueBuilder trims huge logs before the feedback email gets too long") { let longLines = (0..<140).map { index in "[15:11:\(String(format: "%02d", index)).000] DIAG | capture.right_option_pressed | marker_\(index) \(String(repeating: "extra diagnostic context ", count: 8))" } - let url = FeedbackIssueBuilder.issueURL(rawLogLines: longLines) + let url = FeedbackIssueBuilder.emailURL(rawLogLines: longLines) - assertNotNil(url, "feedback issue URL should still be created for huge logs") - assertTrue((url?.absoluteString.count ?? 0) <= FeedbackIssueBuilder.maxIssueURLCharacterCount, "huge log URL should be capped") + assertNotNil(url, "feedback email URL should still be created for huge logs") + assertTrue((url?.absoluteString.count ?? 0) <= FeedbackIssueBuilder.maxEmailURLCharacterCount, "huge log URL should be capped") let body = feedbackBody(from: url) - assertTrue(body.contains("Older logs omitted"), "trimmed issue body should explain why older logs are missing") + assertTrue(body.contains("Older logs omitted"), "trimmed email body should explain why older logs are missing") assertTrue(body.contains("marker_139"), "newest useful log lines should be kept") assertFalse(body.contains("marker_0"), "oldest logs should be omitted") } runSuite("FeedbackIssueBuilder keeps only the latest in-app log window") { let lines = (0..<100).map { "[14:00:\($0)] marker_\($0)" } - let url = FeedbackIssueBuilder.issueURL(rawLogLines: lines) + let url = FeedbackIssueBuilder.emailURL(rawLogLines: lines) let body = feedbackBody(from: url) assertFalse(body.contains("marker_0"), "logs older than the latest 80 entries should not be included") assertTrue(body.contains("marker_99"), "latest log entry should be included") } + + runSuite("FeedbackIssueBuilder attaches sanitized diagnostics") { + let url = FeedbackIssueBuilder.emailURL( + rawLogLines: ["[14:00:00.000] APP LAUNCHED"], + diagnostics: "Route bluetooth_input_to_built_in_output from /Users/redbars/private.txt for person@example.com" + ) + let body = feedbackBody(from: url) + + assertTrue(body.contains("bluetooth_input_to_built_in_output"), "safe diagnostic route shape should be included") + assertFalse(body.contains("/Users/redbars"), "diagnostic paths should be redacted") + assertFalse(body.contains("person@example.com"), "diagnostic emails should be redacted") + } + + runSuite("FeedbackIssueBuilder builds contextual capture feedback email") { + let report = FeedbackReport( + sourceKind: "dictation", + referenceID: "abc123", + occurredAt: Date(timeIntervalSince1970: 1_714_000_000), + issueKind: "Wrong words", + userNotes: "It included /Users/redbars/private.txt and person@example.com", + appVersion: "Version 1.2.3", + includeDiagnostics: true + ) + let url = FeedbackIssueBuilder.emailURL(report: report, rawLogLines: [ + "[12:00:00.000] APP LAUNCHED", + "[12:01:00.000] ERROR | https://example.com/log" + ]) + let body = feedbackBody(from: url) + let subject = feedbackSubject(from: url) + + assertEqual(subject, "Transcripted Dictation Feedback", "contextual email subject should include the capture type") + assertTrue(body.contains("Capture:"), "contextual email should include capture metadata") + assertTrue(body.contains("Type: dictation"), "source kind should be included") + assertTrue(body.contains("Issue: Wrong words"), "issue kind should be included") + assertTrue(body.contains("Reference: abc123"), "safe reference should be included") + assertFalse(body.contains("/Users/redbars/"), "user notes should be scrubbed") + assertFalse(body.contains("person@example.com"), "emails in user notes should be scrubbed") + assertFalse(body.contains("https://example.com/log"), "diagnostic URLs should be scrubbed") + } } private func feedbackBody(from url: URL?) -> String { @@ -53,3 +95,13 @@ private func feedbackBody(from url: URL?) -> String { return body } + +private func feedbackSubject(from url: URL?) -> String { + guard let url, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let subject = components.queryItems?.first(where: { $0.name == "subject" })?.value else { + return "" + } + + return subject +} diff --git a/Tests/HomeMeetingPreviewFormatterTests.swift b/Tests/HomeMeetingPreviewFormatterTests.swift new file mode 100644 index 00000000..7958c2fb --- /dev/null +++ b/Tests/HomeMeetingPreviewFormatterTests.swift @@ -0,0 +1,71 @@ +import Foundation + +func testHomeMeetingPreviewFormatter() { + runSuite("HomeMeetingPreviewFormatter parses current styled meeting markdown") { + let content = HomeMeetingPreviewContent.make(from: styledMeetingMarkdown()) + + assertEqual(content.transcriptLines.count, 4, "Styled transcript blocks should become readable rows") + assertEqual(content.transcriptLines.first?.time, "00:00", "First row should keep the timestamp") + assertEqual(content.transcriptLines.first?.speaker, "System/Speaker 1", "First row should keep the source/speaker label") + assertTrue( + content.transcriptLines.first?.text.hasPrefix("touch screen. Yeah.") == true, + "Text on the line after the timestamp should be attached to the row" + ) + assertFalse(content.fallbackText.contains("capture_id:"), "Preview fallback should not expose YAML metadata") + } + + runSuite("HomeMeetingPreviewFormatter parses legacy inline transcript rows") { + let content = HomeMeetingPreviewContent.make(from: legacyInlineMarkdown()) + + assertEqual(content.transcriptLines.count, 2, "Legacy inline transcript rows should still preview cleanly") + assertEqual(content.transcriptLines[0].text, "Hello there.", "Inline text should stay on the row") + assertEqual(content.transcriptLines[1].speaker, "System/Alex", "Obsidian speaker links should be cleaned for preview") + } +} + +private func styledMeetingMarkdown() -> String { + """ + --- + title: "Meeting with Linus" + capture_id: "297F08B7-62AE-4291-9EA3-41EB0B17A64A" + capture_type: meeting + total_word_count: 117 + --- + + # Meeting with Linus + + Recorded Apr 25, 2026 at 12:18 PM • 31 sec • 117 words • 4 turns + + ## Transcript + + **00:00** [System/Speaker 1] + touch screen. Yeah. It actually is that it took us this long. + + **00:00** [Mic/Linus] + Yeah. It actually is and it took us a bit long to get to Or if we have + + **00:12** [Mic/Linus] + Oh + + **00:26** [System/Linus] + Dude, this is incredible. I'm honestly impressed. + """ +} + +private func legacyInlineMarkdown() -> String { + """ + --- + capture_type: meeting + --- + + # Meeting Recording + + ## Full Transcript + + [00:01] [Mic/You] Hello there. + + [00:05] [System/[[Alex]]] Nice to meet you. + + *Generated by Transcripted with Parakeet* + """ +} diff --git a/Tests/RuntimeDiagnosticsStoreTests.swift b/Tests/RuntimeDiagnosticsStoreTests.swift new file mode 100644 index 00000000..9de2a9c1 --- /dev/null +++ b/Tests/RuntimeDiagnosticsStoreTests.swift @@ -0,0 +1,52 @@ +import Foundation + +func testRuntimeDiagnosticsStore() { + runSuite("RuntimeDiagnosticsStore builds unclean shutdown context without raw dates") { + let marker = RuntimeDiagnosticsMarker( + launchID: "launch-1", + appVersion: "1.2.3", + buildVersion: "456", + osMajor: 26, + cleanShutdown: false, + startedAt: Date(timeIntervalSince1970: 1_000), + updatedAt: Date(timeIntervalSince1970: 1_100), + lastEvent: "dictation_recording", + sessionKind: "dictation", + sessionStage: "recording", + sessionActive: true + ) + + let context = RuntimeDiagnosticsStore.contextForUncleanShutdown( + previous: marker, + now: Date(timeIntervalSince1970: 1_220) + ) + + assertEqual(context["app_version"], "1.2.3", "context should keep app version") + assertEqual(context["build_version"], "456", "context should keep build version") + assertEqual(context["heartbeat_age_bucket"], "1_4m", "context should bucket heartbeat age") + assertEqual(context["last_event"], "dictation_recording", "context should keep last runtime event") + assertEqual(context["session_active"], "true", "context should keep whether a session was active") + assertEqual(context["session_kind"], "dictation", "context should keep coarse session kind") + assertEqual(context["session_stage"], "recording", "context should keep coarse session stage") + } + + runSuite("RuntimeDiagnosticsStore round-trips marker files") { + let url = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("RuntimeDiagnosticsStoreTests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent(RuntimeDiagnosticsStore.markerFileName) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let marker = RuntimeDiagnosticsStore.makeLaunchMarker( + launchID: "roundtrip", + appVersion: "1.0", + buildVersion: "7", + osMajor: 26, + now: Date(timeIntervalSince1970: 100) + ) + + RuntimeDiagnosticsStore.save(marker, to: url) + let loaded = RuntimeDiagnosticsStore.load(from: url) + + assertEqual(loaded, marker, "runtime diagnostics marker should round-trip through JSON") + } +} diff --git a/Tests/SentryEventPolicyTests.swift b/Tests/SentryEventPolicyTests.swift index 4893754e..610720ee 100644 --- a/Tests/SentryEventPolicyTests.swift +++ b/Tests/SentryEventPolicyTests.swift @@ -6,6 +6,14 @@ func testSentryEventPolicy() { forEngine: "parakeet", event: "transcription_failed" ) + let uncleanShutdown = SentryEventPolicy.policy( + forEngine: "app", + event: "unclean_shutdown_detected" + ) + let sessionStall = SentryEventPolicy.policy( + forEngine: "app", + event: "session_stall_detected" + ) let hotkeyFailure = SentryEventPolicy.policy( forEngine: "capture", event: "hotkey_register_failed" @@ -18,6 +26,14 @@ func testSentryEventPolicy() { forEngine: "dictation", event: "microphone_start_timeout" ) + let deviceRecoveryTimeout = SentryEventPolicy.policy( + forEngine: "parakeet", + event: "device_change_recovery_timeout" + ) + let recordingInterrupted = SentryEventPolicy.policy( + forEngine: "parakeet", + event: "recording_interrupted" + ) let meetingStartFailed = SentryEventPolicy.policy( forEngine: "meeting", event: "meeting_start_failed" @@ -48,9 +64,13 @@ func testSentryEventPolicy() { ) assertEqual(transcriptionFailure?.summary, "Speech transcription failed.", "transcription failure should use the normalized summary") + assertEqual(uncleanShutdown?.summary, "Previous app session did not shut down cleanly.", "unclean shutdown reports should be visible in Sentry") + assertEqual(sessionStall?.summary, "Transcripted detected a stalled runtime session.", "session stalls should be visible in Sentry") assertEqual(hotkeyFailure?.summary, "Transcripted could not register a keyboard shortcut.", "capture failure should stay allowlisted") assertEqual(audioStartFailure?.summary, "Speech audio engine failed to start.", "audio-start failures should stay allowlisted with a privacy-safe summary") assertEqual(microphoneStartTimeout?.summary, "Dictation microphone start timed out.", "microphone start timeouts should be visible in Sentry without raw device names") + assertEqual(deviceRecoveryTimeout?.summary, "Speech engine device-change recovery timed out.", "device recovery timeouts should be visible in Sentry with privacy-safe route context") + assertEqual(recordingInterrupted?.summary, "Dictation recording was interrupted by audio device recovery.", "recording interruptions should be visible in Sentry with privacy-safe route context") assertEqual(meetingStartFailed?.summary, "Meeting recording could not start.", "meeting start failures should be visible without raw device names") assertEqual(meetingCaptureDegraded?.summary, "Meeting capture health degraded.", "degraded meeting capture should be visible without raw device names") assertEqual(meetingStopTimeout?.summary, "Meeting recording stop timed out.", "stop timeouts should be visible without raw device names") diff --git a/Tests/SettingsRecentCaptureRefreshPolicyTests.swift b/Tests/SettingsRecentCaptureRefreshPolicyTests.swift index 8f41d383..b305ca69 100644 --- a/Tests/SettingsRecentCaptureRefreshPolicyTests.swift +++ b/Tests/SettingsRecentCaptureRefreshPolicyTests.swift @@ -23,7 +23,7 @@ func testSettingsRecentCaptureRefreshPolicy() { } runSuite("SettingsRecentCaptureRefreshPolicy.mode — skips recent capture work on non-list pages") { - for page in [TranscriptedSettingsPage.general, .models, .shortcuts, .people, .storage, .connectAgent, .privacy, .about] { + for page in [TranscriptedSettingsPage.general, .models, .shortcuts, .people, .storage, .connectAgent, .privacy, .support, .about] { assertEqual( SettingsRecentCaptureRefreshPolicy.mode(for: page), .none, diff --git a/Tests/SupportDiagnosticsBundleTests.swift b/Tests/SupportDiagnosticsBundleTests.swift new file mode 100644 index 00000000..481b77b8 --- /dev/null +++ b/Tests/SupportDiagnosticsBundleTests.swift @@ -0,0 +1,76 @@ +import Foundation + +func testSupportDiagnosticsBundle() { + runSuite("SupportDiagnosticsBundle text excludes obvious sensitive values") { + let snapshot = SupportDiagnosticsSnapshot( + appVersion: "1.2.3", + buildVersion: "456", + osVersion: "Version 26.0", + crashReportingAvailable: true, + crashReportingEnabled: true, + analyticsAvailable: true, + analyticsEnabled: false, + microphoneStatus: "authorized", + systemAudioRecordingGranted: true, + pastebackGranted: true, + calendarGranted: false, + audioRoute: [ + "input_device_class": "bluetooth", + "input_rate_hz": "24000", + "route_shape": "bluetooth_input_to_built_in_output", + ], + runtime: [ + "session_kind": "dictation", + "session_stage": "recording", + "session_active": "true", + ], + meetingState: "ready", + meetingRecording: false, + meetingDurationBucket: "lt_10s", + recentLogLines: [ + "Opened /Users/redbars/Library/Application Support/Transcripted/logs/app.jsonl", + "Email person@example.com should not leak", + ] + ) + + let text = SupportDiagnosticsBundle.text( + snapshot: snapshot, + now: Date(timeIntervalSince1970: 100) + ) + + assertTrue(text.contains("Version: 1.2.3"), "diagnostics should include app version") + assertTrue(text.contains("input_device_class: bluetooth"), "diagnostics should include coarse route facts") + assertTrue(text.contains("session_stage: recording"), "diagnostics should include runtime session stage") + assertFalse(text.contains("/Users/redbars"), "diagnostics should redact home paths") + assertFalse(text.contains("person@example.com"), "diagnostics should redact emails") + assertFalse(text.contains("Application Support/Transcripted"), "diagnostics should redact app support paths") + } + + runSuite("SupportDiagnosticsBundle Sentry context avoids sanitizer-dropped audio keys") { + let snapshot = SupportDiagnosticsSnapshot( + appVersion: "1.2.3", + buildVersion: "456", + osVersion: "Version 26.0", + crashReportingAvailable: true, + crashReportingEnabled: true, + analyticsAvailable: true, + analyticsEnabled: true, + microphoneStatus: "authorized", + systemAudioRecordingGranted: true, + pastebackGranted: true, + calendarGranted: true, + audioRoute: ["input_device_class": "bluetooth"], + runtime: ["session_stage": "recording"], + meetingState: "recording", + meetingRecording: true, + meetingDurationBucket: "2_9m", + recentLogLines: [] + ) + + let context = SupportDiagnosticsBundle.sentryContext(snapshot: snapshot) + let sanitized = SentryPayloadSanitizer.sanitizeContext(context) + + assertEqual(sanitized["route_input_device_class"], "bluetooth", "route context should survive Sentry key sanitization") + assertNil(sanitized["audio_input_device_class"], "support diagnostics should not use audio-prefixed Sentry keys") + } +} diff --git a/docs/storage-paths.md b/docs/storage-paths.md index 00e904e4..cfc1065e 100644 --- a/docs/storage-paths.md +++ b/docs/storage-paths.md @@ -40,6 +40,7 @@ App-owned meeting state is stored separately under: - speaker DB: `~/Library/Application Support/Transcripted/state/speakers.sqlite` - stats DB: `~/Library/Application Support/Transcripted/state/stats.sqlite` - failed queue: `~/Library/Application Support/Transcripted/state/failed_transcriptions.json` +- runtime diagnostics marker: `~/Library/Application Support/Transcripted/state/runtime-diagnostics.json` Claude Desktop integration installs the bundled read-only MCP helper under: diff --git a/scripts/entrypoints/run-tests.sh b/scripts/entrypoints/run-tests.sh index 495f3524..4c9f16aa 100755 --- a/scripts/entrypoints/run-tests.sh +++ b/scripts/entrypoints/run-tests.sh @@ -159,6 +159,7 @@ APP_SOURCES=( "Sources/Observability/AnalyticsPayloadSanitizer.swift" "Sources/Observability/AnalyticsPreferences.swift" "Sources/Observability/CrashReportingPreferences.swift" + "Sources/Observability/RuntimeDiagnosticsStore.swift" "Sources/Observability/UpdateFailureKind.swift" "Sources/Observability/SentryRuntimeConfiguration.swift" "Sources/Observability/SentryEventPolicy.swift" @@ -171,10 +172,12 @@ APP_SOURCES=( "Sources/TranscriptedCore/Speaker/SpeakerNamingPolicy.swift" "Sources/UI/Shared/AgentConnectionGuide.swift" "Sources/UI/Shared/FeedbackIssueBuilder.swift" + "Sources/UI/Shared/SupportDiagnosticsBundle.swift" "Sources/UI/Shared/FirstRunExperience.swift" "Sources/UI/Shared/AppSoundPlayer.swift" "Sources/UI/Settings/TranscriptedSettingsPage.swift" "Sources/UI/Settings/SettingsRecentCaptureRefreshPolicy.swift" + "Sources/UI/Settings/HomeMeetingPreviewFormatter.swift" "Sources/UI/Overlay/DictationMeterPolicy.swift" "Sources/UI/Shared/MeetingAudioArchiveResolver.swift" "Sources/UI/Shared/MeetingAudioPlayback.swift"