674 lines
21 KiB
Swift
Executable File
674 lines
21 KiB
Swift
Executable File
//
|
|
// TranscriptionFeature.swift
|
|
// Hex
|
|
//
|
|
// Created by Kit Langton on 1/24/25.
|
|
//
|
|
|
|
import ComposableArchitecture
|
|
import CoreGraphics
|
|
import Foundation
|
|
import HexCore
|
|
import Inject
|
|
import SwiftUI
|
|
import WhisperKit
|
|
|
|
private let transcriptionFeatureLogger = HexLog.transcription
|
|
|
|
@Reducer
|
|
struct TranscriptionFeature {
|
|
@ObservableState
|
|
struct State: Equatable {
|
|
var isRecording: Bool = false
|
|
var isTranscribing: Bool = false
|
|
var isPrewarming: Bool = false
|
|
var error: String?
|
|
var recordingStartTime: Date?
|
|
var meter: Meter = .init(averagePower: 0, peakPower: 0)
|
|
var sourceAppBundleID: String?
|
|
var sourceAppName: String?
|
|
@Shared(.hexSettings) var hexSettings: HexSettings
|
|
@Shared(.isRemappingScratchpadFocused) var isRemappingScratchpadFocused: Bool = false
|
|
@Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState
|
|
@Shared(.transcriptionHistory) var transcriptionHistory: TranscriptionHistory
|
|
}
|
|
|
|
enum Action {
|
|
case task
|
|
case audioLevelUpdated(Meter)
|
|
|
|
// Hotkey actions
|
|
case hotKeyPressed
|
|
case hotKeyReleased
|
|
|
|
// Recording flow
|
|
case startRecording
|
|
case stopRecording
|
|
|
|
// Cancel/discard flow
|
|
case cancel // Explicit cancellation with sound
|
|
case discard // Silent discard (too short/accidental)
|
|
|
|
// Transcription result flow
|
|
case transcriptionResult(String, URL, TimeInterval)
|
|
case transcriptionError(Error, URL?)
|
|
|
|
// Model availability
|
|
case modelMissing
|
|
}
|
|
|
|
enum CancelID {
|
|
case metering
|
|
case recordingStart
|
|
case recordingCleanup
|
|
case transcription
|
|
}
|
|
|
|
@Dependency(\.transcription) var transcription
|
|
@Dependency(\.recording) var recording
|
|
@Dependency(\.pasteboard) var pasteboard
|
|
@Dependency(\.keyEventMonitor) var keyEventMonitor
|
|
@Dependency(\.soundEffects) var soundEffect
|
|
@Dependency(\.sleepManagement) var sleepManagement
|
|
@Dependency(\.date.now) var now
|
|
@Dependency(\.transcriptPersistence) var transcriptPersistence
|
|
|
|
var body: some ReducerOf<Self> {
|
|
Reduce { state, action in
|
|
switch action {
|
|
// MARK: - Lifecycle / Setup
|
|
|
|
case .task:
|
|
// Starts two concurrent effects:
|
|
// 1) Observing audio meter
|
|
// 2) Monitoring hot key events
|
|
// 3) Priming the recorder for instant startup
|
|
return .merge(
|
|
startMeteringEffect(),
|
|
startHotKeyMonitoringEffect(),
|
|
warmUpRecorderEffect()
|
|
)
|
|
|
|
// MARK: - Metering
|
|
|
|
case let .audioLevelUpdated(meter):
|
|
state.meter = meter
|
|
return .none
|
|
|
|
// MARK: - HotKey Flow
|
|
|
|
case .hotKeyPressed:
|
|
// If we're transcribing, send a cancel first. Otherwise start recording immediately.
|
|
// We'll decide later (on release) whether to keep or discard the recording.
|
|
return handleHotKeyPressed(isTranscribing: state.isTranscribing)
|
|
|
|
case .hotKeyReleased:
|
|
// If we're currently recording, then stop. Otherwise, just cancel
|
|
// the delayed "startRecording" effect if we never actually started.
|
|
return handleHotKeyReleased(isRecording: state.isRecording)
|
|
|
|
// MARK: - Recording Flow
|
|
|
|
case .startRecording:
|
|
return handleStartRecording(&state)
|
|
|
|
case .stopRecording:
|
|
return handleStopRecording(&state)
|
|
|
|
// MARK: - Transcription Results
|
|
|
|
case let .transcriptionResult(result, audioURL, duration):
|
|
return handleTranscriptionResult(&state, result: result, audioURL: audioURL, duration: duration)
|
|
|
|
case let .transcriptionError(error, audioURL):
|
|
return handleTranscriptionError(&state, error: error, audioURL: audioURL)
|
|
|
|
case .modelMissing:
|
|
return .none
|
|
|
|
// MARK: - Cancel/Discard Flow
|
|
|
|
case .cancel:
|
|
// Only cancel if we're in the middle of recording, transcribing, or post-processing
|
|
guard state.isRecording || state.isTranscribing else {
|
|
return .none
|
|
}
|
|
return handleCancel(&state)
|
|
|
|
case .discard:
|
|
// Silent discard for quick/accidental recordings
|
|
guard state.isRecording else {
|
|
return .none
|
|
}
|
|
return handleDiscard(&state)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Effects: Metering & HotKey
|
|
|
|
private extension TranscriptionFeature {
|
|
/// Effect to begin observing the audio meter.
|
|
func startMeteringEffect() -> Effect<Action> {
|
|
.run { send in
|
|
for await meter in await recording.observeAudioLevel() {
|
|
await send(.audioLevelUpdated(meter))
|
|
}
|
|
}
|
|
.cancellable(id: CancelID.metering, cancelInFlight: true)
|
|
}
|
|
|
|
/// Effect to start monitoring hotkey events through the `keyEventMonitor`.
|
|
func startHotKeyMonitoringEffect() -> Effect<Action> {
|
|
.run { send in
|
|
var hotKeyProcessor: HotKeyProcessor = .init(hotkey: HotKey(key: nil, modifiers: [.option]))
|
|
@Shared(.isSettingHotKey) var isSettingHotKey: Bool
|
|
@Shared(.hexSettings) var hexSettings: HexSettings
|
|
|
|
// Handle incoming input events (keyboard and mouse)
|
|
let token = keyEventMonitor.handleInputEvent { inputEvent in
|
|
// Skip if the user is currently setting a hotkey
|
|
if isSettingHotKey {
|
|
return false
|
|
}
|
|
|
|
// Always keep hotKeyProcessor in sync with current user hotkey preference
|
|
hotKeyProcessor.hotkey = hexSettings.hotkey
|
|
let useDoubleTapOnly = hexSettings.doubleTapLockEnabled && hexSettings.useDoubleTapOnly
|
|
hotKeyProcessor.doubleTapLockEnabled = hexSettings.doubleTapLockEnabled
|
|
hotKeyProcessor.useDoubleTapOnly = useDoubleTapOnly
|
|
hotKeyProcessor.minimumKeyTime = hexSettings.minimumKeyTime
|
|
|
|
switch inputEvent {
|
|
case .keyboard(let keyEvent):
|
|
// If Escape is pressed with no modifiers while idle, let's treat that as `cancel`.
|
|
if keyEvent.key == .escape, keyEvent.modifiers.isEmpty,
|
|
hotKeyProcessor.state == .idle
|
|
{
|
|
Task { await send(.cancel) }
|
|
return false
|
|
}
|
|
|
|
// Process the key event
|
|
switch hotKeyProcessor.process(keyEvent: keyEvent) {
|
|
case .startRecording:
|
|
Task { await send(.hotKeyPressed) }
|
|
// If the hotkey is purely modifiers, return false to keep it from interfering with normal usage
|
|
// But if useDoubleTapOnly is true, always intercept the key
|
|
return useDoubleTapOnly || keyEvent.key != nil
|
|
|
|
case .stopRecording:
|
|
Task { await send(.hotKeyReleased) }
|
|
return false // or `true` if you want to intercept
|
|
|
|
case .cancel:
|
|
Task { await send(.cancel) }
|
|
return true
|
|
|
|
case .discard:
|
|
Task { await send(.discard) }
|
|
return false // Don't intercept - let the key chord reach other apps
|
|
|
|
case .none:
|
|
// If we detect repeated same chord, maybe intercept.
|
|
if let pressedKey = keyEvent.key,
|
|
pressedKey == hotKeyProcessor.hotkey.key,
|
|
keyEvent.modifiers == hotKeyProcessor.hotkey.modifiers
|
|
{
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
case .mouseClick:
|
|
// Process mouse click - for modifier-only hotkeys, this may cancel/discard
|
|
switch hotKeyProcessor.processMouseClick() {
|
|
case .cancel:
|
|
Task { await send(.cancel) }
|
|
return false // Don't intercept the click itself
|
|
case .discard:
|
|
Task { await send(.discard) }
|
|
return false // Don't intercept the click itself
|
|
case .startRecording, .stopRecording, .none:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
defer { token.cancel() }
|
|
|
|
await withTaskCancellationHandler {
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(60))
|
|
}
|
|
} onCancel: {
|
|
token.cancel()
|
|
}
|
|
}
|
|
}
|
|
|
|
func warmUpRecorderEffect() -> Effect<Action> {
|
|
.run { _ in
|
|
await recording.warmUpRecorder()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - HotKey Press/Release Handlers
|
|
|
|
private extension TranscriptionFeature {
|
|
func handleHotKeyPressed(isTranscribing: Bool) -> Effect<Action> {
|
|
// If already transcribing, cancel first. Otherwise start recording immediately.
|
|
guard isTranscribing else { return .send(.startRecording) }
|
|
return .concatenate(
|
|
.send(.cancel),
|
|
.send(.startRecording)
|
|
)
|
|
}
|
|
|
|
func handleHotKeyReleased(isRecording: Bool) -> Effect<Action> {
|
|
// Always stop recording when hotkey is released
|
|
return isRecording ? .send(.stopRecording) : .none
|
|
}
|
|
}
|
|
|
|
// MARK: - Recording Handlers
|
|
|
|
private extension TranscriptionFeature {
|
|
func handleStartRecording(_ state: inout State) -> Effect<Action> {
|
|
guard state.modelBootstrapState.isModelReady else {
|
|
return .merge(
|
|
.send(.modelMissing),
|
|
.run { _ in soundEffect.play(.cancel) }
|
|
)
|
|
}
|
|
state.isRecording = true
|
|
let startTime = now
|
|
state.recordingStartTime = startTime
|
|
|
|
// Capture the active application
|
|
if let activeApp = NSWorkspace.shared.frontmostApplication {
|
|
state.sourceAppBundleID = activeApp.bundleIdentifier
|
|
state.sourceAppName = activeApp.localizedName
|
|
}
|
|
transcriptionFeatureLogger.notice("Recording started at \(startTime.ISO8601Format())")
|
|
|
|
// Prevent system sleep during recording
|
|
return .merge(
|
|
.cancel(id: CancelID.recordingCleanup),
|
|
.run { [sleepManagement, preventSleep = state.hexSettings.preventSystemSleep] _ in
|
|
// Play sound immediately for instant feedback
|
|
soundEffect.play(.startRecording)
|
|
|
|
if preventSleep {
|
|
await sleepManagement.preventSleep(reason: "Hex Voice Recording")
|
|
}
|
|
guard !Task.isCancelled else {
|
|
if preventSleep {
|
|
await sleepManagement.allowSleep()
|
|
}
|
|
return
|
|
}
|
|
await recording.startRecording()
|
|
}
|
|
.cancellable(id: CancelID.recordingStart, cancelInFlight: true)
|
|
)
|
|
}
|
|
|
|
func handleStopRecording(_ state: inout State) -> Effect<Action> {
|
|
state.isRecording = false
|
|
|
|
let stopTime = now
|
|
let startTime = state.recordingStartTime
|
|
let duration = startTime.map { stopTime.timeIntervalSince($0) } ?? 0
|
|
|
|
let decision = RecordingDecisionEngine.decide(
|
|
.init(
|
|
hotkey: state.hexSettings.hotkey,
|
|
minimumKeyTime: state.hexSettings.minimumKeyTime,
|
|
recordingStartTime: state.recordingStartTime,
|
|
currentTime: stopTime
|
|
)
|
|
)
|
|
|
|
let startStamp = startTime?.ISO8601Format() ?? "nil"
|
|
let stopStamp = stopTime.ISO8601Format()
|
|
let minimumKeyTime = state.hexSettings.minimumKeyTime
|
|
let hotkeyHasKey = state.hexSettings.hotkey.key != nil
|
|
transcriptionFeatureLogger.notice(
|
|
"Recording stopped duration=\(String(format: "%.3f", duration))s start=\(startStamp) stop=\(stopStamp) decision=\(String(describing: decision)) minimumKeyTime=\(String(format: "%.2f", minimumKeyTime)) hotkeyHasKey=\(hotkeyHasKey)"
|
|
)
|
|
|
|
guard decision == .proceedToTranscription else {
|
|
// If the user recorded for less than minimumKeyTime and the hotkey is modifier-only,
|
|
// discard the audio to avoid accidental triggers.
|
|
transcriptionFeatureLogger.notice("Discarding short recording per decision \(String(describing: decision))")
|
|
return handleDiscard(&state)
|
|
}
|
|
|
|
let model = state.hexSettings.selectedModel
|
|
guard !model.isEmpty else {
|
|
// Defense-in-depth: handleStartRecording already blocks recording when the
|
|
// bootstrap state says no model is ready, but settings can change while a
|
|
// recording is in flight (or the in-memory bootstrap default can race a
|
|
// cold launch). Never hand an empty model name to the transcriber: it
|
|
// silently produces nothing (or junk like "[BLANK_AUDIO]").
|
|
transcriptionFeatureLogger.error("Recording stopped with no transcription model selected; discarding audio")
|
|
return .merge(
|
|
handleDiscard(&state),
|
|
.send(.modelMissing)
|
|
)
|
|
}
|
|
|
|
// Otherwise, proceed to transcription
|
|
state.isTranscribing = true
|
|
state.error = nil
|
|
let language = state.hexSettings.outputLanguage
|
|
|
|
state.isPrewarming = true
|
|
|
|
return .merge(
|
|
.cancel(id: CancelID.recordingStart),
|
|
.run { [sleepManagement] send in
|
|
// Allow system to sleep again
|
|
await sleepManagement.allowSleep()
|
|
|
|
var audioURL: URL?
|
|
defer {
|
|
if let audioURL {
|
|
FileManager.default.removeItemIfExists(at: audioURL)
|
|
}
|
|
}
|
|
do {
|
|
let stopResult = await recording.stopRecording()
|
|
let capturedURL: URL
|
|
switch stopResult {
|
|
case let .captured(url):
|
|
capturedURL = url
|
|
case .ignored(.staleSession):
|
|
transcriptionFeatureLogger.notice("Ignoring transcription stop superseded by a newer recording session")
|
|
return
|
|
case .ignored(.noActiveRecording):
|
|
transcriptionFeatureLogger.error("Recording stopped without captured audio")
|
|
await send(.transcriptionError(RecordingFailure.noCapturedAudio, nil))
|
|
return
|
|
case let .failed(error):
|
|
transcriptionFeatureLogger.error("Recording stop failed: \(error.localizedDescription)")
|
|
await send(.transcriptionError(error, nil))
|
|
return
|
|
}
|
|
audioURL = capturedURL
|
|
guard !Task.isCancelled else { return }
|
|
soundEffect.play(.stopRecording)
|
|
|
|
// Create transcription options with the selected language
|
|
// Note: cap concurrency to avoid audio I/O overloads on some Macs
|
|
let decodeOptions = DecodingOptions(
|
|
language: language,
|
|
detectLanguage: language == nil, // Only auto-detect if no language specified
|
|
chunkingStrategy: .vad,
|
|
)
|
|
|
|
let result = try await transcription.transcribe(capturedURL, model, decodeOptions) { _ in }
|
|
|
|
transcriptionFeatureLogger.notice("Transcribed audio from \(capturedURL.lastPathComponent) to text length \(result.count)")
|
|
audioURL = nil
|
|
await send(.transcriptionResult(result, capturedURL, duration))
|
|
} catch {
|
|
transcriptionFeatureLogger.error("Transcription failed: \(error.localizedDescription)")
|
|
await send(.transcriptionError(error, nil))
|
|
}
|
|
}
|
|
.cancellable(id: CancelID.transcription)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Transcription Handlers
|
|
|
|
private extension TranscriptionFeature {
|
|
func handleTranscriptionResult(
|
|
_ state: inout State,
|
|
result: String,
|
|
audioURL: URL,
|
|
duration: TimeInterval
|
|
) -> Effect<Action> {
|
|
state.isTranscribing = false
|
|
state.isPrewarming = false
|
|
|
|
// Check for force quit command (emergency escape hatch)
|
|
if ForceQuitCommandDetector.matches(result) {
|
|
transcriptionFeatureLogger.fault("Force quit voice command recognized; terminating Hex.")
|
|
return .run { _ in
|
|
FileManager.default.removeItemIfExists(at: audioURL)
|
|
await MainActor.run {
|
|
NSApp.terminate(nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
// If empty text, nothing else to do
|
|
guard !result.isEmpty else {
|
|
return .run { _ in
|
|
FileManager.default.removeItemIfExists(at: audioURL)
|
|
}
|
|
}
|
|
|
|
transcriptionFeatureLogger.info("Raw transcription: '\(result, privacy: .private)'")
|
|
let remappings = state.hexSettings.wordRemappings
|
|
let removalsEnabled = state.hexSettings.wordRemovalsEnabled
|
|
let removals = state.hexSettings.wordRemovals
|
|
let modifiedResult: String
|
|
if state.isRemappingScratchpadFocused {
|
|
modifiedResult = result
|
|
transcriptionFeatureLogger.info("Scratchpad focused; skipping word modifications")
|
|
} else {
|
|
var output = result
|
|
if removalsEnabled {
|
|
let removedResult = WordRemovalApplier.apply(output, removals: removals)
|
|
if removedResult != output {
|
|
let enabledRemovalCount = removals.filter(\.isEnabled).count
|
|
transcriptionFeatureLogger.info("Applied \(enabledRemovalCount) word removal(s)")
|
|
}
|
|
output = removedResult
|
|
}
|
|
let remappedResult = WordRemappingApplier.apply(output, remappings: remappings)
|
|
if remappedResult != output {
|
|
transcriptionFeatureLogger.info("Applied \(remappings.count) word remapping(s)")
|
|
}
|
|
let formattedResult = TranscriptFormattingApplier.apply(
|
|
remappedResult,
|
|
lowercase: state.hexSettings.lowercaseTranscripts,
|
|
removePunctuation: state.hexSettings.removePunctuation
|
|
)
|
|
if formattedResult != remappedResult {
|
|
transcriptionFeatureLogger.info("Applied paste formatting")
|
|
}
|
|
modifiedResult = formattedResult
|
|
}
|
|
|
|
guard !modifiedResult.isEmpty else {
|
|
return .run { _ in
|
|
FileManager.default.removeItemIfExists(at: audioURL)
|
|
}
|
|
}
|
|
|
|
let sourceAppBundleID = state.sourceAppBundleID
|
|
let sourceAppName = state.sourceAppName
|
|
let transcriptionHistory = state.$transcriptionHistory
|
|
|
|
return .run { send in
|
|
do {
|
|
try await finalizeRecordingAndStoreTranscript(
|
|
result: modifiedResult,
|
|
duration: duration,
|
|
sourceAppBundleID: sourceAppBundleID,
|
|
sourceAppName: sourceAppName,
|
|
audioURL: audioURL,
|
|
transcriptionHistory: transcriptionHistory
|
|
)
|
|
} catch {
|
|
await send(.transcriptionError(error, audioURL))
|
|
}
|
|
}
|
|
.cancellable(id: CancelID.transcription)
|
|
}
|
|
|
|
func handleTranscriptionError(
|
|
_ state: inout State,
|
|
error: Error,
|
|
audioURL: URL?
|
|
) -> Effect<Action> {
|
|
state.isTranscribing = false
|
|
state.isPrewarming = false
|
|
state.error = error.localizedDescription
|
|
|
|
if let audioURL {
|
|
FileManager.default.removeItemIfExists(at: audioURL)
|
|
}
|
|
|
|
return .none
|
|
}
|
|
|
|
/// Move file to permanent location, create a transcript record, paste text, and play sound.
|
|
func finalizeRecordingAndStoreTranscript(
|
|
result: String,
|
|
duration: TimeInterval,
|
|
sourceAppBundleID: String?,
|
|
sourceAppName: String?,
|
|
audioURL: URL,
|
|
transcriptionHistory: Shared<TranscriptionHistory>
|
|
) async throws {
|
|
@Shared(.hexSettings) var hexSettings: HexSettings
|
|
|
|
if hexSettings.saveTranscriptionHistory {
|
|
let transcript = try await transcriptPersistence.save(
|
|
result,
|
|
audioURL,
|
|
duration,
|
|
sourceAppBundleID,
|
|
sourceAppName
|
|
)
|
|
|
|
transcriptionHistory.withLock { history in
|
|
history.history.insert(transcript, at: 0)
|
|
|
|
if let maxEntries = hexSettings.maxHistoryEntries, maxEntries > 0 {
|
|
while history.history.count > maxEntries {
|
|
if let removedTranscript = history.history.popLast() {
|
|
Task {
|
|
try? await transcriptPersistence.deleteAudio(removedTranscript)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
FileManager.default.removeItemIfExists(at: audioURL)
|
|
}
|
|
|
|
await pasteboard.paste(result)
|
|
soundEffect.play(.pasteTranscript)
|
|
}
|
|
}
|
|
|
|
// MARK: - Cancel/Discard Handlers
|
|
|
|
private extension TranscriptionFeature {
|
|
func handleCancel(_ state: inout State) -> Effect<Action> {
|
|
let wasRecording = state.isRecording
|
|
state.isTranscribing = false
|
|
state.isRecording = false
|
|
state.isPrewarming = false
|
|
|
|
return .merge(
|
|
.cancel(id: CancelID.transcription),
|
|
.cancel(id: CancelID.recordingStart),
|
|
.run { [sleepManagement] _ in
|
|
// Allow system to sleep again
|
|
await sleepManagement.allowSleep()
|
|
guard wasRecording else {
|
|
soundEffect.play(.cancel)
|
|
return
|
|
}
|
|
// Stop the recording to release microphone access
|
|
let result = await recording.stopRecording()
|
|
if case let .captured(url) = result {
|
|
FileManager.default.removeItemIfExists(at: url)
|
|
}
|
|
guard !Task.isCancelled else { return }
|
|
soundEffect.play(.cancel)
|
|
}
|
|
.cancellable(id: CancelID.recordingCleanup, cancelInFlight: true)
|
|
)
|
|
}
|
|
|
|
func handleDiscard(_ state: inout State) -> Effect<Action> {
|
|
state.isRecording = false
|
|
state.isPrewarming = false
|
|
|
|
// Silently discard - no sound effect
|
|
return .merge(
|
|
.cancel(id: CancelID.recordingStart),
|
|
.run { [sleepManagement] _ in
|
|
// Allow system to sleep again
|
|
await sleepManagement.allowSleep()
|
|
let result = await recording.stopRecording()
|
|
if case let .captured(url) = result {
|
|
FileManager.default.removeItemIfExists(at: url)
|
|
}
|
|
guard !Task.isCancelled else { return }
|
|
}
|
|
.cancellable(id: CancelID.recordingCleanup, cancelInFlight: true)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - View
|
|
|
|
struct TranscriptionView: View {
|
|
@Bindable var store: StoreOf<TranscriptionFeature>
|
|
@ObserveInjection var inject
|
|
|
|
var status: TranscriptionIndicatorView.Status {
|
|
if store.isTranscribing {
|
|
return .transcribing
|
|
} else if store.isRecording {
|
|
return .recording
|
|
} else if store.isPrewarming {
|
|
return .prewarming
|
|
} else {
|
|
return .hidden
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
TranscriptionIndicatorView(
|
|
status: status,
|
|
meter: store.meter
|
|
)
|
|
.task {
|
|
await store.send(.task).finish()
|
|
}
|
|
.enableInjection()
|
|
}
|
|
}
|
|
|
|
// MARK: - Force Quit Command
|
|
|
|
private enum ForceQuitCommandDetector {
|
|
static func matches(_ text: String) -> Bool {
|
|
let normalized = normalize(text)
|
|
return normalized == "force quit hex now" || normalized == "force quit hex"
|
|
}
|
|
|
|
private static func normalize(_ text: String) -> String {
|
|
text
|
|
.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
|
|
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
|
.filter { !$0.isEmpty }
|
|
.joined(separator: " ")
|
|
}
|
|
}
|