Add copy device information feature
This commit is contained in:
parent
4361597ee9
commit
3eed3e2f6b
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
194
VoiceInk/Services/SystemInfoService.swift
Normal file
194
VoiceInk/Services/SystemInfoService.swift
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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()
|
||||
}) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user