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
+82
View File
@@ -0,0 +1,82 @@
import Foundation
/// Central repository for timing thresholds and magic numbers used throughout HexCore.
///
/// These values have been carefully tuned based on user testing and OS behavior.
/// Changing these values may affect hotkey responsiveness and conflict with system shortcuts.
public enum HexCoreConstants {
// MARK: - Hotkey Timing Thresholds
/// Maximum time between two hotkey taps to be considered a double-tap.
///
/// **Value:** 0.3 seconds
///
/// **Rationale:** This feels responsive for intentional double-taps while being
/// long enough to avoid accidental triggers. Tested to align with standard
/// UI double-click timing expectations.
///
/// **Used in:**
/// - `HotKeyProcessor`: Double-tap lock detection
/// - Tests: Verifying double-tap vs two separate taps
public static let doubleTapWindow: TimeInterval = 0.3
/// Minimum duration for modifier-only hotkeys to avoid conflicts with OS shortcuts.
///
/// **Value:** 0.3 seconds
///
/// **Rationale:** macOS uses modifier keys for many shortcuts:
/// - Option+click = duplicate in Finder
/// - Cmd+click = open in new tab
/// - etc.
///
/// A 0.3s minimum prevents accidental transcription when users perform these
/// system actions. This value is enforced regardless of user's `minimumKeyTime` setting
/// (though user can set higher if desired).
///
/// **Used in:**
/// - `RecordingDecisionEngine`: Discard short modifier-only recordings
/// - `HotKeyProcessor`: Mouse click cancellation threshold
public static let modifierOnlyMinimumDuration: TimeInterval = 0.3
/// Time window for canceling press-and-hold on different key press.
///
/// **Value:** 1.0 second
///
/// **Rationale:** For key+modifier hotkeys (e.g., Cmd+A), if user presses a different
/// key within 1 second, it's likely accidental (fat-finger, muscle memory for different shortcut).
/// After 1 second, we assume the user wants to type while recording.
///
/// Does NOT apply to modifier-only hotkeys (they use `modifierOnlyMinimumDuration` instead).
///
/// **Used in:**
/// - `HotKeyProcessor`: Accidental key press detection for key+modifier hotkeys
public static let pressAndHoldCancelWindow: TimeInterval = 1.0
// MARK: - Default Settings
/// Default minimum time a key must be held to register as valid press.
///
/// **Value:** 0.2 seconds
///
/// **Rationale:** Prevents very quick accidental taps while still feeling responsive.
/// User-configurable in Settings. Modifier-only hotkeys override this with
/// `modifierOnlyMinimumDuration` if higher.
///
/// **Used in:**
/// - `HexSettings`: Default value for user preference
/// - `HotKeyProcessor`: Validation for printable-key hotkeys
public static let defaultMinimumKeyTime: TimeInterval = 0.2
/// Base volume for sound effects (before user multiplier applied).
///
/// **Value:** 0.2 (20%)
///
/// **Rationale:** Quiet enough to not be jarring, loud enough to provide clear feedback.
/// User can adjust via soundEffectsVolume multiplier.
///
/// **Used in:**
/// - `HexSettings`: Default sound effects volume
/// - Sound effect playback: Base volume before scaling
public static let baseSoundEffectsVolume: Double = 0.2
}
+42
View File
@@ -0,0 +1,42 @@
import os.log
/// Shared helper for creating consistent os.Logger instances across the Hex app and HexCore.
public enum HexLog {
public static let subsystem = "com.kitlangton.Hex"
public enum Category: String {
case app = "App"
case caches = "Caches"
case transcription = "Transcription"
case models = "Models"
case recording = "Recording"
case media = "Media"
case pasteboard = "Pasteboard"
case sound = "SoundEffect"
case hotKey = "HotKey"
case keyEvent = "KeyEvent"
case parakeet = "Parakeet"
case history = "History"
case settings = "Settings"
case permissions = "Permissions"
}
public static func logger(_ category: Category) -> os.Logger {
os.Logger(subsystem: subsystem, category: category.rawValue)
}
public static let app = logger(.app)
public static let caches = logger(.caches)
public static let transcription = logger(.transcription)
public static let models = logger(.models)
public static let recording = logger(.recording)
public static let media = logger(.media)
public static let pasteboard = logger(.pasteboard)
public static let sound = logger(.sound)
public static let hotKey = logger(.hotKey)
public static let keyEvent = logger(.keyEvent)
public static let parakeet = logger(.parakeet)
public static let history = logger(.history)
public static let settings = logger(.settings)
public static let permissions = logger(.permissions)
}
@@ -0,0 +1,537 @@
//
// HotKeyProcessor.swift
// Hex
//
// Created by Kit Langton on 1/28/25.
//
import Dependencies
import Foundation
import SwiftUI
private let hotKeyLogger = HexLog.hotKey
/// A state machine that processes keyboard events to detect hotkey activations.
///
/// Implements two complementary recording modes:
/// 1. **Press-and-Hold**: Start recording when hotkey is pressed, stop when released
/// 2. **Double-Tap Lock**: Quick double-tap locks recording until hotkey is pressed again
///
/// # Architecture
///
/// The processor maintains three possible states:
/// - `.idle`: Waiting for hotkey activation
/// - `.pressAndHold(startTime)`: Recording active, will stop when hotkey released
/// - `.doubleTapLock`: Recording locked, requires explicit hotkey press to stop
///
/// # Double-Tap Detection
///
/// A "tap" is a quick press-and-release sequence. The processor tracks release times:
/// - First tap: Press hotkey → release → record release time
/// - Second tap: If pressed within `doubleTapThreshold` (0.3s), enters `.doubleTapLock`
/// - The lock persists until the user presses the hotkey again or presses ESC
///
/// # Press-and-Hold Behavior
///
/// Standard recording mode:
/// - Hotkey pressed → `.startRecording` output, enter `.pressAndHold` state
/// - Hotkey released → `.stopRecording` output, return to `.idle`
/// - Different key pressed within threshold → cancel (accidental activation prevention)
/// - Different key pressed after threshold → ignored (intentional simultaneous input)
///
/// # Modifier-Only Hotkey Specifics
///
/// For hotkeys with no key component (e.g., Option-only):
/// - "Press" = all required modifiers held, no key pressed
/// - "Release" = any required modifier released
/// - Uses higher minimum duration (0.3s) to prevent conflicts with OS shortcuts
/// - Mouse clicks within threshold → silent discard (prevents Option+click conflicts)
/// - After threshold, only ESC cancels (mouse clicks ignored)
///
/// # Dirty State & Backsliding Prevention
///
/// After cancellation or with extra modifiers, processor enters "dirty" state:
/// - All input ignored until full release (key:nil, modifiers:[])
/// - Prevents accidental re-triggering during complex key combinations
/// - User cannot "backslide" into hotkey by releasing extra modifiers
///
/// # ESC Key Handling
///
/// Pressing ESC always cancels active recordings:
/// - Returns `.cancel` output (plays cancel sound)
/// - Enters dirty state to prevent immediate re-triggering
/// - Works in both `.pressAndHold` and `.doubleTapLock` states
///
/// # Example Interaction Flow
///
/// ```
/// // Simple press-and-hold (Cmd+A hotkey)
/// Event: Cmd+A pressed → Output: .startRecording, State: .pressAndHold
/// Event: Cmd released → Output: .stopRecording, State: .idle
///
/// // Double-tap lock (Option hotkey)
/// Event: Option pressed → Output: .startRecording, State: .pressAndHold
/// Event: Option released → Output: .stopRecording, State: .idle
/// Event: Option pressed → Output: .startRecording, State: .pressAndHold
/// Event: Option released → Output: nil, State: .doubleTapLock (locked!)
/// Event: Option pressed → Output: .stopRecording, State: .idle
///
/// // Accidental trigger prevention
/// Event: Cmd+A pressed → Output: .startRecording, State: .pressAndHold
/// Event: Cmd+B pressed (0.1s) → Output: .stopRecording, State: .idle (different key)
/// ```
///
/// # Related Components
///
/// - `RecordingDecisionEngine`: Determines if recording duration meets minimum thresholds
/// - `KeyEvent`: Input events from keyboard monitoring
/// - `HotKey`: Configuration of which key/modifiers to detect
///
public struct HotKeyProcessor {
@Dependency(\.date.now) var now
// MARK: - Configuration
/// The hotkey combination to detect (key + modifiers)
public var hotkey: HotKey
/// If true, only double-tap activates recording (press-and-hold disabled)
/// Only applies to key+modifier hotkeys; modifier-only always allows press-and-hold
public var useDoubleTapOnly: Bool = false
/// If false, the quick double-tap lock gesture is disabled.
/// Press-and-hold still works normally.
public var doubleTapLockEnabled: Bool = true
/// Minimum duration before very quick taps are considered valid
/// For modifier-only hotkeys, this is overridden to 0.3s minimum
public var minimumKeyTime: TimeInterval = 0.15
// MARK: - State
/// Current state of the processor
public private(set) var state: State = .idle
/// Timestamp of the most recent hotkey release (for double-tap detection)
private var lastTapAt: Date?
/// When true, all input is ignored until full keyboard release
/// Prevents accidental re-triggering after cancellation or during complex key combos
private var isDirty: Bool = false
// MARK: - Timing Thresholds
/// Maximum time between two taps to be considered a double-tap (0.3 seconds)
/// Chosen to feel responsive while avoiding accidental double-taps
public static let doubleTapThreshold: TimeInterval = HexCoreConstants.doubleTapWindow
/// Time window for canceling press-and-hold on different key press (1 second)
/// For key+modifier hotkeys: different key within 1s = accidental, after 1s = intentional
public static let pressAndHoldCancelThreshold: TimeInterval = HexCoreConstants.pressAndHoldCancelWindow
// MARK: - Initialization
/// Creates a new hotkey processor
/// - Parameters:
/// - hotkey: The key combination to detect
/// - useDoubleTapOnly: If true, disables press-and-hold for key+modifier hotkeys
/// - doubleTapLockEnabled: If false, disables double-tap lock behavior
/// - minimumKeyTime: Minimum duration for valid key press (overridden to modifierOnlyMinimumDuration for modifier-only)
public init(
hotkey: HotKey,
useDoubleTapOnly: Bool = false,
doubleTapLockEnabled: Bool = true,
minimumKeyTime: TimeInterval = HexCoreConstants.defaultMinimumKeyTime
) {
self.hotkey = hotkey
self.useDoubleTapOnly = useDoubleTapOnly
self.doubleTapLockEnabled = doubleTapLockEnabled
self.minimumKeyTime = minimumKeyTime
}
// MARK: - Public API
/// Returns true if recording is currently active (press-and-hold or double-tap locked)
public var isMatched: Bool {
switch state {
case .idle:
return false
case .pressAndHold, .doubleTapLock:
return true
}
}
/// Processes a keyboard event and returns an action to take, if any.
///
/// - Parameter keyEvent: The keyboard event containing key and modifier state
/// - Returns: An output action (.startRecording, .stopRecording, .cancel, .discard) or nil if no action needed
///
/// # Event Processing Order
/// 1. ESC key → immediate cancellation
/// 2. Dirty state check → ignore input until full release
/// 3. Matching chord → handle as hotkey press
/// 4. Non-matching chord → handle as release or different key
public mutating func process(keyEvent: KeyEvent) -> Output? {
// 1) ESC => immediate cancel
if keyEvent.key == .escape {
let currentState = state
hotKeyLogger.notice("ESC pressed while state=\(String(describing: currentState))")
}
if keyEvent.key == .escape, state != .idle {
isDirty = true
resetToIdle()
return .cancel
}
// 2) If dirty, ignore until full release (nil, [])
if isDirty {
if chordIsFullyReleased(keyEvent) {
isDirty = false
} else {
return nil
}
}
// 3) Matching chord => handle as "press"
if chordMatchesHotkey(keyEvent) {
return handleMatchingChord()
} else {
// Potentially become dirty if chord has extra mods or different key
if chordIsDirty(keyEvent) {
isDirty = true
}
return handleNonmatchingChord(keyEvent)
}
}
/// Processes a mouse click event to prevent accidental recordings.
///
/// For modifier-only hotkeys, mouse clicks can interfere with recording:
/// - Option+click = duplicate items in Finder
/// - Cmd+click = open in new tab
/// - etc.
///
/// This method discards recordings that haven't passed the minimum threshold yet.
///
/// - Returns: `.discard` if recording canceled, nil if click ignored
///
/// # Behavior
/// - Modifier-only hotkeys: Discard if within threshold, ignore after threshold
/// - Key+modifier hotkeys: Always ignore (no conflict with mouse clicks)
/// - Double-tap lock: Always ignore (intentional recording, only ESC cancels)
public mutating func processMouseClick() -> Output? {
// Only cancel if:
// 1. The hotkey is modifier-only (no key component)
// 2. We're currently in an active recording state (pressAndHold or doubleTapLock)
guard hotkey.key == nil else {
return nil
}
switch state {
case .idle:
return nil
case let .pressAndHold(startTime):
// Mouse click during modifier-only recording
let elapsed = now.timeIntervalSince(startTime)
// For modifier-only hotkeys, use the same threshold as RecordingDecisionEngine
// (max of minimumKeyTime and 0.3s) to be consistent
let effectiveMinimum = max(minimumKeyTime, RecordingDecisionEngine.modifierOnlyMinimumDuration)
// Only discard if within threshold - after threshold, ignore clicks (only ESC cancels)
if elapsed < effectiveMinimum {
isDirty = true
resetToIdle()
return .discard
} else {
// After threshold, ignore mouse clicks - let recording continue
return nil
}
case .doubleTapLock:
// Mouse click during double-tap lock => ignore (only ESC cancels locked recordings)
return nil
}
}
}
// MARK: - State & Output
public extension HotKeyProcessor {
/// Represents the current state of hotkey detection
enum State: Equatable {
/// Idle, waiting for hotkey activation
case idle
/// Press-and-hold recording active
/// - Parameter startTime: When the hotkey was first pressed (for duration calculation)
case pressAndHold(startTime: Date)
/// Double-tap lock active - recording continues until explicit stop
case doubleTapLock
}
/// Actions to take in response to keyboard events
enum Output: Equatable {
/// Begin a new recording session
case startRecording
/// Stop the current recording and process audio
case stopRecording
/// Explicit user cancellation via ESC key
/// Plays cancel sound to provide feedback
case cancel
/// Silent discard of accidental/short activation
/// Used for very quick taps or mouse click conflicts
case discard
}
}
// MARK: - Core Logic
extension HotKeyProcessor {
private var isDoubleTapOnlyEnabledForCurrentHotkey: Bool {
useDoubleTapOnly && doubleTapLockEnabled && hotkey.key != nil
}
/// Handles keyboard events that match the configured hotkey.
///
/// # State Transitions
/// - `.idle` → `.pressAndHold`: Start new recording (unless useDoubleTapOnly mode)
/// - `.pressAndHold` → no change: Already recording, ignore
/// - `.doubleTapLock` → `.idle`: User pressed hotkey to stop locked recording
///
/// # Double-Tap Only Mode
/// For key+modifier hotkeys with useDoubleTapOnly enabled:
/// - First press: Record timestamp but don't start recording
/// - Wait for quick release and second press to actually start
///
/// - Returns: `.startRecording` when entering press-and-hold, `.stopRecording` when exiting lock
private mutating func handleMatchingChord() -> Output? {
switch state {
case .idle:
// If doubleTapOnly mode is enabled and the hotkey has a key component,
// we want to delay starting recording until we see the double-tap
if isDoubleTapOnlyEnabledForCurrentHotkey {
// Record the timestamp but don't start recording
lastTapAt = now
return nil
} else {
// Normal press => .pressAndHold => .startRecording
state = .pressAndHold(startTime: now)
return .startRecording
}
case .pressAndHold:
// Already matched, no new output
return nil
case .doubleTapLock:
// Pressing hotkey again while locked => stop
resetToIdle()
return .stopRecording
}
}
/// Handles keyboard events that don't match the configured hotkey.
///
/// This method detects:
/// 1. **Hotkey release**: User lifted the hotkey (transition to idle or double-tap lock)
/// 2. **Different key press**: User pressed a different key while holding hotkey (potential cancel)
/// 3. **Extra modifiers**: User added modifiers beyond hotkey requirements (potential cancel)
///
/// # Cancel Behavior
/// Different keys/modifiers are handled based on timing and hotkey type:
///
/// **Modifier-only hotkeys:**
/// - Within threshold (0.3s): Discard silently (accidental trigger, e.g., Option+click)
/// - After threshold: Ignore completely, keep recording (only ESC cancels)
///
/// **Key+modifier hotkeys:**
/// - Within 1s: Stop recording (likely accidental)
/// - After 1s: Ignore, keep recording (intentional simultaneous input)
///
/// - Parameter e: The non-matching keyboard event
/// - Returns: Recording control output or nil
private mutating func handleNonmatchingChord(_ e: KeyEvent) -> Output? {
switch state {
case .idle:
// Handle double-tap detection for key+modifier combinations
if isDoubleTapOnlyEnabledForCurrentHotkey &&
chordIsFullyReleased(e) &&
lastTapAt != nil {
// If we've seen a tap recently, and now we see a full release, and we're in idle state
// Check if the time between taps is within the threshold
if let prevTapTime = lastTapAt,
now.timeIntervalSince(prevTapTime) < Self.doubleTapThreshold {
// This is the second tap - activate recording in double-tap lock mode
state = .doubleTapLock
return .startRecording
}
// Reset the tap timer as we've fully released
lastTapAt = nil
}
return nil
case let .pressAndHold(startTime):
// If user truly "released" the chord => either normal stop or doubleTapLock
if isReleaseForActiveHotkey(e) {
// Check if this release is close to the prior release => double-tap lock
if doubleTapLockEnabled,
let prevReleaseTime = lastTapAt,
now.timeIntervalSince(prevReleaseTime) < Self.doubleTapThreshold
{
// => Switch to doubleTapLock, remain matched, no new output
state = .doubleTapLock
return nil
} else {
// Normal stop => idle => record the release time
state = .idle
lastTapAt = doubleTapLockEnabled ? now : nil
return .stopRecording
}
} else {
// User pressed a different key/modifier while holding hotkey
let elapsed = now.timeIntervalSince(startTime)
// Modifier-only hotkeys: Only discard within threshold, ignore after
if hotkey.key == nil {
let effectiveMinimum = max(minimumKeyTime, RecordingDecisionEngine.modifierOnlyMinimumDuration)
if elapsed < effectiveMinimum {
// Within threshold => discard silently (accidental trigger)
isDirty = true
resetToIdle()
return .discard
} else {
// After threshold => ignore extra modifiers/keys, keep recording (only ESC cancels)
return nil
}
} else {
// Printable-key hotkeys: Use old behavior with 1s threshold
if elapsed < Self.pressAndHoldCancelThreshold {
// Within 1s threshold => treat as accidental
isDirty = true
resetToIdle()
// If very quick (< minimumKeyTime), discard silently. Otherwise stop with sound.
return elapsed < minimumKeyTime ? .discard : .stopRecording
} else {
// After 1s => remain matched
return nil
}
}
}
case .doubleTapLock:
// For key+modifier combinations in doubleTapLock mode, require full key release to stop
if isDoubleTapOnlyEnabledForCurrentHotkey && chordIsFullyReleased(e) {
resetToIdle()
return .stopRecording
}
// Otherwise, if locked, ignore everything except chord == hotkey => stop
return nil
}
}
// MARK: - Helpers
/// Checks if the given keyboard event exactly matches the configured hotkey.
///
/// # Matching Rules
/// - **Key+modifier hotkey**: Both key and modifiers must match exactly
/// - **Modifier-only hotkey**: Modifiers match exactly and no key is pressed
///
/// - Parameter e: The keyboard event to check
/// - Returns: True if event matches hotkey configuration
private func chordMatchesHotkey(_ e: KeyEvent) -> Bool {
if hotkey.key != nil {
return e.key == hotkey.key && e.modifiers.matchesExactly(hotkey.modifiers)
} else {
return e.key == nil && e.modifiers.matchesExactly(hotkey.modifiers)
}
}
/// Checks if keyboard event contains extra keys/modifiers that should trigger dirty state.
///
/// "Dirty" means the user is doing something unrelated to our hotkey, so we should
/// ignore all input until they fully release the keyboard.
///
/// # Dirty Conditions
/// - **Modifier-only hotkey**: Any key press OR extra modifiers beyond requirements
/// - **Key+modifier hotkey**: Different key OR modifiers not subset of requirements
///
/// - Parameter e: The keyboard event to check
/// - Returns: True if event should trigger dirty state
private func chordIsDirty(_ e: KeyEvent) -> Bool {
if hotkey.key == nil {
// Any key press while watching pure-modifier hotkey is "dirty"
// Also dirty if there are extra modifiers beyond what the hotkey requires
return e.key != nil || !e.modifiers.isSubset(of: hotkey.modifiers)
}
let isSubset = e.modifiers.isSubset(of: hotkey.modifiers)
let isWrongKey = (e.key != nil && e.key != hotkey.key)
return !isSubset || isWrongKey
}
/// Checks if all keys and modifiers have been released.
///
/// Used to clear dirty state - once user fully releases keyboard,
/// we can start accepting hotkey input again.
///
/// - Parameter e: The keyboard event to check
/// - Returns: True if no keys or modifiers are pressed
private func chordIsFullyReleased(_ e: KeyEvent) -> Bool {
e.key == nil && e.modifiers.isEmpty
}
/// Detects if user has released the active hotkey.
///
/// Release detection differs based on hotkey type:
///
/// # Key+Modifier Hotkey (e.g., Cmd+A)
/// "Release" = key is lifted, modifiers may still be held
/// - Allows partial modifier release before key release
/// - User can lift Cmd slightly early without affecting detection
///
/// # Modifier-Only Hotkey (e.g., Option)
/// "Release" = required modifiers no longer pressed
/// - Detects when user lifts the specific modifier(s)
/// - Key must be nil (no key component in hotkey)
///
/// - Parameter e: The keyboard event to check
/// - Returns: True if hotkey has been released
private func isReleaseForActiveHotkey(_ e: KeyEvent) -> Bool {
if hotkey.key != nil {
let requiredModifiers = hotkey.modifiers
let keyReleased = e.key == nil
let modifiersAreSubset = e.modifiers.isSubset(of: requiredModifiers)
if keyReleased {
// Treat as release even if some modifiers were lifted first,
// as long as no new modifiers are introduced.
return modifiersAreSubset
}
return false
} else {
// For modifier-only hotkeys, we check:
// 1. Key is nil
// 2. Required hotkey modifiers are no longer pressed
// This detects when user has released the specific modifiers in the hotkey
return e.key == nil && !hotkey.modifiers.isSubset(of: e.modifiers)
}
}
/// Resets processor to idle state, clearing active recording state.
///
/// Preserves `isDirty` flag if caller has set it, allowing dirty state
/// to persist across state transitions for proper input blocking.
///
/// Clears:
/// - `state` → `.idle`
/// - `lastTapAt` → nil (double-tap timing reset)
private mutating func resetToIdle() {
state = .idle
lastTapAt = nil
}
}
@@ -0,0 +1,59 @@
//
// ModelPatternMatcher.swift
// HexCore
//
// Shared utility for matching model names using glob patterns (fnmatch).
//
import Foundation
/// Utilities for matching model names against glob patterns.
public enum ModelPatternMatcher {
/// Returns `true` if `text` matches `pattern` (supports `*` and `?` wildcards).
public static func matches(_ pattern: String, _ text: String) -> Bool {
if pattern.contains("*") || pattern.contains("?") {
return fnmatch(pattern, text, 0) == 0
}
return pattern == text
}
/// Returns `true` if either name matches the other as a pattern.
/// Use when comparing a stored selection to a model name and either side
/// may be a glob (e.g. "distil*large-v3") or a concrete identifier.
public static func namesMatch(_ lhs: String, _ rhs: String) -> Bool {
matches(lhs, rhs) || matches(rhs, lhs)
}
/// Given a list of model names and download status, resolve a glob pattern to a concrete name.
/// Preference: downloaded > non-turbo > any match.
/// Returns `nil` if no match found.
public static func resolvePattern(
_ pattern: String,
from models: [(name: String, isDownloaded: Bool)]
) -> String? {
// No glob characters: return as-is
guard pattern.contains("*") || pattern.contains("?") else {
return pattern
}
// Find all matches
let matched = models.filter { fnmatch(pattern, $0.name, 0) == 0 }
guard !matched.isEmpty else { return nil }
// Prefer already-downloaded matches
let downloaded = matched.filter { $0.isDownloaded }
if !downloaded.isEmpty {
// Prefer non-turbo if both exist
if let nonTurbo = downloaded.first(where: { !$0.name.localizedCaseInsensitiveContains("turbo") }) {
return nonTurbo.name
}
return downloaded.first!.name
}
// If none downloaded yet, prefer non-turbo first
if let nonTurbo = matched.first(where: { !$0.name.localizedCaseInsensitiveContains("turbo") }) {
return nonTurbo.name
}
return matched.first!.name
}
}
@@ -0,0 +1,77 @@
import Foundation
/// Determines whether a recording should be kept or discarded based on duration and hotkey type.
///
/// This engine enforces minimum recording durations to prevent accidental activations
/// and conflicts with system shortcuts.
public struct RecordingDecisionEngine {
/// Minimum duration for modifier-only hotkeys to avoid OS shortcut conflicts.
///
/// This is applied regardless of user's minimumKeyTime setting.
/// See `HexCoreConstants.modifierOnlyMinimumDuration` for rationale.
public static let modifierOnlyMinimumDuration: TimeInterval = HexCoreConstants.modifierOnlyMinimumDuration
/// Context information needed to make a recording decision.
public struct Context: Equatable {
/// The hotkey configuration that triggered this recording
public var hotkey: HotKey
/// User's configured minimum key time preference
public var minimumKeyTime: TimeInterval
/// When recording started (nil if no recording)
public var recordingStartTime: Date?
/// Current timestamp
public var currentTime: Date
public init(
hotkey: HotKey,
minimumKeyTime: TimeInterval,
recordingStartTime: Date?,
currentTime: Date
) {
self.hotkey = hotkey
self.minimumKeyTime = minimumKeyTime
self.recordingStartTime = recordingStartTime
self.currentTime = currentTime
}
}
/// The decision outcome for a recording.
public enum Decision: Equatable {
/// Recording was too short or accidental - discard silently
case discardShortRecording
/// Recording meets minimum requirements - proceed with transcription
case proceedToTranscription
}
/// Determines whether to keep or discard a recording based on duration and hotkey type.
///
/// # Decision Logic
///
/// **Modifier-only hotkeys** (e.g., Option):
/// - Must meet `max(minimumKeyTime, modifierOnlyMinimumDuration)`
/// - Always enforces 0.3s minimum to prevent OS shortcut conflicts
///
/// **Key+modifier hotkeys** (e.g., Cmd+A):
/// - Always proceeds to transcription (duration checked elsewhere)
/// - User's minimumKeyTime preference applies
///
/// - Parameter context: Recording context with timing and configuration
/// - Returns: Decision to discard or proceed
public static func decide(_ context: Context) -> Decision {
let elapsed = context.recordingStartTime.map { context.currentTime.timeIntervalSince($0) } ?? 0
let includesPrintableKey = context.hotkey.key != nil
// For modifier-only hotkeys, use the higher of minimumKeyTime or modifierOnlyMinimumDuration
// to prevent conflicts with system shortcuts
let effectiveMinimum = includesPrintableKey
? context.minimumKeyTime
: max(context.minimumKeyTime, modifierOnlyMinimumDuration)
let durationIsLongEnough = elapsed >= effectiveMinimum
return (durationIsLongEnough || includesPrintableKey) ? .proceedToTranscription : .discardShortRecording
}
}
@@ -0,0 +1,8 @@
/// App lifecycle activation events
public enum AppActivation: Equatable, Sendable {
/// The app became the active application
case didBecomeActive
/// The app will resign active status
case willResignActive
}
@@ -0,0 +1,191 @@
@preconcurrency import AppKit
import AVFoundation
import CoreGraphics
import Dependencies
import Foundation
import IOKit
import IOKit.hidsystem
private let logger = HexLog.permissions
extension PermissionClient: DependencyKey {
public static var liveValue: Self {
let live = PermissionClientLive()
return Self(
microphoneStatus: { await live.microphoneStatus() },
accessibilityStatus: { live.accessibilityStatus() },
inputMonitoringStatus: { live.inputMonitoringStatus() },
requestMicrophone: { await live.requestMicrophone() },
requestAccessibility: { await live.requestAccessibility() },
requestInputMonitoring: { await live.requestInputMonitoring() },
openMicrophoneSettings: { await live.openMicrophoneSettings() },
openAccessibilitySettings: { await live.openAccessibilitySettings() },
openInputMonitoringSettings: { await live.openInputMonitoringSettings() },
observeAppActivation: { live.observeAppActivation() }
)
}
}
/// Live implementation of the PermissionClient.
///
/// This actor manages permission checking, requesting, and app activation monitoring.
/// It uses NotificationCenter to observe app lifecycle events and provides an AsyncStream
/// for reactive permission updates.
actor PermissionClientLive {
private let (activationStream, activationContinuation) = AsyncStream<AppActivation>.makeStream()
private nonisolated(unsafe) var observations: [Any] = []
init() {
logger.debug("Initializing PermissionClient, setting up app activation observers")
// Subscribe to app activation notifications
let didBecomeActiveObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
logger.debug("App became active")
Task {
self?.activationContinuation.yield(.didBecomeActive)
}
}
let willResignActiveObserver = NotificationCenter.default.addObserver(
forName: NSApplication.willResignActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
logger.debug("App will resign active")
Task {
self?.activationContinuation.yield(.willResignActive)
}
}
observations = [didBecomeActiveObserver, willResignActiveObserver]
}
deinit {
observations.forEach { NotificationCenter.default.removeObserver($0) }
}
// MARK: - Microphone Permissions
func microphoneStatus() async -> PermissionStatus {
let status = AVCaptureDevice.authorizationStatus(for: .audio)
let result: PermissionStatus
switch status {
case .authorized:
result = .granted
case .denied, .restricted:
result = .denied
case .notDetermined:
result = .notDetermined
@unknown default:
result = .denied
}
logger.info("Microphone status: \(String(describing: result))")
return result
}
func requestMicrophone() async -> Bool {
logger.info("Requesting microphone permission...")
let granted = await withCheckedContinuation { continuation in
AVCaptureDevice.requestAccess(for: .audio) { granted in
continuation.resume(returning: granted)
}
}
logger.info("Microphone permission granted: \(granted)")
return granted
}
func openMicrophoneSettings() async {
logger.info("Opening microphone settings in System Preferences...")
await MainActor.run {
_ = NSWorkspace.shared.open(
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone")!
)
}
}
// MARK: - Accessibility Permissions
nonisolated func accessibilityStatus() -> PermissionStatus {
// Check without prompting (kAXTrustedCheckOptionPrompt: false)
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: false] as CFDictionary
let result = AXIsProcessTrustedWithOptions(options) ? PermissionStatus.granted : .denied
logger.info("Accessibility status: \(String(describing: result))")
return result
}
nonisolated func inputMonitoringStatus() -> PermissionStatus {
let access = IOHIDCheckAccess(kIOHIDRequestTypeListenEvent)
let result = mapIOHIDAccess(access)
logger.info("Input monitoring status: \(String(describing: result)) (IOHIDAccess: \(String(describing: access)))")
return result
}
func requestAccessibility() async {
logger.info("Requesting accessibility permission...")
// First, trigger the system prompt (on main actor for safety)
await MainActor.run {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
}
// Also open System Settings (the prompt alone is insufficient on modern macOS)
await openAccessibilitySettings()
}
func requestInputMonitoring() async -> Bool {
logger.info("Requesting input monitoring permission...")
let granted = await MainActor.run {
if CGPreflightListenEventAccess() {
return true
}
return CGRequestListenEventAccess()
}
if !granted {
logger.info("Input monitoring not granted, opening Settings...")
await openInputMonitoringSettings()
} else {
logger.info("Input monitoring permission granted: \(granted)")
}
return granted
}
func openAccessibilitySettings() async {
logger.info("Opening accessibility settings in System Preferences...")
await MainActor.run {
_ = NSWorkspace.shared.open(
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!
)
}
}
func openInputMonitoringSettings() async {
logger.info("Opening input monitoring settings in System Preferences...")
await MainActor.run {
_ = NSWorkspace.shared.open(
URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")!
)
}
}
// MARK: - Reactive Monitoring
nonisolated func observeAppActivation() -> AsyncStream<AppActivation> {
activationStream
}
private nonisolated func mapIOHIDAccess(_ access: IOHIDAccessType) -> PermissionStatus {
switch access {
case kIOHIDAccessTypeGranted:
return .granted
case kIOHIDAccessTypeDenied:
return .denied
default:
return .notDetermined
}
}
}
@@ -0,0 +1,97 @@
import Dependencies
import DependenciesMacros
import Foundation
/// A client for managing system permissions (microphone, accessibility) in a composable way.
///
/// This client provides a unified interface for checking permission status, requesting permissions,
/// and monitoring app activation events to reactively update permission state.
///
/// ## Usage
///
/// ```swift
/// @Dependency(\.permissions) var permissions
///
/// // Check current status
/// let micStatus = await permissions.microphoneStatus()
///
/// // Request permission
/// let granted = await permissions.requestMicrophone()
///
/// // Monitor app activation for reactive updates
/// for await activation in permissions.observeAppActivation() {
/// if case .didBecomeActive = activation {
/// // Re-check permissions
/// }
/// }
/// ```
@DependencyClient
public struct PermissionClient: Sendable {
/// Check the current microphone permission status.
///
/// This is async to accommodate iOS 18+ where AVCaptureDevice authorization checks
/// may perform I/O operations. On macOS this is typically instant.
public var microphoneStatus: @Sendable () async -> PermissionStatus = { .notDetermined }
/// Check the current accessibility permission status (synchronous).
///
/// Uses `AXIsProcessTrusted` which is a fast, synchronous check.
public var accessibilityStatus: @Sendable () -> PermissionStatus = { .notDetermined }
/// Check the current input monitoring permission status (synchronous).
///
/// Uses `IOHIDCheckAccess` to determine whether we can listen for global keyboard events.
public var inputMonitoringStatus: @Sendable () -> PermissionStatus = { .notDetermined }
/// Request microphone permission from the user.
///
/// If permission is `.notDetermined`, this will show the system permission dialog.
/// If already granted or denied, this will return the current status.
///
/// - Returns: `true` if permission was granted, `false` otherwise
public var requestMicrophone: @Sendable () async -> Bool = { false }
/// Request accessibility permission from the user.
///
/// This triggers the system permission prompt and opens System Settings to the
/// Accessibility privacy panel. The user must manually enable the app in Settings.
public var requestAccessibility: @Sendable () async -> Void = {}
/// Request input monitoring permission from the user.
///
/// Triggers the consent dialog introduced in macOS Sequoia when listening for keyboard events.
public var requestInputMonitoring: @Sendable () async -> Bool = { false }
/// Open System Settings to the microphone privacy panel.
///
/// Useful when permission is denied and the user needs to manually change it.
public var openMicrophoneSettings: @Sendable () async -> Void = {}
/// Open System Settings to the accessibility privacy panel.
///
/// Useful when permission is denied and the user needs to manually change it.
public var openAccessibilitySettings: @Sendable () async -> Void = {}
/// Open System Settings to the Input Monitoring privacy panel.
public var openInputMonitoringSettings: @Sendable () async -> Void = {}
/// Observe app activation events.
///
/// Returns an `AsyncStream` that yields `AppActivation` events when the app
/// becomes active or resigns active status. Use this to reactively re-check
/// permissions when the app comes to the foreground.
///
/// - Note: On macOS, the app is killed when permissions change in System Settings,
/// so continuous polling is unnecessary. Checking on app activation is sufficient.
// Note: An empty-closure AsyncStream never yields and never finishes, matching
// ConcurrencyExtras' `.never` without relying on that (undeclared) dependency.
public var observeAppActivation: @Sendable () -> AsyncStream<AppActivation> = { AsyncStream { _ in } }
}
extension DependencyValues {
/// Access the permission client dependency.
public var permissions: PermissionClient {
get { self[PermissionClient.self] }
set { self[PermissionClient.self] = newValue }
}
}
@@ -0,0 +1,11 @@
/// Represents the authorization status for a system permission
public enum PermissionStatus: Equatable, Codable, Sendable {
/// Permission has not been requested yet
case notDetermined
/// Permission has been granted by the user
case granted
/// Permission has been denied or restricted by the user
case denied
}
@@ -0,0 +1,299 @@
import Foundation
public enum RecordingAudioBehavior: String, Codable, CaseIterable, Equatable, Sendable {
case pauseMedia
case mute
case doNothing
}
/// User-configurable settings saved to disk.
public struct HexSettings: Codable, Equatable, Sendable {
public static let defaultPasteLastTranscriptHotkey = HotKey(key: .v, modifiers: [.option, .shift])
public static let baseSoundEffectsVolume: Double = HexCoreConstants.baseSoundEffectsVolume
public static let defaultWordRemovals: [WordRemoval] = [
.init(pattern: "uh+"),
.init(pattern: "um+"),
.init(pattern: "er+"),
.init(pattern: "hm+")
]
public static var defaultPasteLastTranscriptHotkeyDescription: String {
let modifiers = defaultPasteLastTranscriptHotkey.modifiers.sorted.map { $0.stringValue }.joined()
let key = defaultPasteLastTranscriptHotkey.key?.toString ?? ""
return modifiers + key
}
public var soundEffectsEnabled: Bool
public var soundEffectsVolume: Double
public var hotkey: HotKey
public var openOnLogin: Bool
public var showDockIcon: Bool
public var selectedModel: String
public var useClipboardPaste: Bool
public var preventSystemSleep: Bool
public var recordingAudioBehavior: RecordingAudioBehavior
public var minimumKeyTime: Double
public var copyToClipboard: Bool
public var superFastModeEnabled: Bool
public var useDoubleTapOnly: Bool
public var doubleTapLockEnabled: Bool
public var outputLanguage: String?
public var selectedMicrophoneID: String?
public var saveTranscriptionHistory: Bool
public var maxHistoryEntries: Int?
public var pasteLastTranscriptHotkey: HotKey?
public var hasCompletedModelBootstrap: Bool
public var hasCompletedStorageMigration: Bool
public var wordRemovalsEnabled: Bool
public var wordRemovals: [WordRemoval]
public var wordRemappings: [WordRemapping]
public var lowercaseTranscripts: Bool
public var removePunctuation: Bool
private mutating func normalizeDoubleTapSettings() {
if !doubleTapLockEnabled {
useDoubleTapOnly = false
}
}
public init(
soundEffectsEnabled: Bool = true,
soundEffectsVolume: Double = HexSettings.baseSoundEffectsVolume,
hotkey: HotKey = .init(key: nil, modifiers: [.option]),
openOnLogin: Bool = false,
showDockIcon: Bool = true,
selectedModel: String = ParakeetModel.multilingualV3.identifier,
useClipboardPaste: Bool = true,
preventSystemSleep: Bool = true,
recordingAudioBehavior: RecordingAudioBehavior = .doNothing,
minimumKeyTime: Double = HexCoreConstants.defaultMinimumKeyTime,
copyToClipboard: Bool = false,
superFastModeEnabled: Bool = true,
useDoubleTapOnly: Bool = false,
doubleTapLockEnabled: Bool = true,
outputLanguage: String? = nil,
selectedMicrophoneID: String? = nil,
saveTranscriptionHistory: Bool = true,
maxHistoryEntries: Int? = nil,
pasteLastTranscriptHotkey: HotKey? = HexSettings.defaultPasteLastTranscriptHotkey,
hasCompletedModelBootstrap: Bool = false,
hasCompletedStorageMigration: Bool = false,
wordRemovalsEnabled: Bool = false,
wordRemovals: [WordRemoval] = HexSettings.defaultWordRemovals,
wordRemappings: [WordRemapping] = [],
lowercaseTranscripts: Bool = false,
removePunctuation: Bool = false
) {
self.soundEffectsEnabled = soundEffectsEnabled
self.soundEffectsVolume = soundEffectsVolume
self.hotkey = hotkey
self.openOnLogin = openOnLogin
self.showDockIcon = showDockIcon
self.selectedModel = selectedModel
self.useClipboardPaste = useClipboardPaste
self.preventSystemSleep = preventSystemSleep
self.recordingAudioBehavior = recordingAudioBehavior
self.minimumKeyTime = minimumKeyTime
self.copyToClipboard = copyToClipboard
self.superFastModeEnabled = superFastModeEnabled
self.useDoubleTapOnly = useDoubleTapOnly
self.doubleTapLockEnabled = doubleTapLockEnabled
self.outputLanguage = outputLanguage
self.selectedMicrophoneID = selectedMicrophoneID
self.saveTranscriptionHistory = saveTranscriptionHistory
self.maxHistoryEntries = maxHistoryEntries
self.pasteLastTranscriptHotkey = pasteLastTranscriptHotkey
self.hasCompletedModelBootstrap = hasCompletedModelBootstrap
self.hasCompletedStorageMigration = hasCompletedStorageMigration
self.wordRemovalsEnabled = wordRemovalsEnabled
self.wordRemovals = wordRemovals
self.wordRemappings = wordRemappings
self.lowercaseTranscripts = lowercaseTranscripts
self.removePunctuation = removePunctuation
normalizeDoubleTapSettings()
}
public init(from decoder: Decoder) throws {
self.init()
let container = try decoder.container(keyedBy: HexSettingKey.self)
for field in HexSettingsSchema.fields {
try field.decode(into: &self, from: container)
}
normalizeDoubleTapSettings()
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: HexSettingKey.self)
for field in HexSettingsSchema.fields {
try field.encode(self, into: &container)
}
}
}
// MARK: - Schema
private enum HexSettingKey: String, CodingKey, CaseIterable {
case soundEffectsEnabled
case soundEffectsVolume
case hotkey
case openOnLogin
case showDockIcon
case selectedModel
case useClipboardPaste
case preventSystemSleep
case recordingAudioBehavior
case pauseMediaOnRecord // Legacy
case minimumKeyTime
case copyToClipboard
case superFastModeEnabled
case useDoubleTapOnly
case doubleTapLockEnabled
case outputLanguage
case selectedMicrophoneID
case saveTranscriptionHistory
case maxHistoryEntries
case pasteLastTranscriptHotkey
case hasCompletedModelBootstrap
case hasCompletedStorageMigration
case wordRemovalsEnabled
case wordRemovals
case wordRemappings
case lowercaseTranscripts
case removePunctuation
}
private struct SettingsField<Value: Codable & Sendable> {
let key: HexSettingKey
let keyPath: WritableKeyPath<HexSettings, Value>
let defaultValue: Value
let decodeStrategy: (KeyedDecodingContainer<HexSettingKey>, HexSettingKey, Value) throws -> Value
let encodeStrategy: (inout KeyedEncodingContainer<HexSettingKey>, HexSettingKey, Value) throws -> Void
init(
_ key: HexSettingKey,
keyPath: WritableKeyPath<HexSettings, Value>,
default defaultValue: Value,
decode: ((KeyedDecodingContainer<HexSettingKey>, HexSettingKey, Value) throws -> Value)? = nil,
encode: ((inout KeyedEncodingContainer<HexSettingKey>, HexSettingKey, Value) throws -> Void)? = nil
) {
self.key = key
self.keyPath = keyPath
self.defaultValue = defaultValue
self.decodeStrategy = decode ?? { container, key, defaultValue in
try container.decodeIfPresent(Value.self, forKey: key) ?? defaultValue
}
self.encodeStrategy = encode ?? { container, key, value in
try container.encode(value, forKey: key)
}
}
func eraseToAny() -> AnySettingsField {
AnySettingsField(
key: key,
decode: { container, settings in
let value = try decodeStrategy(container, key, defaultValue)
settings[keyPath: keyPath] = value
},
encode: { settings, container in
let value = settings[keyPath: keyPath]
try encodeStrategy(&container, key, value)
}
)
}
}
private struct AnySettingsField {
let key: HexSettingKey
let decode: (KeyedDecodingContainer<HexSettingKey>, inout HexSettings) throws -> Void
let encode: (HexSettings, inout KeyedEncodingContainer<HexSettingKey>) throws -> Void
func decode(into settings: inout HexSettings, from container: KeyedDecodingContainer<HexSettingKey>) throws {
try decode(container, &settings)
}
func encode(_ settings: HexSettings, into container: inout KeyedEncodingContainer<HexSettingKey>) throws {
try encode(settings, &container)
}
}
private enum HexSettingsSchema {
static let defaults = HexSettings()
nonisolated(unsafe) static let fields: [AnySettingsField] = [
SettingsField(.soundEffectsEnabled, keyPath: \.soundEffectsEnabled, default: defaults.soundEffectsEnabled).eraseToAny(),
SettingsField(.soundEffectsVolume, keyPath: \.soundEffectsVolume, default: defaults.soundEffectsVolume).eraseToAny(),
SettingsField(.hotkey, keyPath: \.hotkey, default: defaults.hotkey).eraseToAny(),
SettingsField(.openOnLogin, keyPath: \.openOnLogin, default: defaults.openOnLogin).eraseToAny(),
SettingsField(.showDockIcon, keyPath: \.showDockIcon, default: defaults.showDockIcon).eraseToAny(),
SettingsField(.selectedModel, keyPath: \.selectedModel, default: defaults.selectedModel).eraseToAny(),
SettingsField(.useClipboardPaste, keyPath: \.useClipboardPaste, default: defaults.useClipboardPaste).eraseToAny(),
SettingsField(.preventSystemSleep, keyPath: \.preventSystemSleep, default: defaults.preventSystemSleep).eraseToAny(),
SettingsField(
.recordingAudioBehavior,
keyPath: \.recordingAudioBehavior,
default: defaults.recordingAudioBehavior,
decode: { container, key, defaultValue in
if let value = try container.decodeIfPresent(RecordingAudioBehavior.self, forKey: key) {
return value
}
if let legacyPause = try container.decodeIfPresent(Bool.self, forKey: .pauseMediaOnRecord) {
return legacyPause ? .pauseMedia : .doNothing
}
return defaultValue
}
).eraseToAny(),
SettingsField(.minimumKeyTime, keyPath: \.minimumKeyTime, default: defaults.minimumKeyTime).eraseToAny(),
SettingsField(.copyToClipboard, keyPath: \.copyToClipboard, default: defaults.copyToClipboard).eraseToAny(),
SettingsField(.superFastModeEnabled, keyPath: \.superFastModeEnabled, default: defaults.superFastModeEnabled).eraseToAny(),
SettingsField(.useDoubleTapOnly, keyPath: \.useDoubleTapOnly, default: defaults.useDoubleTapOnly).eraseToAny(),
SettingsField(.doubleTapLockEnabled, keyPath: \.doubleTapLockEnabled, default: defaults.doubleTapLockEnabled).eraseToAny(),
SettingsField(
.outputLanguage,
keyPath: \.outputLanguage,
default: defaults.outputLanguage,
encode: { container, key, value in
try container.encodeIfPresent(value, forKey: key)
}
).eraseToAny(),
SettingsField(
.selectedMicrophoneID,
keyPath: \.selectedMicrophoneID,
default: defaults.selectedMicrophoneID,
encode: { container, key, value in
try container.encodeIfPresent(value, forKey: key)
}
).eraseToAny(),
SettingsField(.saveTranscriptionHistory, keyPath: \.saveTranscriptionHistory, default: defaults.saveTranscriptionHistory).eraseToAny(),
SettingsField(
.maxHistoryEntries,
keyPath: \.maxHistoryEntries,
default: defaults.maxHistoryEntries,
encode: { container, key, value in
try container.encodeIfPresent(value, forKey: key)
}
).eraseToAny(),
SettingsField(
.pasteLastTranscriptHotkey,
keyPath: \.pasteLastTranscriptHotkey,
default: defaults.pasteLastTranscriptHotkey,
encode: { container, key, value in
try container.encodeIfPresent(value, forKey: key)
}
).eraseToAny(),
SettingsField(.hasCompletedModelBootstrap, keyPath: \.hasCompletedModelBootstrap, default: defaults.hasCompletedModelBootstrap).eraseToAny(),
SettingsField(.hasCompletedStorageMigration, keyPath: \.hasCompletedStorageMigration, default: defaults.hasCompletedStorageMigration).eraseToAny(),
SettingsField(.wordRemovalsEnabled, keyPath: \.wordRemovalsEnabled, default: defaults.wordRemovalsEnabled).eraseToAny(),
SettingsField(
.wordRemovals,
keyPath: \.wordRemovals,
default: defaults.wordRemovals
).eraseToAny(),
SettingsField(
.wordRemappings,
keyPath: \.wordRemappings,
default: defaults.wordRemappings
).eraseToAny(),
SettingsField(.lowercaseTranscripts, keyPath: \.lowercaseTranscripts, default: defaults.lowercaseTranscripts).eraseToAny(),
SettingsField(.removePunctuation, keyPath: \.removePunctuation, default: defaults.removePunctuation).eraseToAny()
]
}
@@ -0,0 +1,50 @@
import Dependencies
import IOKit.pwr_mgt
extension SleepManagementClient: DependencyKey {
public static var liveValue: Self {
let live = SleepManagementClientLive()
return Self(
preventSleep: { reason in
await live.preventSleep(reason: reason)
},
allowSleep: {
await live.allowSleep()
}
)
}
}
/// Live implementation of SleepManagementClient that manages assertion lifecycle.
actor SleepManagementClientLive {
private var currentAssertionID: IOPMAssertionID?
func preventSleep(reason: String) {
// Release any existing assertion first
if let existingID = currentAssertionID {
IOPMAssertionRelease(existingID)
currentAssertionID = nil
}
// Create new assertion
let reasonForActivity = reason as CFString
var assertionID: IOPMAssertionID = 0
let success = IOPMAssertionCreateWithName(
kIOPMAssertionTypeNoDisplaySleep as CFString,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
reasonForActivity,
&assertionID
)
if success == kIOReturnSuccess {
currentAssertionID = assertionID
}
}
func allowSleep() {
if let assertionID = currentAssertionID {
IOPMAssertionRelease(assertionID)
currentAssertionID = nil
}
}
}
@@ -0,0 +1,44 @@
import Dependencies
import DependenciesMacros
/// Client for managing system sleep prevention during critical operations.
///
/// On macOS, this uses IOKit power assertions to prevent the display from sleeping
/// while operations like voice recording are in progress.
///
/// The client manages assertion lifecycle internally - calling `preventSleep` multiple times
/// will automatically release any previous assertion before creating a new one.
///
/// ## Usage
///
/// ```swift
/// @Dependency(\.sleepManagement) var sleepManagement
///
/// // Prevent sleep during recording
/// await sleepManagement.preventSleep(reason: "Voice Recording")
/// // Recording in progress...
/// await sleepManagement.allowSleep()
/// ```
@DependencyClient
public struct SleepManagementClient: Sendable {
/// Prevent the system from sleeping.
///
/// If sleep is already being prevented, this will release the old assertion
/// and create a new one with the updated reason.
///
/// - Parameter reason: A human-readable string describing why sleep is being prevented
public var preventSleep: @Sendable (_ reason: String) async -> Void = { _ in }
/// Allow the system to sleep again by releasing any active assertion.
///
/// Safe to call even if no assertion is active.
public var allowSleep: @Sendable () async -> Void = {}
}
extension DependencyValues {
/// Access the sleep management client dependency.
public var sleepManagement: SleepManagementClient {
get { self[SleepManagementClient.self] }
set { self[SleepManagementClient.self] = newValue }
}
}
+71
View File
@@ -0,0 +1,71 @@
import Foundation
public extension URL {
static var hexApplicationSupport: URL {
get throws {
let fm = FileManager.default
let appSupport = try fm.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let hexDirectory = appSupport.appendingPathComponent("com.kitlangton.Hex", isDirectory: true)
try fm.createDirectory(at: hexDirectory, withIntermediateDirectories: true)
return hexDirectory
}
}
static var legacyDocumentsDirectory: URL {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}
static func hexMigratedFileURL(named fileName: String) -> URL {
let newURL = (try? hexApplicationSupport.appending(component: fileName))
?? documentsDirectory.appending(component: fileName)
let legacyURL = legacyDocumentsDirectory.appending(component: fileName)
FileManager.default.migrateIfNeeded(from: legacyURL, to: newURL)
return newURL
}
static var hexModelsDirectory: URL {
get throws {
let modelsDirectory = try hexApplicationSupport.appendingPathComponent("models", isDirectory: true)
try FileManager.default.createDirectory(at: modelsDirectory, withIntermediateDirectories: true)
return modelsDirectory
}
}
/// Where FluidAudio (Parakeet) keeps its on-disk model caches.
///
/// FluidAudio writes to `<Application Support>/FluidAudio/Models/<variant>` in
/// the sandboxed container, regardless of `XDG_CACHE_HOME`. We surface that
/// location so "Show in Finder" can reveal Parakeet caches instead of the
/// WhisperKit-only models directory.
static var hexParakeetModelsDirectory: URL {
get throws {
let fm = FileManager.default
let appSupport = try fm.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let dir = appSupport.appendingPathComponent("FluidAudio/Models", isDirectory: true)
try fm.createDirectory(at: dir, withIntermediateDirectories: true)
return dir
}
}
}
public extension FileManager {
func migrateIfNeeded(from legacy: URL, to new: URL) {
guard fileExists(atPath: legacy.path), !fileExists(atPath: new.path) else { return }
try? copyItem(at: legacy, to: new)
}
func removeItemIfExists(at url: URL) {
guard fileExists(atPath: url.path) else { return }
try? removeItem(at: url)
}
}
@@ -0,0 +1,56 @@
import Dependencies
import Foundation
public struct TranscriptPersistenceClient: Sendable {
public var save: @Sendable (
_ result: String,
_ audioURL: URL,
_ duration: TimeInterval,
_ sourceAppBundleID: String?,
_ sourceAppName: String?
) async throws -> Transcript
public var deleteAudio: @Sendable (_ transcript: Transcript) async throws -> Void
}
extension TranscriptPersistenceClient: DependencyKey {
public static let liveValue: TranscriptPersistenceClient = {
return TranscriptPersistenceClient(
save: { result, audioURL, duration, sourceAppBundleID, sourceAppName in
let fm = FileManager.default
let recordingsFolder = try URL.hexApplicationSupport.appendingPathComponent("Recordings", isDirectory: true)
try fm.createDirectory(at: recordingsFolder, withIntermediateDirectories: true)
let filename = "\(Date().timeIntervalSince1970).wav"
let finalURL = recordingsFolder.appendingPathComponent(filename)
try fm.moveItem(at: audioURL, to: finalURL)
return Transcript(
timestamp: Date(),
text: result,
audioPath: finalURL,
duration: duration,
sourceAppBundleID: sourceAppBundleID,
sourceAppName: sourceAppName
)
},
deleteAudio: { transcript in
FileManager.default.removeItemIfExists(at: transcript.audioPath)
}
)
}()
public static let testValue = TranscriptPersistenceClient(
save: { _, _, _, _, _ in
Transcript(timestamp: Date(), text: "", audioPath: URL(fileURLWithPath: "/"), duration: 0)
},
deleteAudio: { _ in }
)
}
public extension DependencyValues {
var transcriptPersistence: TranscriptPersistenceClient {
get { self[TranscriptPersistenceClient.self] }
set { self[TranscriptPersistenceClient.self] = newValue }
}
}