478 lines
21 KiB
Swift
478 lines
21 KiB
Swift
import SwiftUI
|
|
import AVFoundation
|
|
import AppKit
|
|
import KeyboardShortcuts
|
|
|
|
struct OnboardingPermission: Identifiable {
|
|
let id = UUID()
|
|
let title: String
|
|
let description: String
|
|
let icon: String
|
|
let type: PermissionType
|
|
|
|
enum PermissionType {
|
|
case microphone
|
|
case audioDeviceSelection
|
|
case accessibility
|
|
case screenRecording
|
|
case keyboardShortcut
|
|
|
|
var systemName: String {
|
|
switch self {
|
|
case .microphone: return "mic"
|
|
case .audioDeviceSelection: return "headphones"
|
|
case .accessibility: return "accessibility"
|
|
case .screenRecording: return "rectangle.inset.filled.and.person.filled"
|
|
case .keyboardShortcut: return "keyboard"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct OnboardingPermissionsView: View {
|
|
@Binding var hasCompletedOnboarding: Bool
|
|
@EnvironmentObject private var hotkeyManager: HotkeyManager
|
|
@ObservedObject private var audioDeviceManager = AudioDeviceManager.shared
|
|
@State private var currentPermissionIndex = 0
|
|
@State private var permissionStates: [Bool] = [false, false, false, false, false]
|
|
@State private var showAnimation = false
|
|
@State private var scale: CGFloat = 0.8
|
|
@State private var opacity: CGFloat = 0
|
|
@State private var showModelDownload = false
|
|
|
|
private let permissions: [OnboardingPermission] = [
|
|
OnboardingPermission(
|
|
title: "Microphone Access",
|
|
description: "Enable your microphone to start speaking and converting your voice to text instantly.",
|
|
icon: "waveform",
|
|
type: .microphone
|
|
),
|
|
OnboardingPermission(
|
|
title: "Microphone Selection",
|
|
description: "Select the audio input device you want to use with VoiceInk.",
|
|
icon: "headphones",
|
|
type: .audioDeviceSelection
|
|
),
|
|
OnboardingPermission(
|
|
title: "Accessibility Access",
|
|
description: "Allow VoiceInk to help you type anywhere in your Mac.",
|
|
icon: "accessibility",
|
|
type: .accessibility
|
|
),
|
|
OnboardingPermission(
|
|
title: "Screen Recording",
|
|
description: "This helps to improve the accuracy of transcription.",
|
|
icon: "rectangle.inset.filled.and.person.filled",
|
|
type: .screenRecording
|
|
),
|
|
OnboardingPermission(
|
|
title: "Keyboard Shortcut",
|
|
description: "Set up a keyboard shortcut to quickly access VoiceInk from anywhere.",
|
|
icon: "keyboard",
|
|
type: .keyboardShortcut
|
|
)
|
|
]
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
GeometryReader { geometry in
|
|
ZStack {
|
|
// Reusable background
|
|
OnboardingBackgroundView()
|
|
|
|
VStack(spacing: 40) {
|
|
// Progress indicator
|
|
HStack(spacing: 8) {
|
|
ForEach(0..<permissions.count, id: \.self) { index in
|
|
Circle()
|
|
.fill(index <= currentPermissionIndex ? Color.accentColor : Color.white.opacity(0.1))
|
|
.frame(width: 8, height: 8)
|
|
.scaleEffect(index == currentPermissionIndex ? 1.2 : 1.0)
|
|
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: currentPermissionIndex)
|
|
}
|
|
}
|
|
.padding(.top, 40)
|
|
|
|
// Current permission card
|
|
VStack(spacing: 30) {
|
|
// Permission icon
|
|
ZStack {
|
|
Circle()
|
|
.fill(Color.accentColor.opacity(0.1))
|
|
.frame(width: 100, height: 100)
|
|
|
|
if permissionStates[currentPermissionIndex] {
|
|
Image(systemName: "checkmark.seal.fill")
|
|
.font(.system(size: 50))
|
|
.foregroundColor(.accentColor)
|
|
.transition(.scale.combined(with: .opacity))
|
|
} else {
|
|
Image(systemName: permissions[currentPermissionIndex].icon)
|
|
.font(.system(size: 40))
|
|
.foregroundColor(.accentColor)
|
|
}
|
|
}
|
|
.scaleEffect(scale)
|
|
.opacity(opacity)
|
|
|
|
// Permission text
|
|
VStack(spacing: 12) {
|
|
Text(permissions[currentPermissionIndex].title)
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(.white)
|
|
|
|
Text(permissions[currentPermissionIndex].description)
|
|
.font(.body)
|
|
.foregroundColor(.white.opacity(0.7))
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
}
|
|
.scaleEffect(scale)
|
|
.opacity(opacity)
|
|
|
|
// Audio device selection (only shown for audio device selection step)
|
|
if permissions[currentPermissionIndex].type == .audioDeviceSelection {
|
|
VStack(spacing: 20) {
|
|
if audioDeviceManager.availableDevices.isEmpty {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "mic.slash.circle.fill")
|
|
.font(.system(size: 36))
|
|
.symbolRenderingMode(.hierarchical)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text("No microphones found")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding()
|
|
} else {
|
|
Picker("Select Microphone", selection: Binding(
|
|
get: {
|
|
audioDeviceManager.selectedDeviceID ??
|
|
audioDeviceManager.availableDevices.first?.id ?? 0
|
|
},
|
|
set: { newValue in
|
|
audioDeviceManager.selectDevice(id: newValue)
|
|
audioDeviceManager.selectInputMode(.custom)
|
|
withAnimation {
|
|
permissionStates[currentPermissionIndex] = true
|
|
showAnimation = true
|
|
}
|
|
}
|
|
)) {
|
|
ForEach(audioDeviceManager.availableDevices, id: \.id) { device in
|
|
Text(device.name).tag(device.id)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
.labelsHidden()
|
|
.frame(maxWidth: 300)
|
|
.padding(8)
|
|
.background(Color.white.opacity(0.1))
|
|
.cornerRadius(8)
|
|
.onAppear {
|
|
// Auto-select built-in microphone if no device is selected
|
|
if audioDeviceManager.selectedDeviceID == nil && !audioDeviceManager.availableDevices.isEmpty {
|
|
let builtInDevice = audioDeviceManager.availableDevices.first { device in
|
|
device.name.lowercased().contains("built-in") ||
|
|
device.name.lowercased().contains("internal")
|
|
}
|
|
let deviceToSelect = builtInDevice ?? audioDeviceManager.availableDevices.first
|
|
if let device = deviceToSelect {
|
|
audioDeviceManager.selectDevice(id: device.id)
|
|
audioDeviceManager.selectInputMode(.custom)
|
|
withAnimation {
|
|
permissionStates[currentPermissionIndex] = true
|
|
showAnimation = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Text("For best results, using your Mac's built-in microphone is recommended.")
|
|
.font(.caption)
|
|
.foregroundColor(.white.opacity(0.7))
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal)
|
|
}
|
|
.scaleEffect(scale)
|
|
.opacity(opacity)
|
|
}
|
|
|
|
// Keyboard shortcut recorder (only shown for keyboard shortcut step)
|
|
if permissions[currentPermissionIndex].type == .keyboardShortcut {
|
|
hotkeyView(
|
|
binding: $hotkeyManager.selectedHotkey1,
|
|
shortcutName: .toggleMiniRecorder
|
|
) { isConfigured in
|
|
withAnimation {
|
|
permissionStates[currentPermissionIndex] = isConfigured
|
|
showAnimation = isConfigured
|
|
}
|
|
}
|
|
.scaleEffect(scale)
|
|
.opacity(opacity)
|
|
}
|
|
}
|
|
.frame(maxWidth: 400)
|
|
.padding(.vertical, 40)
|
|
|
|
// Action buttons
|
|
VStack(spacing: 16) {
|
|
Button(action: requestPermission) {
|
|
Text(getButtonTitle())
|
|
.font(.headline)
|
|
.foregroundColor(.white)
|
|
.frame(width: 200, height: 50)
|
|
.background(Color.accentColor)
|
|
.cornerRadius(25)
|
|
}
|
|
.buttonStyle(ScaleButtonStyle())
|
|
|
|
if !permissionStates[currentPermissionIndex] &&
|
|
permissions[currentPermissionIndex].type != .keyboardShortcut &&
|
|
permissions[currentPermissionIndex].type != .audioDeviceSelection {
|
|
SkipButton(text: "Skip for now") {
|
|
moveToNext()
|
|
}
|
|
}
|
|
}
|
|
.opacity(opacity)
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
if showModelDownload {
|
|
OnboardingModelDownloadView(hasCompletedOnboarding: $hasCompletedOnboarding)
|
|
.transition(.move(edge: .trailing).combined(with: .opacity))
|
|
}
|
|
}
|
|
.onAppear {
|
|
checkExistingPermissions()
|
|
animateIn()
|
|
// Ensure audio devices are loaded
|
|
audioDeviceManager.loadAvailableDevices()
|
|
}
|
|
}
|
|
|
|
private func animateIn() {
|
|
withAnimation(.spring(response: 0.6, dampingFraction: 0.7)) {
|
|
scale = 1
|
|
opacity = 1
|
|
}
|
|
}
|
|
|
|
private func resetAnimation() {
|
|
scale = 0.8
|
|
opacity = 0
|
|
animateIn()
|
|
}
|
|
|
|
private func checkExistingPermissions() {
|
|
// Check microphone permission
|
|
permissionStates[0] = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
|
|
|
// Check if device is selected or system default mode is being used
|
|
permissionStates[1] = audioDeviceManager.selectedDeviceID != nil || audioDeviceManager.inputMode == .systemDefault
|
|
|
|
// Check accessibility permission
|
|
permissionStates[2] = AXIsProcessTrusted()
|
|
|
|
// Check screen recording permission
|
|
permissionStates[3] = CGPreflightScreenCaptureAccess()
|
|
|
|
// Check keyboard shortcut
|
|
permissionStates[4] = hotkeyManager.isShortcutConfigured
|
|
}
|
|
|
|
private func requestPermission() {
|
|
if permissionStates[currentPermissionIndex] {
|
|
moveToNext()
|
|
return
|
|
}
|
|
|
|
switch permissions[currentPermissionIndex].type {
|
|
case .microphone:
|
|
AVCaptureDevice.requestAccess(for: .audio) { granted in
|
|
DispatchQueue.main.async {
|
|
self.permissionStates[self.currentPermissionIndex] = granted
|
|
if granted {
|
|
withAnimation {
|
|
self.showAnimation = true
|
|
}
|
|
self.audioDeviceManager.loadAvailableDevices()
|
|
}
|
|
}
|
|
}
|
|
|
|
case .audioDeviceSelection:
|
|
audioDeviceManager.loadAvailableDevices()
|
|
|
|
if audioDeviceManager.availableDevices.isEmpty {
|
|
audioDeviceManager.selectInputMode(.systemDefault)
|
|
withAnimation {
|
|
permissionStates[currentPermissionIndex] = true
|
|
showAnimation = true
|
|
}
|
|
moveToNext()
|
|
return
|
|
}
|
|
|
|
// If no device is selected yet, auto-select the built-in microphone or first available device
|
|
if audioDeviceManager.selectedDeviceID == nil {
|
|
let builtInDevice = audioDeviceManager.availableDevices.first { device in
|
|
device.name.lowercased().contains("built-in") ||
|
|
device.name.lowercased().contains("internal")
|
|
}
|
|
|
|
let deviceToSelect = builtInDevice ?? audioDeviceManager.availableDevices.first
|
|
|
|
if let device = deviceToSelect {
|
|
audioDeviceManager.selectDevice(id: device.id)
|
|
audioDeviceManager.selectInputMode(.custom)
|
|
withAnimation {
|
|
permissionStates[currentPermissionIndex] = true
|
|
showAnimation = true
|
|
}
|
|
}
|
|
}
|
|
moveToNext()
|
|
|
|
case .accessibility:
|
|
let options: NSDictionary = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true]
|
|
AXIsProcessTrustedWithOptions(options)
|
|
|
|
// Start checking for permission status
|
|
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
|
|
if AXIsProcessTrusted() {
|
|
timer.invalidate()
|
|
permissionStates[currentPermissionIndex] = true
|
|
withAnimation {
|
|
showAnimation = true
|
|
}
|
|
}
|
|
}
|
|
|
|
case .screenRecording:
|
|
// First try to request permission programmatically
|
|
CGRequestScreenCaptureAccess()
|
|
|
|
// Also open system preferences as fallback
|
|
if let prefpaneURL = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
|
|
NSWorkspace.shared.open(prefpaneURL)
|
|
}
|
|
|
|
// Start checking for permission status
|
|
Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { timer in
|
|
if CGPreflightScreenCaptureAccess() {
|
|
timer.invalidate()
|
|
permissionStates[currentPermissionIndex] = true
|
|
withAnimation {
|
|
showAnimation = true
|
|
}
|
|
}
|
|
}
|
|
|
|
case .keyboardShortcut:
|
|
// The keyboard shortcut is handled by the KeyboardShortcuts.Recorder
|
|
break
|
|
}
|
|
}
|
|
|
|
private func moveToNext() {
|
|
if currentPermissionIndex < permissions.count - 1 {
|
|
withAnimation {
|
|
currentPermissionIndex += 1
|
|
resetAnimation()
|
|
}
|
|
} else {
|
|
withAnimation {
|
|
showModelDownload = true
|
|
}
|
|
}
|
|
}
|
|
|
|
private func getButtonTitle() -> String {
|
|
switch permissions[currentPermissionIndex].type {
|
|
case .keyboardShortcut:
|
|
return permissionStates[currentPermissionIndex] ? "Continue" : "Set Shortcut"
|
|
case .audioDeviceSelection:
|
|
return "Continue"
|
|
default:
|
|
return permissionStates[currentPermissionIndex] ? "Continue" : "Enable Access"
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func hotkeyView(
|
|
binding: Binding<HotkeyManager.HotkeyOption>,
|
|
shortcutName: KeyboardShortcuts.Name,
|
|
onConfigured: @escaping (Bool) -> Void
|
|
) -> some View {
|
|
VStack(spacing: 16) {
|
|
HStack(spacing: 12) {
|
|
Spacer()
|
|
|
|
Text("Shortcut:")
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundColor(.white.opacity(0.8))
|
|
|
|
Menu {
|
|
ForEach(HotkeyManager.HotkeyOption.allCases, id: \.self) { option in
|
|
if option != .none && option != .custom { // Exclude 'None' and 'Custom' from the list
|
|
Button(action: {
|
|
binding.wrappedValue = option
|
|
onConfigured(option.isModifierKey)
|
|
}) {
|
|
HStack {
|
|
Text(option.displayName)
|
|
if binding.wrappedValue == option {
|
|
Spacer()
|
|
Image(systemName: "checkmark")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Text(binding.wrappedValue.displayName)
|
|
.foregroundColor(.white)
|
|
.font(.system(size: 16, weight: .medium))
|
|
Image(systemName: "chevron.up.chevron.down")
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.white.opacity(0.6))
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.background(Color.white.opacity(0.1))
|
|
.cornerRadius(10)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.stroke(Color.white.opacity(0.2), lineWidth: 1)
|
|
)
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
|
|
Spacer()
|
|
}
|
|
|
|
if binding.wrappedValue == .custom {
|
|
KeyboardShortcuts.Recorder(for: shortcutName) { newShortcut in
|
|
onConfigured(newShortcut != nil)
|
|
}
|
|
.controlSize(.large)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color.white.opacity(0.05))
|
|
.cornerRadius(12)
|
|
.onChange(of: binding.wrappedValue) { newValue in
|
|
onConfigured(newValue != .none)
|
|
}
|
|
}
|
|
}
|