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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions speaktype/Services/SupportInfoService.swift
Original file line number Diff line number Diff line change
@@ -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())
}
}
44 changes: 44 additions & 0 deletions speaktype/Views/Screens/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down