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
+78
View File
@@ -0,0 +1,78 @@
{
"originHash" : "31930225da43237330c66527a658f3d42dc4c7f482922f5d83ef0030c5e414b3",
"pins" : [
{
"identity" : "combine-schedulers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/combine-schedulers",
"state" : {
"revision" : "fd16d76fd8b9a976d88bfb6cacc05ca8d19c91b6",
"version" : "1.1.0"
}
},
{
"identity" : "sauce",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Clipy/Sauce",
"state" : {
"branch" : "master",
"revision" : "9c0de6c233f29d892e86dda68c2dd791aa10670c"
}
},
{
"identity" : "swift-clocks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-clocks",
"state" : {
"revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e",
"version" : "1.0.6"
}
},
{
"identity" : "swift-concurrency-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-concurrency-extras",
"state" : {
"revision" : "5a3825302b1a0d744183200915a47b508c828e6f",
"version" : "1.3.2"
}
},
{
"identity" : "swift-dependencies",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-dependencies",
"state" : {
"revision" : "c79f72b3e67a1eb64f66f76704c22ed6a5c1ed84",
"version" : "1.11.0"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log",
"state" : {
"revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523",
"version" : "1.10.1"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
"revision" : "4799286537280063c85a32f09884cfbca301b1a1",
"version" : "602.0.0"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "dfd70507def84cb5fb821278448a262c6ff2bbad",
"version" : "1.9.0"
}
}
],
"version" : 3
}
+38
View File
@@ -0,0 +1,38 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "HexCore",
platforms: [.macOS(.v14)],
products: [
.library(name: "HexCore", targets: ["HexCore"]),
],
dependencies: [
.package(url: "https://github.com/Clipy/Sauce", branch: "master"),
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.11.0"),
.package(url: "https://github.com/apple/swift-log", from: "1.9.1"),
],
targets: [
.target(
name: "HexCore",
dependencies: [
"Sauce",
.product(name: "Dependencies", package: "swift-dependencies"),
.product(name: "DependenciesMacros", package: "swift-dependencies"),
.product(name: "Logging", package: "swift-log"),
],
path: "Sources/HexCore",
linkerSettings: [
.linkedFramework("IOKit")
]
),
.testTarget(
name: "HexCoreTests",
dependencies: ["HexCore"],
path: "Tests/HexCoreTests",
resources: [
.copy("Fixtures")
]
),
]
)
+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 }
}
}
@@ -0,0 +1,35 @@
{
"soundEffectsEnabled": false,
"hotkey": {
"key": null,
"modifiers": {
"modifiers": [
"option"
]
}
},
"openOnLogin": true,
"showDockIcon": false,
"selectedModel": "whisper-large-v3",
"useClipboardPaste": false,
"preventSystemSleep": true,
"pauseMediaOnRecord": true,
"minimumKeyTime": 0.25,
"copyToClipboard": true,
"useDoubleTapOnly": true,
"outputLanguage": "en",
"selectedMicrophoneID": "builtin:mic",
"saveTranscriptionHistory": false,
"maxHistoryEntries": 10,
"pasteLastTranscriptHotkey": {
"key": "v",
"modifiers": {
"modifiers": [
"option",
"shift"
]
}
},
"hasCompletedModelBootstrap": true,
"hasCompletedStorageMigration": true
}
@@ -0,0 +1,84 @@
import XCTest
@testable import HexCore
final class HexSettingsMigrationTests: XCTestCase {
func testV1FixtureMigratesToCurrentDefaults() throws {
let data = try loadFixture(named: "v1")
let decoded = try JSONDecoder().decode(HexSettings.self, from: data)
XCTAssertEqual(decoded.recordingAudioBehavior, .pauseMedia, "Legacy pauseMediaOnRecord bool should map to pauseMedia behavior")
XCTAssertEqual(decoded.soundEffectsEnabled, false)
XCTAssertEqual(decoded.soundEffectsVolume, HexSettings.baseSoundEffectsVolume)
XCTAssertEqual(decoded.openOnLogin, true)
XCTAssertEqual(decoded.showDockIcon, false)
XCTAssertEqual(decoded.selectedModel, "whisper-large-v3")
XCTAssertEqual(decoded.useClipboardPaste, false)
XCTAssertEqual(decoded.preventSystemSleep, true)
XCTAssertEqual(decoded.minimumKeyTime, 0.25)
XCTAssertEqual(decoded.copyToClipboard, true)
XCTAssertTrue(decoded.superFastModeEnabled)
XCTAssertEqual(decoded.useDoubleTapOnly, true)
XCTAssertEqual(decoded.doubleTapLockEnabled, true)
XCTAssertEqual(decoded.outputLanguage, "en")
XCTAssertEqual(decoded.selectedMicrophoneID, "builtin:mic")
XCTAssertEqual(decoded.saveTranscriptionHistory, false)
XCTAssertEqual(decoded.maxHistoryEntries, 10)
XCTAssertEqual(decoded.hasCompletedModelBootstrap, true)
XCTAssertEqual(decoded.hasCompletedStorageMigration, true)
XCTAssertFalse(decoded.lowercaseTranscripts)
XCTAssertFalse(decoded.removePunctuation)
}
func testEncodeDecodeRoundTripPreservesDefaults() throws {
let settings = HexSettings()
let data = try JSONEncoder().encode(settings)
let decoded = try JSONDecoder().decode(HexSettings.self, from: data)
XCTAssertEqual(decoded, settings)
}
func testNewSettingsEnableSuperFastModeByDefault() {
XCTAssertTrue(HexSettings().superFastModeEnabled)
}
func testInitNormalizesDoubleTapOnlyWhenLockDisabled() {
let settings = HexSettings(useDoubleTapOnly: true, doubleTapLockEnabled: false)
XCTAssertFalse(settings.useDoubleTapOnly)
XCTAssertFalse(settings.doubleTapLockEnabled)
}
func testDecodeNormalizesDoubleTapOnlyWhenLockDisabled() throws {
let payload = "{\"useDoubleTapOnly\":true,\"doubleTapLockEnabled\":false}"
guard let data = payload.data(using: .utf8) else {
XCTFail("Failed to encode JSON payload")
return
}
let decoded = try JSONDecoder().decode(HexSettings.self, from: data)
XCTAssertFalse(decoded.useDoubleTapOnly)
XCTAssertFalse(decoded.doubleTapLockEnabled)
}
func testEncodeDecodeRoundTripPreservesNormalizedDoubleTapValues() throws {
let settings = HexSettings(useDoubleTapOnly: true, doubleTapLockEnabled: false)
let data = try JSONEncoder().encode(settings)
let decoded = try JSONDecoder().decode(HexSettings.self, from: data)
XCTAssertFalse(settings.useDoubleTapOnly)
XCTAssertFalse(decoded.useDoubleTapOnly)
XCTAssertEqual(decoded, settings)
}
private func loadFixture(named name: String) throws -> Data {
guard let url = Bundle.module.url(
forResource: name,
withExtension: "json",
subdirectory: "Fixtures/HexSettings"
) else {
XCTFail("Missing fixture \(name).json")
throw NSError(domain: "Fixture", code: 0)
}
return try Data(contentsOf: url)
}
}
@@ -0,0 +1,934 @@
//
// HotKeyProcessorTests.swift
// HexCoreTests
//
// Created by Kit Langton on 1/27/25.
//
import Dependencies
import Foundation
@testable import HexCore
import Sauce
import Testing
struct HotKeyProcessorTests {
// MARK: - Standard HotKey (key + modifiers) Tests
// Tests a single key press that matches the hotkey
@Test
func pressAndHold_startsRecordingOnHotkey_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
@Test
func pressAndHold_startsRecordingOnHotkey_modifierOnly() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
// Tests releasing the hotkey stops recording
@Test
func pressAndHold_stopsRecordingOnHotkeyRelease_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
ScenarioStep(time: 0.2, key: nil, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
@Test
func pressAndHold_stopsRecordingOnHotkeyRelease_modifierOnly() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
ScenarioStep(time: 0.2, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
@Test
func pressAndHold_stopsRecordingOnHotkeyRelease_multipleModifiers() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option, .command]),
steps: [
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: false),
ScenarioStep(time: 0.1, key: nil, modifiers: [.option, .command], expectedOutput: .startRecording, expectedIsMatched: true),
ScenarioStep(time: 0.2, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
@Test
func pressAndHold_releasingModifierBeforeKeyStillStops() throws {
runScenario(
hotkey: HotKey(key: .u, modifiers: [.option]),
steps: [
// Press modifier first (Option)
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: false),
// Then press the key to start recording
ScenarioStep(time: 0.05, key: .u, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Release the modifier while holding the key
ScenarioStep(time: 1.5, key: .u, modifiers: [], expectedOutput: nil, expectedIsMatched: true),
// Release the key a beat later — should stop recording automatically
ScenarioStep(time: 1.55, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// Tests pressing a different key cancels recording
@Test
func pressAndHold_cancelsOnOtherKeyPress_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// Initial hotkey press
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Different key press within cancel threshold
ScenarioStep(time: 0.5, key: .b, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// For modifier-only hotkeys, extra modifiers after threshold are ignored
@Test
func pressAndHold_ignoresExtraModifierAfterThreshold_modifierOnly() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// Initial hotkey press (option)
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Press a different modifier after threshold (0.5s > 0.3s) - should be ignored
ScenarioStep(time: 0.5, key: nil, modifiers: [.option, .command], expectedOutput: nil, expectedIsMatched: true),
]
)
}
// Tests that pressing a different key after threshold doesn't cancel
@Test
func pressAndHold_doesNotCancelAfterThreshold_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// Initial hotkey press
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Different key press after cancel threshold
ScenarioStep(time: 1.5, key: .b, modifiers: [.command], expectedOutput: nil, expectedIsMatched: true),
]
)
}
@Test
func pressAndHold_doesNotCancelAfterThreshold_modifierOnly() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// Initial hotkey press
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Different modifier press after cancel threshold
ScenarioStep(time: 1.5, key: nil, modifiers: [.option, .command], expectedOutput: nil, expectedIsMatched: true),
]
)
}
// The user cannot "backslide" into pressing the hotkey. If the user is chording extra modifiers,
// everything must be released before a hotkey can trigger
@Test
func pressAndHold_doesNotTriggerOnBackslide_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// They press the hotkey with an extra modifier
ScenarioStep(time: 0.0, key: .a, modifiers: [.command, .shift], expectedOutput: nil, expectedIsMatched: false),
// And then release the extra modifier, nothing should happen
ScenarioStep(time: 0.1, key: .a, modifiers: [.command], expectedOutput: nil, expectedIsMatched: false),
// Then if they release everything, the hotkey should trigger
ScenarioStep(time: 0.2, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// And try to press the hotkey again, it should start recording
ScenarioStep(time: 0.3, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
// Tests double-tap to lock recording
@Test
func doubleTapLock_startsRecordingOnDoubleTap_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
// Release all modifiers
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// Press modifier again
ScenarioStep(time: 0.15, key: nil, modifiers: [.command], expectedOutput: nil, expectedIsMatched: false),
// Second tap within threshold
ScenarioStep(time: 0.2, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Second release (should stay recording)
ScenarioStep(time: 0.3, key: nil, modifiers: [.command], expectedOutput: nil, expectedIsMatched: true, expectedState: .doubleTapLock),
]
)
}
@Test
func doubleTapLock_startsRecordingOnDoubleTap_modifierOnly() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap within threshold
ScenarioStep(time: 0.2, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Second release (should stay recording)
ScenarioStep(time: 0.3, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: true, expectedState: .doubleTapLock),
]
)
}
@Test
func doubleTapLock_startsRecordingOnDoubleTap_multipleModifiers() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option, .command]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: false),
ScenarioStep(time: 0.05, key: nil, modifiers: [.option, .command], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [.option], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap within threshold
ScenarioStep(time: 0.2, key: nil, modifiers: [.option, .command], expectedOutput: .startRecording, expectedIsMatched: true),
// Second release (should stay recording)
ScenarioStep(time: 0.3, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: true, expectedState: .doubleTapLock),
]
)
}
// Tests that a slow double tap doesn't lock recording
@Test
func doubleTapLock_ignoresSlowDoubleTap_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap after threshold
ScenarioStep(time: 0.4, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
@Test
func doubleTapLock_ignoresSlowDoubleTap_modifierOnly() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap after threshold
ScenarioStep(time: 0.4, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
// Tests that tapping again after double-tap lock stops recording
@Test
func doubleTapLock_stopsRecordingOnNextTap_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap within threshold
ScenarioStep(time: 0.2, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Second release (should stay recording)
ScenarioStep(time: 0.3, key: nil, modifiers: [.command], expectedOutput: nil, expectedIsMatched: true, expectedState: .doubleTapLock),
// Third tap to stop recording
ScenarioStep(time: 1.0, key: .a, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
@Test
func doubleTapLock_stopsRecordingOnNextTap_modifierOnly() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap within threshold
ScenarioStep(time: 0.2, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Second release (should stay recording)
ScenarioStep(time: 0.3, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: true, expectedState: .doubleTapLock),
// Third tap to stop recording
ScenarioStep(time: 1.0, key: nil, modifiers: [.option], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
@Test
func doubleTapLock_disabled_staysPressAndHold_standard() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
doubleTapLockEnabled: false,
steps: [
// First tap
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
// Release all modifiers
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// Press modifier again
ScenarioStep(time: 0.15, key: nil, modifiers: [.command], expectedOutput: nil, expectedIsMatched: false),
// Second tap within threshold
ScenarioStep(time: 0.2, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Second release should stop normally (no lock)
ScenarioStep(time: 0.3, key: nil, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false, expectedState: .idle),
]
)
}
@Test
func doubleTapOnly_ignoredWhenDoubleTapLockDisabled() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
useDoubleTapOnly: true,
doubleTapLockEnabled: false,
steps: [
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
ScenarioStep(time: 0.2, key: nil, modifiers: [.command], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// MARK: - Edge Cases
// Tests that after pressing a key with option, releasing the key but keeping option pressed
// does not restart recording due to the "dirty" state
@Test
func pressAndHold_stopsRecordingOnKeyPressAndStaysDirty() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// Initial hotkey press (option)
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Press a different key within cancel threshold - should discard silently since < minimumKeyTime
ScenarioStep(time: 0.1, key: .c, modifiers: [.option], expectedOutput: .discard, expectedIsMatched: false),
// Release the C
ScenarioStep(time: 0.2, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: false),
]
)
}
// MARK: - Fn + Arrow Regression
// After using Fn with another key (e.g., Arrow), then fully releasing,
// a subsequent standalone Fn press should be recognized and start recording.
// This guards against the state getting "stuck" after Fn+Arrow usage (Issue #81).
@Test
func modifierOnly_fn_triggersAfterFnPlusKeyThenFullRelease() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.fn]),
steps: [
// Simulate using an Arrow with Fn held (use .c as a stand-in key for arrows in unit tests)
ScenarioStep(time: 0.00, key: .c, modifiers: [.fn], expectedOutput: nil, expectedIsMatched: false),
// Fully release everything
ScenarioStep(time: 0.05, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// Next standalone Fn press should trigger recording
ScenarioStep(time: 0.20, key: nil, modifiers: [.fn], expectedOutput: .startRecording, expectedIsMatched: true),
// Release Fn should stop recording (must exceed modifierOnlyMinimumDuration)
ScenarioStep(time: 0.40, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// If the user uses Fn+Key and releases only the key (keeps Fn held),
// we must NOT trigger — no standalone Fn edge occurred.
@Test
func modifierOnly_fn_doesNotTriggerWhenFnRemainsHeldAfterKeyRelease() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.fn]),
steps: [
// Use Fn with another key (stand-in for arrow)
ScenarioStep(time: 0.00, key: .c, modifiers: [.fn], expectedOutput: nil, expectedIsMatched: false),
// Release the key but keep Fn held — should not start
ScenarioStep(time: 0.05, key: nil, modifiers: [.fn], expectedOutput: nil, expectedIsMatched: false),
// Only once the user fully releases and presses Fn again should it start
ScenarioStep(time: 0.10, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
ScenarioStep(time: 0.25, key: nil, modifiers: [.fn], expectedOutput: .startRecording, expectedIsMatched: true),
// Must exceed modifierOnlyMinimumDuration before stopping
ScenarioStep(time: 0.60, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// The user presses and holds options, therefore it should start recording and then after two seconds he also presses command, which should not do anything.
@Test
func pressAndHold_staysDirtyAfterTwoSeconds() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// Initial hotkey press (option)
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Press command after two seconds
ScenarioStep(time: 2.0, key: nil, modifiers: [.option, .command], expectedOutput: nil, expectedIsMatched: true),
// Release command
ScenarioStep(time: 2.1, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: true),
// Release option
ScenarioStep(time: 2.2, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// Tests that double-tap lock only engages after the second release, not the second press
@Test
func doubleTap_onlyLocksAfterSecondRelease() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap within threshold - should start a new recording but not lock yet
ScenarioStep(time: 0.2, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true, expectedState: .pressAndHold(startTime: Date(timeIntervalSince1970: 0.2))),
// Second release - NOW it should lock
ScenarioStep(time: 0.3, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: true, expectedState: .doubleTapLock),
]
)
}
// Tests that if second tap is held too long, it's treated as a new press-and-hold instead of double-tap
@Test
func doubleTap_secondTapHeldTooLongBecomesHold() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second press within threshold
ScenarioStep(time: 0.2, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Hold for 2 seconds (should stay in press-and-hold mode)
ScenarioStep(time: 2.2, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: true),
// Release - should stop recording since it was a hold
ScenarioStep(time: 2.3, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// MARK: - Additional Coverage Tests
// Tests ESC cancellation from hold state
@Test
func escape_cancelsFromHold() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// Start recording
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Press ESC
ScenarioStep(time: 0.5, key: .escape, modifiers: [], expectedOutput: .cancel, expectedIsMatched: false),
]
)
}
// Tests ESC cancellation from lock state
@Test
func escape_cancelsFromLock() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// First tap
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// First release
ScenarioStep(time: 0.1, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
// Second tap (locks)
ScenarioStep(time: 0.2, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
ScenarioStep(time: 0.3, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: true),
// Now locked - press ESC
ScenarioStep(time: 1.0, key: .escape, modifiers: [], expectedOutput: .cancel, expectedIsMatched: false),
]
)
}
// Tests that ESC while holding hotkey doesn't restart recording (issue #36)
@Test
func escape_whileHoldingHotkey_doesNotRestart() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// Start recording
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Press ESC while still holding hotkey
ScenarioStep(time: 0.5, key: .escape, modifiers: [.command], expectedOutput: .cancel, expectedIsMatched: false),
// Hotkey still held - should be ignored (dirty)
ScenarioStep(time: 0.6, key: .a, modifiers: [.command], expectedOutput: nil, expectedIsMatched: false),
// Full release
ScenarioStep(time: 0.7, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// Now pressing hotkey should work again
ScenarioStep(time: 0.8, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
// Tests that modifier-only hotkey doesn't trigger when used with other keys (issue #87)
@Test
func modifierOnly_doesNotTriggerWithOtherKeys() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.command, .option]),
steps: [
// User presses cmd-option-T (keyboard shortcut)
ScenarioStep(time: 0.0, key: .t, modifiers: [.command, .option], expectedOutput: nil, expectedIsMatched: false),
// Release T but keep modifiers held
ScenarioStep(time: 0.1, key: nil, modifiers: [.command, .option], expectedOutput: nil, expectedIsMatched: false),
// Full release
ScenarioStep(time: 0.2, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// Now press just cmd-option (no key) - should trigger
ScenarioStep(time: 0.3, key: nil, modifiers: [.command, .option], expectedOutput: .startRecording, expectedIsMatched: true),
// Release
ScenarioStep(time: 0.4, key: nil, modifiers: [], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// Tests that partially releasing multiple modifiers counts as full release
@Test
func multipleModifiers_partialRelease() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option, .command]),
steps: [
// Press both modifiers
ScenarioStep(time: 0.0, key: nil, modifiers: [.option, .command], expectedOutput: .startRecording, expectedIsMatched: true),
// Release Command (keep Option) - should stop recording
ScenarioStep(time: 0.5, key: nil, modifiers: [.option], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// Tests that adding extra modifier to multiple-modifier hotkey after threshold is ignored
@Test
func multipleModifiers_addingExtra_ignoredAfterThreshold() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option, .command]),
steps: [
// Press both required modifiers
ScenarioStep(time: 0.0, key: nil, modifiers: [.option, .command], expectedOutput: .startRecording, expectedIsMatched: true),
// Add Shift after threshold (0.5s > 0.3s) - should be ignored
ScenarioStep(time: 0.5, key: nil, modifiers: [.option, .command, .shift], expectedOutput: nil, expectedIsMatched: true),
]
)
}
// Tests that changing modifiers on same key cancels within 1s
@Test
func keyModifier_changingModifiers_cancelsWithin1s() throws {
runScenario(
hotkey: HotKey(key: .a, modifiers: [.command]),
steps: [
// Initial hotkey press
ScenarioStep(time: 0.0, key: .a, modifiers: [.command], expectedOutput: .startRecording, expectedIsMatched: true),
// Add Shift modifier while keeping same key, within 1s
ScenarioStep(time: 0.5, key: .a, modifiers: [.command, .shift], expectedOutput: .stopRecording, expectedIsMatched: false),
]
)
}
// Tests that dirty state blocks all input until full release
@Test
func dirtyState_blocksInputUntilFullRelease() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option]),
steps: [
// Start recording
ScenarioStep(time: 0.0, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
// Press extra modifier - discards silently since < minimumKeyTime and goes dirty
ScenarioStep(time: 0.1, key: nil, modifiers: [.option, .command], expectedOutput: .discard, expectedIsMatched: false),
// Try pressing hotkey again - should be ignored (dirty)
ScenarioStep(time: 0.2, key: nil, modifiers: [.option], expectedOutput: nil, expectedIsMatched: false),
// Try pressing different keys - should be ignored (dirty)
ScenarioStep(time: 0.3, key: .c, modifiers: [.option], expectedOutput: nil, expectedIsMatched: false),
// Full release - clears dirty
ScenarioStep(time: 0.4, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// Now hotkey works again
ScenarioStep(time: 0.5, key: nil, modifiers: [.option], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
// Tests that you can't activate by releasing extra modifiers (backslide)
@Test
func multipleModifiers_noBackslideActivation() throws {
runScenario(
hotkey: HotKey(key: nil, modifiers: [.option, .command]),
steps: [
// Press with extra modifier (doesn't match)
ScenarioStep(time: 0.0, key: nil, modifiers: [.option, .command, .shift], expectedOutput: nil, expectedIsMatched: false),
// Release Shift - now matches hotkey exactly, but should NOT activate (backslide)
ScenarioStep(time: 0.1, key: nil, modifiers: [.option, .command], expectedOutput: nil, expectedIsMatched: false),
// Full release
ScenarioStep(time: 0.2, key: nil, modifiers: [], expectedOutput: nil, expectedIsMatched: false),
// NOW pressing hotkey should work
ScenarioStep(time: 0.3, key: nil, modifiers: [.option, .command], expectedOutput: .startRecording, expectedIsMatched: true),
]
)
}
}
struct ScenarioStep {
/// The time offset (in seconds) relative to the scenario start.
let time: TimeInterval
/// Which key (if any) is pressed in this chord
let key: Key?
/// Which modifiers are held in this chord
let modifiers: Modifiers
/// The expected output from `processor.process(...)` at this step,
/// or `nil` if we expect no output.
let expectedOutput: HotKeyProcessor.Output?
/// Whether we expect `processor.isMatched` after this step, or `nil` if we don't care.
let expectedIsMatched: Bool?
/// If we want to check the processor's exact `state`.
/// This is optional; if `nil` we won't check it.
let expectedState: HotKeyProcessor.State?
init(
time: TimeInterval,
key: Key? = nil,
modifiers: Modifiers = [],
expectedOutput: HotKeyProcessor.Output? = nil,
expectedIsMatched: Bool? = nil,
expectedState: HotKeyProcessor.State? = nil
) {
self.time = time
self.key = key
self.modifiers = modifiers
self.expectedOutput = expectedOutput
self.expectedIsMatched = expectedIsMatched
self.expectedState = expectedState
}
}
func runScenario(
hotkey: HotKey,
useDoubleTapOnly: Bool = false,
doubleTapLockEnabled: Bool = true,
steps: [ScenarioStep]
) {
// Sort steps by time, just in case they're not in ascending order
let sortedSteps = steps.sorted { $0.time < $1.time }
// We'll keep track of the "current time" as we simulate
var currentTime: TimeInterval = 0
// Create the processor with an initial date
var processor = withDependencies {
$0.date.now = Date(timeIntervalSince1970: currentTime)
} operation: {
HotKeyProcessor(
hotkey: hotkey,
useDoubleTapOnly: useDoubleTapOnly,
doubleTapLockEnabled: doubleTapLockEnabled
)
}
// We'll step through each event
for step in sortedSteps {
// let delta = step.time - currentTime
currentTime = step.time
// Sleep or jump time
withDependencies {
$0.date.now = Date(timeIntervalSince1970: currentTime)
} operation: {
// Build a KeyEvent from step's chord
let keyEvent = KeyEvent(key: step.key, modifiers: step.modifiers)
// Process
let actualOutput = processor.process(keyEvent: keyEvent)
// If step.expectedOutput != nil, #expect that it matches actualOutput
if let expected = step.expectedOutput {
#expect(
actualOutput == expected,
"\(step.time)s: expected output \(expected), got \(String(describing: actualOutput))"
)
} else {
// We expect no output
#expect(
actualOutput == nil,
"\(step.time)s: expected no output, got \(String(describing: actualOutput))"
)
}
// If step.expectedIsMatched != nil, #expect that it matches processor.isMatched
if let expMatch = step.expectedIsMatched {
#expect(
processor.isMatched == expMatch,
"\(step.time)s: expected isMatched=\(expMatch), got \(processor.isMatched)"
)
}
// If we want to test the entire state:
if let expState = step.expectedState {
#expect(
processor.state == expState,
"\(step.time)s: expected state=\(expState), got \(processor.state)"
)
}
}
}
}
// MARK: - Recording Decision Tests
struct RecordingDecisionTests {
private func makeContext(
hotkey: HotKey,
minimumKeyTime: TimeInterval = 0.2,
duration: TimeInterval?
) -> RecordingDecisionEngine.Context {
let now = Date(timeIntervalSinceReferenceDate: 0)
let start = duration.map { now.addingTimeInterval(-$0) }
return RecordingDecisionEngine.Context(
hotkey: hotkey,
minimumKeyTime: minimumKeyTime,
recordingStartTime: start,
currentTime: now
)
}
@Test
func modifierOnlyShortPressIsDiscarded() {
let ctx = makeContext(hotkey: HotKey(key: nil, modifiers: [.command]), duration: 0.1)
#expect(RecordingDecisionEngine.decide(ctx) == .discardShortRecording)
}
@Test
func printableKeyShortPressStillProceeds() {
let ctx = makeContext(hotkey: HotKey(key: .quote, modifiers: [.command]), duration: 0.1)
#expect(RecordingDecisionEngine.decide(ctx) == .proceedToTranscription)
}
@Test
func longPressModifierOnlyProceeds() {
// Duration at modifierOnlyMinimumDuration threshold (0.3s)
let ctx = makeContext(hotkey: HotKey(key: nil, modifiers: [.option]), duration: 0.3)
#expect(RecordingDecisionEngine.decide(ctx) == .proceedToTranscription)
}
@Test
func missingStartTimeDefaultsToShort() {
let ctx = RecordingDecisionEngine.Context(
hotkey: HotKey(key: nil, modifiers: [.option]),
minimumKeyTime: 0.2,
recordingStartTime: nil,
currentTime: Date(timeIntervalSinceReferenceDate: 0)
)
#expect(RecordingDecisionEngine.decide(ctx) == .discardShortRecording)
}
// MARK: - Modifier-Only Minimum Duration Tests
@Test
func modifierOnly_enforcesMinimumDuration_0_3s() {
// User sets minimumKeyTime to 0.1s, but modifier-only enforces modifierOnlyMinimumDuration (0.3s)
let ctx = makeContext(hotkey: HotKey(key: nil, modifiers: [.option]), minimumKeyTime: 0.1, duration: 0.25)
#expect(RecordingDecisionEngine.decide(ctx) == .discardShortRecording)
}
@Test
func modifierOnly_proceedsWhenAboveMinimumDuration() {
// User sets minimumKeyTime to 0.1s, recording is 0.35s (above modifierOnlyMinimumDuration)
let ctx = makeContext(hotkey: HotKey(key: nil, modifiers: [.option]), minimumKeyTime: 0.1, duration: 0.35)
#expect(RecordingDecisionEngine.decide(ctx) == .proceedToTranscription)
}
@Test
func modifierOnly_respectsUserPreferenceWhenHigher() {
// User sets minimumKeyTime to 0.5s (higher than modifierOnlyMinimumDuration)
let ctx = makeContext(hotkey: HotKey(key: nil, modifiers: [.option]), minimumKeyTime: 0.5, duration: 0.4)
#expect(RecordingDecisionEngine.decide(ctx) == .discardShortRecording)
}
@Test
func printableKey_doesNotEnforceModifierOnlyMinimum() {
// Printable key hotkeys use user's minimumKeyTime, not modifierOnlyMinimumDuration
let ctx = makeContext(hotkey: HotKey(key: .a, modifiers: [.command]), minimumKeyTime: 0.1, duration: 0.15)
#expect(RecordingDecisionEngine.decide(ctx) == .proceedToTranscription)
}
}
// MARK: - Mouse Click Tests
struct MouseClickTests {
@Test
func mouseClick_discardsQuickModifierOnlyRecording() throws {
var processor = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
HotKeyProcessor(hotkey: HotKey(key: nil, modifiers: [.option]), minimumKeyTime: 0.15)
}
// Start recording with modifier-only hotkey
let startOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
processor.process(keyEvent: KeyEvent(key: nil, modifiers: [.option]))
}
#expect(startOutput == .startRecording)
// Mouse click 0.25s later (< 0.3s threshold for modifier-only) should discard silently
let clickOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.25)
} operation: {
processor.processMouseClick()
}
#expect(clickOutput == .discard)
}
@Test
func mouseClick_ignoredAfterThreshold() throws {
var processor = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
HotKeyProcessor(hotkey: HotKey(key: nil, modifiers: [.option]), minimumKeyTime: 0.15)
}
// Start recording with modifier-only hotkey
let startOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
processor.process(keyEvent: KeyEvent(key: nil, modifiers: [.option]))
}
#expect(startOutput == .startRecording)
// Mouse click 0.35s later (> 0.3s threshold) should be ignored - only ESC cancels
let clickOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.35)
} operation: {
processor.processMouseClick()
}
#expect(clickOutput == nil)
}
@Test
func mouseClick_ignoredInDoubleTapLock() throws {
var processor = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
HotKeyProcessor(hotkey: HotKey(key: nil, modifiers: [.option]), minimumKeyTime: 0.15)
}
// First tap
_ = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
processor.process(keyEvent: KeyEvent(key: nil, modifiers: [.option]))
}
_ = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.2)
} operation: {
processor.process(keyEvent: KeyEvent(key: nil, modifiers: []))
}
// Second tap within threshold - should lock
_ = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.4)
} operation: {
processor.process(keyEvent: KeyEvent(key: nil, modifiers: [.option]))
}
_ = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.5)
} operation: {
processor.process(keyEvent: KeyEvent(key: nil, modifiers: []))
}
// Should be in double-tap lock now
#expect(processor.state == .doubleTapLock)
// Mouse click should be ignored - only ESC cancels locked recordings
let clickOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.6)
} operation: {
processor.processMouseClick()
}
#expect(clickOutput == nil)
}
@Test
func mouseClick_ignoresKeyPlusModifierHotkey() throws {
var processor = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
HotKeyProcessor(hotkey: HotKey(key: .a, modifiers: [.command]), minimumKeyTime: 0.15)
}
// Start recording with key+modifier hotkey
let startOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
processor.process(keyEvent: KeyEvent(key: .a, modifiers: [.command]))
}
#expect(startOutput == .startRecording)
// Mouse click should be ignored for key+modifier hotkeys
let clickOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.1)
} operation: {
processor.processMouseClick()
}
#expect(clickOutput == nil)
}
@Test
func mouseClick_respectsHigherUserPreference() throws {
var processor = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
HotKeyProcessor(hotkey: HotKey(key: nil, modifiers: [.option]), minimumKeyTime: 0.5)
}
// Start recording with modifier-only hotkey
let startOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0)
} operation: {
processor.process(keyEvent: KeyEvent(key: nil, modifiers: [.option]))
}
#expect(startOutput == .startRecording)
// Mouse click 0.4s later (> 0.3s but < 0.5s user preference) should still discard
let clickOutput = withDependencies {
$0.date.now = Date(timeIntervalSince1970: 0.4)
} operation: {
processor.processMouseClick()
}
#expect(clickOutput == .discard)
}
}
@@ -0,0 +1,49 @@
import Testing
@testable import HexCore
struct TranscriptFormattingTests {
@Test
func lowercasesTranscript() {
let result = TranscriptFormattingApplier.apply(
"Hello WORLD!",
lowercase: true,
removePunctuation: false
)
#expect(result == "hello world!")
}
@Test
func removesUnicodePunctuation() {
let result = TranscriptFormattingApplier.apply(
"Hello, world! It’s well-known — really.",
lowercase: false,
removePunctuation: true
)
#expect(result == "Hello world Its wellknown really")
}
@Test
func appliesBothOptionsIndependently() {
let result = TranscriptFormattingApplier.apply(
"Hello, WORLD!",
lowercase: true,
removePunctuation: true
)
#expect(result == "hello world")
}
@Test
func leavesTranscriptUnchangedWhenDisabled() {
let input = "Hello, WORLD!"
let result = TranscriptFormattingApplier.apply(
input,
lowercase: false,
removePunctuation: false
)
#expect(result == input)
}
}
@@ -0,0 +1,105 @@
import Testing
@testable import HexCore
struct WordRemappingTests {
@Test
func basicRemapping() {
let remappings = [
WordRemapping(match: "comma", replacement: ",")
]
let result = WordRemappingApplier.apply("Hello comma world", remappings: remappings)
#expect(result == "Hello , world")
}
@Test
func newlineEscapeSequence() {
let remappings = [
WordRemapping(match: "new line", replacement: "\\n")
]
let result = WordRemappingApplier.apply("Hello new line world", remappings: remappings)
#expect(result == "Hello \n world")
}
@Test
func newParagraphEscapeSequence() {
let remappings = [
WordRemapping(match: "new paragraph", replacement: "\\n\\n")
]
let result = WordRemappingApplier.apply("Hello new paragraph world", remappings: remappings)
#expect(result == "Hello \n\n world")
}
@Test
func tabEscapeSequence() {
let remappings = [
WordRemapping(match: "tab", replacement: "\\t")
]
let result = WordRemappingApplier.apply("Hello tab world", remappings: remappings)
#expect(result == "Hello \t world")
}
@Test
func escapedBackslash() {
let remappings = [
WordRemapping(match: "backslash", replacement: "\\\\")
]
let result = WordRemappingApplier.apply("Hello backslash world", remappings: remappings)
#expect(result == "Hello \\ world")
}
@Test
func literalBackslashN() {
let remappings = [
WordRemapping(match: "code", replacement: "\\\\n")
]
let result = WordRemappingApplier.apply("Hello code world", remappings: remappings)
#expect(result == "Hello \\n world")
}
@Test
func literalDollarSign() {
let remappings = [
WordRemapping(match: "price", replacement: "$1")
]
let result = WordRemappingApplier.apply("It costs price today", remappings: remappings)
#expect(result == "It costs $1 today")
}
@Test
func caseInsensitive() {
let remappings = [
WordRemapping(match: "COMMA", replacement: ",")
]
let result = WordRemappingApplier.apply("Hello comma world", remappings: remappings)
#expect(result == "Hello , world")
}
@Test
func doesNotRemapInsideWords() {
let remappings = [
WordRemapping(match: "new", replacement: "\\n")
]
let result = WordRemappingApplier.apply("renewable energy", remappings: remappings)
#expect(result == "renewable energy")
}
@Test
func disabledRemappingIgnored() {
let remappings = [
WordRemapping(isEnabled: false, match: "comma", replacement: ",")
]
let result = WordRemappingApplier.apply("Hello comma world", remappings: remappings)
#expect(result == "Hello comma world")
}
@Test
func multipleRemappings() {
let remappings = [
WordRemapping(match: "comma", replacement: ","),
WordRemapping(match: "period", replacement: "."),
WordRemapping(match: "new line", replacement: "\\n")
]
let result = WordRemappingApplier.apply("Hello comma new line world period", remappings: remappings)
#expect(result == "Hello , \n world .")
}
}
@@ -0,0 +1,38 @@
import Testing
@testable import HexCore
struct WordRemovalTests {
@Test
func removesFillerWordsAndRepeats() {
let removals = [
WordRemoval(pattern: "uh+"),
WordRemoval(pattern: "um+"),
WordRemoval(pattern: "er+"),
WordRemoval(pattern: "hm+")
]
let result = WordRemovalApplier.apply("Umm uhhh er hmm", removals: removals)
#expect(result.isEmpty)
}
@Test
func cleansSpacesAndPunctuation() {
let removals = [
WordRemoval(pattern: "uh+"),
WordRemoval(pattern: "um+")
]
let result = WordRemovalApplier.apply("Well, um, that's uh fine", removals: removals)
#expect(result == "Well, that's fine")
}
@Test
func doesNotRemoveInsideWords() {
let result = WordRemovalApplier.apply("thumb", removals: [WordRemoval(pattern: "um+")])
#expect(result == "thumb")
}
@Test
func removesLeadingPunctuation() {
let result = WordRemovalApplier.apply("um, hello", removals: [WordRemoval(pattern: "um+")])
#expect(result == "hello")
}
}