import AVFoundation import AppKit import ComposableArchitecture import CoreAudio import Dependencies import HexCore import IdentifiedCollections import Sauce import ServiceManagement import SwiftUI private let settingsLogger = HexLog.settings private typealias SettingsAudioPropertyListenerBlock = @convention(block) (UInt32, UnsafePointer) -> Void private enum HotKeyCaptureTarget { case recording case pasteLastTranscript } extension SharedReaderKey where Self == InMemoryKey.Default { static var isSettingHotKey: Self { Self[.inMemory("isSettingHotKey"), default: false] } static var isSettingPasteLastTranscriptHotkey: Self { Self[.inMemory("isSettingPasteLastTranscriptHotkey"), default: false] } static var isRemappingScratchpadFocused: Self { Self[.inMemory("isRemappingScratchpadFocused"), default: false] } } // MARK: - Settings Feature @Reducer struct SettingsFeature { @ObservableState struct State { @Shared(.hexSettings) var hexSettings: HexSettings @Shared(.isSettingHotKey) var isSettingHotKey: Bool = false @Shared(.isSettingPasteLastTranscriptHotkey) var isSettingPasteLastTranscriptHotkey: Bool = false @Shared(.isRemappingScratchpadFocused) var isRemappingScratchpadFocused: Bool = false @Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory @Shared(.hotkeyPermissionState) var hotkeyPermissionState: HotkeyPermissionState var languages: IdentifiedArrayOf = [] var currentModifiers: Modifiers = .init(modifiers: []) var currentPasteLastModifiers: Modifiers = .init(modifiers: []) var remappingScratchpadText: String = "" // Available microphones var availableInputDevices: [AudioInputDevice] = [] var defaultInputDeviceName: String? // Model Management var modelDownload = ModelDownloadFeature.State() var shouldFlashModelSection = false } enum Action: BindableAction { case binding(BindingAction) // Existing case task case startSettingHotKey case startSettingPasteLastTranscriptHotkey case clearPasteLastTranscriptHotkey case keyEvent(KeyEvent) case toggleOpenOnLogin(Bool) case toggleShowDockIcon(Bool) case togglePreventSystemSleep(Bool) case setRecordingAudioBehavior(RecordingAudioBehavior) case toggleSuperFastMode(Bool) case setUseClipboardPaste(Bool) case setCopyToClipboard(Bool) case setDoubleTapLockEnabled(Bool) case setUseDoubleTapOnly(Bool) case setMinimumKeyTime(Double) case setOutputLanguage(String?) case setSelectedMicrophoneID(String?) case setSoundEffectsEnabled(Bool) case setSoundEffectsVolume(Double) // Permission delegation (forwarded to AppFeature) case requestMicrophone case requestAccessibility case requestInputMonitoring // Microphone selection case loadAvailableInputDevices case availableInputDevicesLoaded([AudioInputDevice], String?) // Model Management case modelDownload(ModelDownloadFeature.Action) // History Management case toggleSaveTranscriptionHistory(Bool) case setMaxHistoryEntries(Int?) // Modifier configuration case setModifierSide(Modifier.Kind, Modifier.Side) // Word remappings case setWordRemovalsEnabled(Bool) case addWordRemoval case updateWordRemoval(WordRemoval) case removeWordRemoval(UUID) case addWordRemapping case updateWordRemapping(WordRemapping) case removeWordRemapping(UUID) case setRemappingScratchpadFocused(Bool) case setLowercaseTranscripts(Bool) case setRemovePunctuation(Bool) } @Dependency(\.keyEventMonitor) var keyEventMonitor @Dependency(\.transcription) var transcription @Dependency(\.recording) var recording @Dependency(\.soundEffects) var soundEffects @Dependency(\.transcriptPersistence) var transcriptPersistence private func deleteAudioEffect(for transcripts: [Transcript]) -> Effect { .run { [transcriptPersistence] _ in for transcript in transcripts { try? await transcriptPersistence.deleteAudio(transcript) } } } private func beginCapture(_ target: HotKeyCaptureTarget, state: inout State) { switch target { case .recording: state.$isSettingHotKey.withLock { $0 = true } state.currentModifiers = .init(modifiers: []) case .pasteLastTranscript: state.$isSettingPasteLastTranscriptHotkey.withLock { $0 = true } state.currentPasteLastModifiers = .init(modifiers: []) } } private func endCapture(_ target: HotKeyCaptureTarget, state: inout State) { switch target { case .recording: state.$isSettingHotKey.withLock { $0 = false } state.currentModifiers = .init(modifiers: []) case .pasteLastTranscript: state.$isSettingPasteLastTranscriptHotkey.withLock { $0 = false } state.currentPasteLastModifiers = .init(modifiers: []) } } private func captureModifiers(for target: HotKeyCaptureTarget, state: State) -> Modifiers { switch target { case .recording: state.currentModifiers case .pasteLastTranscript: state.currentPasteLastModifiers } } private func updateCaptureModifiers(_ modifiers: Modifiers, for target: HotKeyCaptureTarget, state: inout State) { switch target { case .recording: state.currentModifiers = modifiers case .pasteLastTranscript: state.currentPasteLastModifiers = modifiers } } private func applyCapturedHotKey(key: Key?, modifiers: Modifiers, for target: HotKeyCaptureTarget, state: inout State) { switch target { case .recording: state.$hexSettings.withLock { $0.hotkey.key = key $0.hotkey.modifiers = modifiers.erasingSides() } case .pasteLastTranscript: guard let key else { return } state.$hexSettings.withLock { $0.pasteLastTranscriptHotkey = HotKey(key: key, modifiers: modifiers.erasingSides()) } } } private func handleCapture(_ keyEvent: KeyEvent, for target: HotKeyCaptureTarget, state: inout State) -> Effect { if keyEvent.key == .escape { endCapture(target, state: &state) return .none } let updatedModifiers = keyEvent.modifiers.union(captureModifiers(for: target, state: state)) updateCaptureModifiers(updatedModifiers, for: target, state: &state) if target == .pasteLastTranscript, keyEvent.key != nil, updatedModifiers.isEmpty { return .none } if let key = keyEvent.key { applyCapturedHotKey(key: key, modifiers: updatedModifiers, for: target, state: &state) endCapture(target, state: &state) return .none } if target == .recording, keyEvent.modifiers.isEmpty { applyCapturedHotKey(key: nil, modifiers: updatedModifiers, for: target, state: &state) endCapture(target, state: &state) } return .none } var body: some ReducerOf { BindingReducer() Scope(state: \.modelDownload, action: \.modelDownload) { ModelDownloadFeature() } Reduce { state, action in switch action { case .binding: let didNormalizeDoubleTapOnly = !state.hexSettings.doubleTapLockEnabled && state.hexSettings.useDoubleTapOnly if didNormalizeDoubleTapOnly { state.$hexSettings.withLock { $0.useDoubleTapOnly = false } } return .none case .task: if let url = Bundle.main.url(forResource: "languages", withExtension: "json"), let data = try? Data(contentsOf: url), let languages = try? JSONDecoder().decode([Language].self, from: data) { state.languages = IdentifiedArray(uniqueElements: languages) } else { settingsLogger.error("Failed to load languages JSON from bundle") } // Listen for key events and load microphones (existing + new) return .run { send in func audioPropertyAddress( _ selector: AudioObjectPropertySelector, scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal, element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain ) -> AudioObjectPropertyAddress { AudioObjectPropertyAddress( mSelector: selector, mScope: scope, mElement: element ) } await send(.modelDownload(.fetchModels)) await send(.loadAvailableInputDevices) // Listen for device connection/disconnection notifications // Using a simpler debounced approach with a single task var deviceUpdateTask: Task? var audioHardwareObservers: [(AudioObjectPropertySelector, SettingsAudioPropertyListenerBlock)] = [] // Helper function to debounce device updates func debounceDeviceUpdate() { deviceUpdateTask?.cancel() deviceUpdateTask = Task { try? await Task.sleep(nanoseconds: 500_000_000) // 500ms if !Task.isCancelled { await send(.loadAvailableInputDevices) } } } func installAudioHardwareObserver(_ selector: AudioObjectPropertySelector) { let listener: SettingsAudioPropertyListenerBlock = { _, _ in debounceDeviceUpdate() } var address = audioPropertyAddress(selector) let status = AudioObjectAddPropertyListenerBlock( AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, listener ) if status == noErr { audioHardwareObservers.append((selector, listener)) } else { settingsLogger.error("Failed to observe audio hardware selector \(selector): \(status)") } } let deviceConnectionObserver = NotificationCenter.default.addObserver( forName: NSNotification.Name(rawValue: "AVCaptureDeviceWasConnected"), object: nil, queue: .main ) { _ in debounceDeviceUpdate() } let deviceDisconnectionObserver = NotificationCenter.default.addObserver( forName: NSNotification.Name(rawValue: "AVCaptureDeviceWasDisconnected"), object: nil, queue: .main ) { _ in debounceDeviceUpdate() } let appDidBecomeActiveObserver = NotificationCenter.default.addObserver( forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main ) { _ in debounceDeviceUpdate() } let wakeObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didWakeNotification, object: nil, queue: .main ) { _ in debounceDeviceUpdate() } installAudioHardwareObserver(kAudioHardwarePropertyDefaultInputDevice) installAudioHardwareObserver(kAudioHardwarePropertyDevices) // Be sure to clean up resources when the task is finished defer { deviceUpdateTask?.cancel() NotificationCenter.default.removeObserver(deviceConnectionObserver) NotificationCenter.default.removeObserver(deviceDisconnectionObserver) NotificationCenter.default.removeObserver(appDidBecomeActiveObserver) NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver) for (selector, listener) in audioHardwareObservers { var address = audioPropertyAddress(selector) let status = AudioObjectRemovePropertyListenerBlock( AudioObjectID(kAudioObjectSystemObject), &address, DispatchQueue.main, listener ) if status != noErr { settingsLogger.error("Failed to remove audio hardware observer for selector \(selector): \(status)") } } } for try await keyEvent in await keyEventMonitor.listenForKeyPress() { await send(.keyEvent(keyEvent)) } } case .startSettingHotKey: beginCapture(.recording, state: &state) return .none case .addWordRemoval: state.$hexSettings.withLock { $0.wordRemovals.append(.init(pattern: "")) } return .none case let .updateWordRemoval(removal): state.$hexSettings.withLock { guard let index = $0.wordRemovals.firstIndex(where: { $0.id == removal.id }) else { return } $0.wordRemovals[index] = removal } return .none case let .removeWordRemoval(id): state.$hexSettings.withLock { $0.wordRemovals.removeAll { $0.id == id } } return .none case .addWordRemapping: state.$hexSettings.withLock { $0.wordRemappings.append(.init(match: "", replacement: "")) } return .none case let .updateWordRemapping(remapping): state.$hexSettings.withLock { guard let index = $0.wordRemappings.firstIndex(where: { $0.id == remapping.id }) else { return } $0.wordRemappings[index] = remapping } return .none case let .removeWordRemapping(id): state.$hexSettings.withLock { $0.wordRemappings.removeAll { $0.id == id } } return .none case let .setRemappingScratchpadFocused(isFocused): state.$isRemappingScratchpadFocused.withLock { $0 = isFocused } return .none case let .setLowercaseTranscripts(enabled): state.$hexSettings.withLock { $0.lowercaseTranscripts = enabled } return .none case let .setRemovePunctuation(enabled): state.$hexSettings.withLock { $0.removePunctuation = enabled } return .none case .startSettingPasteLastTranscriptHotkey: beginCapture(.pasteLastTranscript, state: &state) return .none case .clearPasteLastTranscriptHotkey: state.$hexSettings.withLock { $0.pasteLastTranscriptHotkey = nil } return .none case let .keyEvent(keyEvent): if state.isSettingPasteLastTranscriptHotkey { return handleCapture(keyEvent, for: .pasteLastTranscript, state: &state) } guard state.isSettingHotKey else { return .none } return handleCapture(keyEvent, for: .recording, state: &state) case let .toggleOpenOnLogin(enabled): state.$hexSettings.withLock { $0.openOnLogin = enabled } return .run { _ in if enabled { try? SMAppService.mainApp.register() } else { try? SMAppService.mainApp.unregister() } } case let .toggleShowDockIcon(enabled): state.$hexSettings.withLock { $0.showDockIcon = enabled } return .run { _ in await MainActor.run { NotificationCenter.default.post(name: .updateAppMode, object: nil) } } case let .togglePreventSystemSleep(enabled): state.$hexSettings.withLock { $0.preventSystemSleep = enabled } return .none case let .setUseClipboardPaste(enabled): state.$hexSettings.withLock { $0.useClipboardPaste = enabled } return .none case let .setCopyToClipboard(enabled): state.$hexSettings.withLock { $0.copyToClipboard = enabled } return .none case let .setRecordingAudioBehavior(behavior): state.$hexSettings.withLock { $0.recordingAudioBehavior = behavior } return .none case let .toggleSuperFastMode(enabled): state.$hexSettings.withLock { $0.superFastModeEnabled = enabled } return .run { _ in await recording.warmUpRecorder() } case let .setDoubleTapLockEnabled(enabled): state.$hexSettings.withLock { $0.doubleTapLockEnabled = enabled if !enabled { $0.useDoubleTapOnly = false } } return .none case let .setUseDoubleTapOnly(enabled): state.$hexSettings.withLock { $0.useDoubleTapOnly = enabled && $0.doubleTapLockEnabled } return .none case let .setMinimumKeyTime(value): state.$hexSettings.withLock { $0.minimumKeyTime = value } return .none case let .setOutputLanguage(language): state.$hexSettings.withLock { $0.outputLanguage = language } return .none case let .setSelectedMicrophoneID(deviceID): state.$hexSettings.withLock { $0.selectedMicrophoneID = deviceID } return .run { _ in await recording.warmUpRecorder() } case let .setSoundEffectsEnabled(enabled): state.$hexSettings.withLock { $0.soundEffectsEnabled = enabled } return .run { _ in await soundEffects.setEnabled(enabled) } case let .setSoundEffectsVolume(volume): state.$hexSettings.withLock { $0.soundEffectsVolume = volume } return .none // Permission requests case .requestMicrophone: settingsLogger.info("User requested microphone permission from settings") return .none case .requestAccessibility: settingsLogger.info("User requested accessibility permission from settings") return .none case .requestInputMonitoring: settingsLogger.info("User requested input monitoring permission from settings") return .none // Model Management case .modelDownload: return .none // Microphone device selection case .loadAvailableInputDevices: return .run { send in let devices = await recording.getAvailableInputDevices() let defaultName = await recording.getDefaultInputDeviceName() await send(.availableInputDevicesLoaded(devices, defaultName)) } case let .availableInputDevicesLoaded(devices, defaultName): if let selectedMicrophoneID = state.hexSettings.selectedMicrophoneID, let device = devices.first(where: { $0.legacyID == selectedMicrophoneID }) { state.availableInputDevices = devices state.defaultInputDeviceName = defaultName return .send(.setSelectedMicrophoneID(device.id)) } state.availableInputDevices = devices state.defaultInputDeviceName = defaultName return .none case let .toggleSaveTranscriptionHistory(enabled): state.$hexSettings.withLock { $0.saveTranscriptionHistory = enabled } // If disabling history, delete all existing entries if !enabled { let transcripts = state.transcriptionHistory.history // Clear the history state.$transcriptionHistory.withLock { history in history.history.removeAll() } return deleteAudioEffect(for: transcripts) } return .none case let .setMaxHistoryEntries(maxHistoryEntries): state.$hexSettings.withLock { $0.maxHistoryEntries = maxHistoryEntries } return .none case let .setModifierSide(kind, side): guard state.hexSettings.hotkey.key == nil else { return .none } state.$hexSettings.withLock { $0.hotkey.modifiers = $0.hotkey.modifiers.setting(kind: kind, to: side) } return .none case let .setWordRemovalsEnabled(enabled): state.$hexSettings.withLock { $0.wordRemovalsEnabled = enabled } return .none } } } }