chore: adiciona .gitignore e commit.command
This commit is contained in:
+580
@@ -0,0 +1,580 @@
|
||||
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<AudioObjectPropertyAddress>) -> Void
|
||||
|
||||
private enum HotKeyCaptureTarget {
|
||||
case recording
|
||||
case pasteLastTranscript
|
||||
}
|
||||
|
||||
extension SharedReaderKey
|
||||
where Self == InMemoryKey<Bool>.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<Language> = []
|
||||
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<State>)
|
||||
|
||||
// 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<Action> {
|
||||
.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<Action> {
|
||||
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<Self> {
|
||||
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<Void, Never>?
|
||||
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
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user