Files
gart/code/Hex-main/Hex/Features/App/AppFeature.swift
T

323 lines
9.4 KiB
Swift
Executable File

//
// 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<State>)
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<Self> {
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<Action> {
.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<Action> {
.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<Action> {
.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<AppFeature>
@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()
}
}