diff --git a/VoiceInk/EmailSupport.swift b/VoiceInk/EmailSupport.swift index 893600b..aae12dd 100644 --- a/VoiceInk/EmailSupport.swift +++ b/VoiceInk/EmailSupport.swift @@ -5,35 +5,29 @@ import AppKit struct EmailSupport { static func generateSupportEmailURL() -> URL? { let subject = "VoiceInk Support Request" - let systemInfo = """ - App Version: \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown") - macOS Version: \(ProcessInfo.processInfo.operatingSystemVersionString) - Device: \(getMacModel()) - CPU: \(getCPUInfo()) - Memory: \(getMemoryInfo()) - """ - + let systemInfo = SystemInfoService.shared.getSystemInfoString() + let body = """ - + ------------------------ ✨ **SCREEN RECORDING HIGHLY RECOMMENDED** ✨ ▶️ Create a quick screen recording showing the issue! ▶️ It helps me understand and fix the problem much faster. - + 📝 ISSUE DETAILS: - What steps did you take before the issue occurred? - What did you expect to happen? - What actually happened instead? - - + + ## 📋 COMMON ISSUES: Check out our Common Issues page before sending an email: https://tryvoiceink.com/common-issues ------------------------ - + System Information: \(systemInfo) - + """ let encodedSubject = subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" @@ -48,25 +42,5 @@ struct EmailSupport { } } - private static func getMacModel() -> String { - var size = 0 - sysctlbyname("hw.model", nil, &size, nil, 0) - var machine = [CChar](repeating: 0, count: size) - sysctlbyname("hw.model", &machine, &size, nil, 0) - return String(cString: machine) - } - - private static func getCPUInfo() -> String { - var size = 0 - sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0) - var buffer = [CChar](repeating: 0, count: size) - sysctlbyname("machdep.cpu.brand_string", &buffer, &size, nil, 0) - return String(cString: buffer) - } - - private static func getMemoryInfo() -> String { - let totalMemory = ProcessInfo.processInfo.physicalMemory - return ByteCountFormatter.string(fromByteCount: Int64(totalMemory), countStyle: .memory) - } } \ No newline at end of file diff --git a/VoiceInk/Services/SystemInfoService.swift b/VoiceInk/Services/SystemInfoService.swift new file mode 100644 index 0000000..a18b094 --- /dev/null +++ b/VoiceInk/Services/SystemInfoService.swift @@ -0,0 +1,194 @@ +import Foundation +import AppKit +import AVFoundation + +class SystemInfoService { + static let shared = SystemInfoService() + + private init() {} + + func getSystemInfoString() -> String { + let info = """ + === VOICEINK SYSTEM INFORMATION === + Generated: \(Date().formatted(date: .long, time: .standard)) + + APP INFORMATION: + App Version: \(getAppVersion()) + Build Version: \(getBuildVersion()) + + OPERATING SYSTEM: + macOS Version: \(ProcessInfo.processInfo.operatingSystemVersionString) + + HARDWARE INFORMATION: + Device Model: \(getMacModel()) + CPU: \(getCPUInfo()) + Memory: \(getMemoryInfo()) + Architecture: \(getArchitecture()) + + AUDIO SETTINGS: + Input Mode: \(getAudioInputMode()) + Current Audio Device: \(getCurrentAudioDevice()) + Available Audio Devices: \(getAvailableAudioDevices()) + + HOTKEY SETTINGS: + Primary Hotkey: \(getPrimaryHotkey()) + Secondary Hotkey: \(getSecondaryHotkey()) + + TRANSCRIPTION SETTINGS: + Current Model: \(getCurrentTranscriptionModel()) + AI Enhancement: \(getAIEnhancementStatus()) + AI Provider: \(getAIProvider()) + AI Model: \(getAIModel()) + + PERMISSIONS: + Accessibility: \(getAccessibilityStatus()) + Screen Recording: \(getScreenRecordingStatus()) + Microphone: \(getMicrophoneStatus()) + """ + + return info + } + + func copySystemInfoToClipboard() { + let info = getSystemInfoString() + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(info, forType: .string) + } + + private func getAppVersion() -> String { + return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + } + + private func getBuildVersion() -> String { + return Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" + } + + private func getMacModel() -> String { + var size = 0 + sysctlbyname("hw.model", nil, &size, nil, 0) + var machine = [CChar](repeating: 0, count: size) + sysctlbyname("hw.model", &machine, &size, nil, 0) + return String(cString: machine) + } + + private func getCPUInfo() -> String { + var size = 0 + sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0) + var buffer = [CChar](repeating: 0, count: size) + sysctlbyname("machdep.cpu.brand_string", &buffer, &size, nil, 0) + return String(cString: buffer) + } + + private func getMemoryInfo() -> String { + let totalMemory = ProcessInfo.processInfo.physicalMemory + return ByteCountFormatter.string(fromByteCount: Int64(totalMemory), countStyle: .memory) + } + + private func getArchitecture() -> String { + #if arch(x86_64) + return "Intel x86_64" + #elseif arch(arm64) + return "Apple Silicon (ARM64)" + #else + return "Unknown" + #endif + } + + private func getAudioInputMode() -> String { + if let mode = UserDefaults.standard.audioInputModeRawValue, + let audioMode = AudioInputMode(rawValue: mode) { + return audioMode.rawValue + } + return "System Default" + } + + private func getCurrentAudioDevice() -> String { + let audioManager = AudioDeviceManager.shared + if let deviceID = audioManager.selectedDeviceID ?? audioManager.fallbackDeviceID, + let deviceName = audioManager.getDeviceName(deviceID: deviceID) { + return deviceName + } + return "System Default" + } + + private func getAvailableAudioDevices() -> String { + let devices = AudioDeviceManager.shared.availableDevices + if devices.isEmpty { + return "None detected" + } + return devices.map { $0.name }.joined(separator: ", ") + } + + private func getPrimaryHotkey() -> String { + if let hotkeyRaw = UserDefaults.standard.string(forKey: "selectedHotkey1"), + let hotkey = HotkeyManager.HotkeyOption(rawValue: hotkeyRaw) { + return hotkey.displayName + } + return "Right Command" + } + + private func getSecondaryHotkey() -> String { + if let hotkeyRaw = UserDefaults.standard.string(forKey: "selectedHotkey2"), + let hotkey = HotkeyManager.HotkeyOption(rawValue: hotkeyRaw) { + return hotkey.displayName + } + return "None" + } + + private func getCurrentTranscriptionModel() -> String { + if let modelName = UserDefaults.standard.string(forKey: "CurrentTranscriptionModel") { + if let model = PredefinedModels.models.first(where: { $0.name == modelName }) { + return model.displayName + } + return modelName + } + return "No model selected" + } + + private func getAIEnhancementStatus() -> String { + let enhancementEnabled = UserDefaults.standard.bool(forKey: "isAIEnhancementEnabled") + return enhancementEnabled ? "Enabled" : "Disabled" + } + + private func getAIProvider() -> String { + if let providerRaw = UserDefaults.standard.string(forKey: "selectedAIProvider") { + return providerRaw + } + return "None selected" + } + + private func getAIModel() -> String { + if let providerRaw = UserDefaults.standard.string(forKey: "selectedAIProvider") { + let modelKey = "\(providerRaw)SelectedModel" + if let savedModel = UserDefaults.standard.string(forKey: modelKey), !savedModel.isEmpty { + return savedModel + } + return "Default (\(providerRaw))" + } + return "None selected" + } + private func getAccessibilityStatus() -> String { + return AXIsProcessTrusted() ? "Granted" : "Not Granted" + } + + private func getScreenRecordingStatus() -> String { + return CGPreflightScreenCaptureAccess() ? "Granted" : "Not Granted" + } + + private func getMicrophoneStatus() -> String { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + return "Granted" + case .denied: + return "Denied" + case .restricted: + return "Restricted" + case .notDetermined: + return "Not Determined" + @unknown default: + return "Unknown" + } + } + +} diff --git a/VoiceInk/Views/LicenseManagementView.swift b/VoiceInk/Views/LicenseManagementView.swift index 426d27c..9c87f7e 100644 --- a/VoiceInk/Views/LicenseManagementView.swift +++ b/VoiceInk/Views/LicenseManagementView.swift @@ -48,13 +48,13 @@ struct LicenseManagementView: View { } } - Text(licenseViewModel.licenseState == .licensed ? + Text(licenseViewModel.licenseState == .licensed ? "Thank you for supporting VoiceInk" : "Transcribe what you say to text instantly with AI") .font(.title3) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - + if case .licensed = licenseViewModel.licenseState { HStack(spacing: 40) { Button { @@ -190,13 +190,13 @@ struct LicenseManagementView: View { VStack(spacing: 20) { Text("Already purchased?") .font(.headline) - + HStack(spacing: 12) { Text("Manage your license and device activations") .font(.subheadline) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) - + Button(action: { if let url = URL(string: "https://polar.sh/beingpax/portal/request") { NSWorkspace.shared.open(url) @@ -253,7 +253,7 @@ struct LicenseManagementView: View { VStack(alignment: .leading, spacing: 16) { Text("License Management") .font(.headline) - + Button(role: .destructive, action: { licenseViewModel.removeLicense() }) { diff --git a/VoiceInk/Views/Metrics/TimeEfficiencyView.swift b/VoiceInk/Views/Metrics/TimeEfficiencyView.swift index c58800c..61b64d5 100644 --- a/VoiceInk/Views/Metrics/TimeEfficiencyView.swift +++ b/VoiceInk/Views/Metrics/TimeEfficiencyView.swift @@ -113,37 +113,40 @@ struct TimeEfficiencyView: View { } private var reportIssueButton: some View { - Button(action: { - EmailSupport.openSupportEmail() - }) { - HStack(alignment: .center, spacing: 12) { - // Left icon - Image(systemName: "exclamationmark.bubble.fill") - .font(.system(size: 20, weight: .medium)) - .foregroundStyle(.white) - - // Center text - Text("Feedback or Issues?") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.white) - - Spacer(minLength: 8) - - // Right button - Text("Report") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(Color.accentColor) - .padding(.horizontal, 12) - .padding(.vertical, 4) - .background(Capsule().fill(.white)) + ZStack { + Button(action: { + EmailSupport.openSupportEmail() + }) { + HStack(alignment: .center, spacing: 12) { + // Left icon + Image(systemName: "exclamationmark.bubble.fill") + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(.white) + + // Center text + Text("Feedback or Issues?") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white) + + Spacer(minLength: 8) + } + .padding(.vertical, 10) + .padding(.horizontal, 12) + .background(accentGradient) + .cornerRadius(10) } - .padding(.vertical, 10) - .padding(.horizontal, 12) - .background(accentGradient) - .cornerRadius(10) + .buttonStyle(.plain) + .shadow(color: Color.accentColor.opacity(0.2), radius: 3, y: 1) + .frame(maxWidth: 280) + + // Copy system info button overlaid and centered vertically + HStack { + Spacer() + CopySystemInfoButton() + .padding(.trailing, 8) + } + .frame(maxWidth: 280) } - .buttonStyle(.plain) - .shadow(color: Color.accentColor.opacity(0.2), radius: 3, y: 1) .frame(maxWidth: 280) } @@ -220,3 +223,45 @@ struct TimeBlockView: View { ) } } + +// MARK: - Copy System Info Button +private struct CopySystemInfoButton: View { + @State private var isCopied: Bool = false + + var body: some View { + Button(action: { + copySystemInfo() + }) { + Image(systemName: isCopied ? "checkmark" : "doc.on.doc") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.9)) + .padding(8) + .background( + Circle() + .fill(Color.white.opacity(0.2)) + .overlay( + Circle() + .stroke(Color.white.opacity(0.3), lineWidth: 1) + ) + ) + .rotationEffect(.degrees(isCopied ? 360 : 0)) + } + .buttonStyle(.plain) + .scaleEffect(isCopied ? 1.1 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isCopied) + } + + private func copySystemInfo() { + SystemInfoService.shared.copySystemInfoToClipboard() + + withAnimation { + isCopied = true + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { + isCopied = false + } + } + } +}