chore: adiciona .gitignore e commit.command

This commit is contained in:
João Henrique
2026-08-18 08:25:29 -04:00
parent 68958fde00
commit 8fca456ceb
215 changed files with 65752 additions and 0 deletions
+580
View File
@@ -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
}
}
}
}