chore: adiciona .gitignore e commit.command
This commit is contained in:
Executable
+78
@@ -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
|
||||
}
|
||||
Executable
+38
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
+191
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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 }
|
||||
}
|
||||
}
|
||||
+11
@@ -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()
|
||||
]
|
||||
}
|
||||
Executable
+50
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Executable
+56
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user