// // AppFeature.swift // Hex // // Created by Kit Langton on 1/26/25. // import AppKit import ComposableArchitecture import Dependencies import HexCore import SwiftUI @Reducer struct AppFeature { private enum CancelID { case modelMissingFlash } enum ActiveTab: Equatable { case settings case remappings case history case about } @ObservableState struct State { var transcription: TranscriptionFeature.State = .init() var settings: SettingsFeature.State = .init() var history: HistoryFeature.State = .init() var activeTab: ActiveTab = .settings @Shared(.hexSettings) var hexSettings: HexSettings @Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState // Permission state var microphonePermission: PermissionStatus = .notDetermined var accessibilityPermission: PermissionStatus = .notDetermined var inputMonitoringPermission: PermissionStatus = .notDetermined } enum Action: BindableAction { case binding(BindingAction) case transcription(TranscriptionFeature.Action) case settings(SettingsFeature.Action) case history(HistoryFeature.Action) case setActiveTab(ActiveTab) case task case pasteLastTranscript // Permission actions case checkPermissions case permissionsUpdated(mic: PermissionStatus, acc: PermissionStatus, input: PermissionStatus) case appActivated case modelStatusEvaluated(Bool) } @Dependency(\.keyEventMonitor) var keyEventMonitor @Dependency(\.pasteboard) var pasteboard @Dependency(\.transcription) var transcription @Dependency(\.permissions) var permissions var body: some ReducerOf { BindingReducer() Scope(state: \.transcription, action: \.transcription) { TranscriptionFeature() } Scope(state: \.settings, action: \.settings) { SettingsFeature() } Scope(state: \.history, action: \.history) { HistoryFeature() } Reduce { state, action in switch action { case .binding: return .none case .task: return .merge( startPasteLastTranscriptMonitoring(), ensureSelectedModelReadiness(), startPermissionMonitoring() ) case .pasteLastTranscript: @Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory guard let lastTranscript = transcriptionHistory.history.first?.text else { return .none } return .run { _ in await pasteboard.paste(lastTranscript) } case .transcription(.modelMissing): HexLog.app.notice("Model missing - activating app and switching to settings") state.activeTab = .settings state.settings.shouldFlashModelSection = true return .run { send in await MainActor.run { HexLog.app.notice("Activating app for model missing") NotificationCenter.default.post(name: .presentSettingsWindow, object: nil) } try? await Task.sleep(for: .seconds(2)) await send(.settings(.set(\.shouldFlashModelSection, false))) } .cancellable(id: CancelID.modelMissingFlash, cancelInFlight: true) case .transcription: return .none case .settings(.requestMicrophone): return .run { send in _ = await permissions.requestMicrophone() await send(.checkPermissions) } case .settings(.requestAccessibility): return .run { send in await permissions.requestAccessibility() // Poll for status change (macOS doesn't provide callback) for _ in 0..<10 { try? await Task.sleep(for: .seconds(1)) await send(.checkPermissions) } } case .settings(.requestInputMonitoring): return .run { send in _ = await permissions.requestInputMonitoring() for _ in 0..<10 { try? await Task.sleep(for: .seconds(1)) await send(.checkPermissions) } } case .settings: return .none case .history(.navigateToSettings): state.activeTab = .settings return .none case .history: return .none case let .setActiveTab(tab): state.activeTab = tab return .none // Permission handling case .checkPermissions: return .run { send in async let mic = permissions.microphoneStatus() async let acc = permissions.accessibilityStatus() async let input = permissions.inputMonitoringStatus() await send(.permissionsUpdated(mic: mic, acc: acc, input: input)) } case let .permissionsUpdated(mic, acc, input): state.microphonePermission = mic state.accessibilityPermission = acc state.inputMonitoringPermission = input return .none case .appActivated: // App became active - re-check permissions return .send(.checkPermissions) case .modelStatusEvaluated: return .none } } } private func startPasteLastTranscriptMonitoring() -> Effect { .run { send in @Shared(.isSettingPasteLastTranscriptHotkey) var isSettingPasteLastTranscriptHotkey: Bool @Shared(.hexSettings) var hexSettings: HexSettings let token = keyEventMonitor.handleKeyEvent { keyEvent in // Skip if user is setting a hotkey if isSettingPasteLastTranscriptHotkey { return false } // Check if this matches the paste last transcript hotkey guard let pasteHotkey = hexSettings.pasteLastTranscriptHotkey, let key = keyEvent.key, key == pasteHotkey.key, keyEvent.modifiers.matchesExactly(pasteHotkey.modifiers) else { return false } // Trigger paste action - use MainActor to avoid escaping send MainActor.assumeIsolated { send(.pasteLastTranscript) } return true // Intercept the key event } defer { token.cancel() } await withTaskCancellationHandler { while !Task.isCancelled { try? await Task.sleep(for: .seconds(60)) } } onCancel: { token.cancel() } } } private func ensureSelectedModelReadiness() -> Effect { .run { send in @Shared(.hexSettings) var hexSettings: HexSettings @Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState let selectedModel = hexSettings.selectedModel guard !selectedModel.isEmpty else { await send(.modelStatusEvaluated(false)) return } let isReady = await transcription.isModelDownloaded(selectedModel) $modelBootstrapState.withLock { state in state.modelIdentifier = selectedModel if state.modelDisplayName?.isEmpty ?? true { state.modelDisplayName = selectedModel } state.isModelReady = isReady if isReady { state.lastError = nil state.progress = 1 } else { state.progress = 0 } } await send(.modelStatusEvaluated(isReady)) } } private func startPermissionMonitoring() -> Effect { .run { send in // Initial check on app launch await send(.checkPermissions) // Monitor app activation events for await activation in permissions.observeAppActivation() { if case .didBecomeActive = activation { await send(.appActivated) } } } } } struct AppView: View { @Bindable var store: StoreOf @State private var columnVisibility = NavigationSplitViewVisibility.automatic var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { List(selection: $store.activeTab) { Button { store.send(.setActiveTab(.settings)) } label: { Label("Settings", systemImage: "gearshape") } .buttonStyle(.plain) .tag(AppFeature.ActiveTab.settings) Button { store.send(.setActiveTab(.remappings)) } label: { Label("Transforms", systemImage: "text.badge.plus") } .buttonStyle(.plain) .tag(AppFeature.ActiveTab.remappings) Button { store.send(.setActiveTab(.history)) } label: { Label("History", systemImage: "clock") } .buttonStyle(.plain) .tag(AppFeature.ActiveTab.history) Button { store.send(.setActiveTab(.about)) } label: { Label("About", systemImage: "info.circle") } .buttonStyle(.plain) .tag(AppFeature.ActiveTab.about) } } detail: { switch store.state.activeTab { case .settings: SettingsView( store: store.scope(state: \.settings, action: \.settings), microphonePermission: store.microphonePermission, accessibilityPermission: store.accessibilityPermission, inputMonitoringPermission: store.inputMonitoringPermission ) .navigationTitle("Settings") case .remappings: WordRemappingsView(store: store.scope(state: \.settings, action: \.settings)) .navigationTitle("Transforms") case .history: HistoryView(store: store.scope(state: \.history, action: \.history)) .navigationTitle("History") case .about: AboutView(store: store.scope(state: \.settings, action: \.settings)) .navigationTitle("About") } } .enableInjection() } }