diff --git a/speaktype/Services/SupportInfoService.swift b/speaktype/Services/SupportInfoService.swift new file mode 100644 index 0000000..542a4cf --- /dev/null +++ b/speaktype/Services/SupportInfoService.swift @@ -0,0 +1,104 @@ +import AVFoundation +import ApplicationServices +import Foundation + +final class SupportInfoService { + static let shared = SupportInfoService() + + private init() {} + + @discardableResult + func copySupportInfo() -> String { + let report = generateSupportInfo() + ClipboardService.shared.copy(text: report) + return report + } + + func generateSupportInfo() -> String { + let userDefaults = UserDefaults.standard + let selectedModelVariant = userDefaults.string(forKey: "selectedModelVariant") ?? "" + let selectedModelName = + AIModel.availableModels.first(where: { $0.variant == selectedModelVariant })?.name + ?? (selectedModelVariant.isEmpty ? "None selected" : selectedModelVariant) + let selectedDeviceName = currentInputDeviceName() + let languageCode = userDefaults.string(forKey: "transcriptionLanguage") ?? "auto" + let recordingMode = userDefaults.integer(forKey: "recordingMode") == 0 + ? "Hold to record" : "Toggle" + let showMenuBarIcon = userDefaults.object(forKey: "showMenuBarIcon") == nil + || userDefaults.bool(forKey: "showMenuBarIcon") ? "On" : "Off" + let hotkey = currentHotkeyDisplayName(from: userDefaults) + let microphoneAccess = permissionStatusLabel( + for: AVCaptureDevice.authorizationStatus(for: .audio)) + let accessibilityAccess = AXIsProcessTrusted() ? "Granted" : "Not granted" + let memoryGB = Int(ProcessInfo.processInfo.physicalMemory / (1024 * 1024 * 1024)) + + return """ + SpeakType Support Info + Generated: \(timestampString()) + App version: \(Constants.App.version) (\(Constants.App.build)) + Build timestamp: \(buildTimestamp) + macOS: \(ProcessInfo.processInfo.operatingSystemVersionString) + Memory: \(memoryGB) GB + Default model: \(selectedModelName)\(selectedModelVariant.isEmpty ? "" : " [\(selectedModelVariant)]") + Recording mode: \(recordingMode) + Hotkey: \(hotkey) + Speech language hint: \(languageDisplayName(for: languageCode)) + Show menu bar icon: \(showMenuBarIcon) + Input device: \(selectedDeviceName) + Microphone access: \(microphoneAccess) + Accessibility access: \(accessibilityAccess) + """ + } + + private func currentHotkeyDisplayName(from userDefaults: UserDefaults) -> String { + let rawValue = userDefaults.string(forKey: "selectedHotkey") ?? HotkeyOption.default.rawValue + return HotkeyOption(rawValue: rawValue)?.displayName ?? rawValue + } + + private func currentInputDeviceName() -> String { + let audioRecorder = AudioRecordingService.shared + let discoverySession = AVCaptureDevice.DiscoverySession( + deviceTypes: [.microphone], + mediaType: .audio, + position: .unspecified + ) + let devices = discoverySession.devices.filter { device in + !device.localizedName.localizedCaseInsensitiveContains("Microsoft Teams") + } + + if let selectedDeviceId = audioRecorder.selectedDeviceId, + let device = devices.first(where: { $0.uniqueID == selectedDeviceId }) + { + return device.localizedName + } + + return devices.first?.localizedName ?? "Unknown" + } + + private func languageDisplayName(for code: String) -> String { + guard code != "auto" else { return "Auto-detect" } + let locale = Locale(identifier: "en") + return locale.localizedString(forLanguageCode: code)?.capitalized ?? code + } + + private func permissionStatusLabel(for status: AVAuthorizationStatus) -> String { + switch status { + case .authorized: + return "Granted" + case .denied: + return "Denied" + case .restricted: + return "Restricted" + case .notDetermined: + return "Not determined" + @unknown default: + return "Unknown" + } + } + + private func timestampString() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.string(from: Date()) + } +} diff --git a/speaktype/Views/Screens/Settings/SettingsView.swift b/speaktype/Views/Screens/Settings/SettingsView.swift index 256dd22..40d2050 100644 --- a/speaktype/Views/Screens/Settings/SettingsView.swift +++ b/speaktype/Views/Screens/Settings/SettingsView.swift @@ -109,6 +109,7 @@ struct GeneralSettingsTab: View { @State private var showLicenseSheet = false @State private var showDeactivateAlert = false + @State private var supportInfoCopied = false var body: some View { ScrollView { @@ -326,6 +327,37 @@ struct GeneralSettingsTab: View { } } + SettingsSection { + SettingsSectionHeader( + icon: "ladybug", title: "Support", + subtitle: "Copy app details for bug reports") + + VStack(alignment: .leading, spacing: 12) { + Text( + "If something breaks, copy this block and paste it into your GitHub issue. It includes the app version, macOS version, permissions, selected model, and current recording settings." + ) + .font(Typography.captionSmall) + .foregroundStyle(Color.textMuted) + + Button(action: copySupportInfo) { + HStack(spacing: 8) { + Image(systemName: supportInfoCopied ? "checkmark" : "doc.on.doc") + .font(.system(size: 12)) + Text(supportInfoCopied ? "Copied" : "Copy Support Info") + .font(Typography.labelMedium) + } + .foregroundStyle( + supportInfoCopied ? Color.accentSuccess : Color.textPrimary + ) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(Color.bgHover) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .buttonStyle(.plain) + } + } + // License - Hidden (logic kept for future use) // SettingsSection { // SettingsSectionHeader( @@ -368,6 +400,18 @@ struct GeneralSettingsTab: View { } } + private func copySupportInfo() { + _ = SupportInfoService.shared.copySupportInfo() + supportInfoCopied = true + + Task { + try? await Task.sleep(nanoseconds: 2_000_000_000) + await MainActor.run { + supportInfoCopied = false + } + } + } + private func displayName(for code: String) -> String { if code == "auto" { return "Auto-detect" } return Self.whisperLanguages.first(where: { $0.code == code })?.name ?? code