323 lines
9.4 KiB
Swift
Executable File
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()
|
|
}
|
|
}
|