Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ APP_EXECUTABLE_TARGET := $(subst $(space),\ ,$(APP_EXECUTABLE))

SOURCES = $(shell find Sources -name '*.swift' -type f | LC_ALL=C sort)
TEST_RUNNER = $(BUILD_DIR)/FreeFlowTests
SHORTCUT_TEST_RUNNER = $(BUILD_DIR)/FreeFlowShortcutTests
RESOURCES = $(CONTENTS)/Resources
ARCH ?= $(shell uname -m)

Expand Down Expand Up @@ -69,8 +70,9 @@ endif
@codesign --force --options runtime --sign "$(CODESIGN_IDENTITY)" --entitlements FreeFlow.entitlements "$(APP_BUNDLE)"
@echo "Built $(APP_BUNDLE)"

test: $(TEST_RUNNER)
test: $(TEST_RUNNER) $(SHORTCUT_TEST_RUNNER)
@$(TEST_RUNNER)
@$(SHORTCUT_TEST_RUNNER)

$(TEST_RUNNER): Sources/AppContextService.swift Sources/LLMAPITransport.swift Sources/ModelConfiguration.swift Tests/AppContextServiceTests.swift
@mkdir -p "$(BUILD_DIR)"
Expand All @@ -81,6 +83,15 @@ $(TEST_RUNNER): Sources/AppContextService.swift Sources/LLMAPITransport.swift So
-target $(ARCH)-apple-macosx13.0 \
Sources/AppContextService.swift Sources/LLMAPITransport.swift Sources/ModelConfiguration.swift Tests/AppContextServiceTests.swift

$(SHORTCUT_TEST_RUNNER): Sources/ShortcutCore/DictationShortcutSessionController.swift Sources/ShortcutCore/ShortcutModels.swift Tests/DictationShortcutSessionControllerTests.swift
@mkdir -p "$(BUILD_DIR)"
swiftc \
-parse-as-library \
-o "$(SHORTCUT_TEST_RUNNER)" \
-sdk $(shell xcrun --show-sdk-path) \
-target $(ARCH)-apple-macosx13.0 \
Sources/ShortcutCore/DictationShortcutSessionController.swift Sources/ShortcutCore/ShortcutModels.swift Tests/DictationShortcutSessionControllerTests.swift

icon: $(ICON_ICNS)

$(ICON_ICNS): $(ICON_SOURCE)
Expand Down
62 changes: 61 additions & 1 deletion Sources/ShortcutCore/DictationShortcutSessionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,61 @@ enum DictationShortcutAction: Equatable {
}

final class DictationShortcutSessionController {
/// Tapping the hold shortcut twice in quick succession latches into toggle
/// mode, so a long dictation does not require holding the key down.
static let defaultDoubleTapWindow: TimeInterval = 0.4

var doubleTapLatchEnabled: Bool
var doubleTapWindow: TimeInterval

/// Injectable clock so the double-tap window is testable without sleeping.
private let now: () -> Date

private(set) var activeMode: RecordingTriggerMode?
private(set) var toggleStopArmed = false

/// When the hold shortcut was last released. Deliberately *not* cleared by
/// `reset()`: the gap that makes a double tap spans two sessions, and
/// `reset()` runs between them once the first tap's recording is committed.
private var lastHoldReleaseAt: Date?

/// True when toggle mode was entered by double-tapping the hold shortcut.
/// Such a session is also stopped by the hold shortcut, since that is the
/// only key the speaker touched.
private(set) var toggleEnteredFromHold = false

init(
doubleTapLatchEnabled: Bool = true,
doubleTapWindow: TimeInterval = DictationShortcutSessionController.defaultDoubleTapWindow,
now: @escaping () -> Date = Date.init
) {
self.doubleTapLatchEnabled = doubleTapLatchEnabled
self.doubleTapWindow = doubleTapWindow
self.now = now
}

func handle(event: ShortcutEvent, isTranscribing: Bool) -> DictationShortcutAction? {
// Paste Again is handled before this controller runs; if it ever
// reaches here, treat as a no-op so dictation state is unaffected.
if event == .copyAgainTriggered { return nil }

if activeMode == nil {
// A double tap is allowed to latch even while the first tap's
// (near-empty) clip is still transcribing. Blocking it there would
// swallow the second tap and leave the speaker with nothing.
if event == .holdActivated, isDoubleTap() {
activeMode = .toggle
toggleEnteredFromHold = true
toggleStopArmed = false
lastHoldReleaseAt = nil
return .start(.toggle)
}

guard !isTranscribing else { return nil }
switch event {
case .toggleActivated:
activeMode = .toggle
toggleEnteredFromHold = false
toggleStopArmed = false
return .start(.toggle)
case .holdActivated:
Expand All @@ -40,10 +82,12 @@ final class DictationShortcutSessionController {
switch event {
case .toggleActivated:
activeMode = .toggle
toggleEnteredFromHold = false
toggleStopArmed = false
return .switchedToToggle
case .holdDeactivated:
reset()
lastHoldReleaseAt = now()
return .stop
case .holdActivated, .toggleDeactivated:
return nil
Expand All @@ -60,8 +104,16 @@ final class DictationShortcutSessionController {
guard toggleStopArmed else { return nil }
reset()
return .stop
case .holdActivated, .holdDeactivated:
case .holdDeactivated:
// Releasing the second tap must not end the session; it arms
// the next press to end it.
guard toggleEnteredFromHold else { return nil }
toggleStopArmed = true
return nil
case .holdActivated:
guard toggleEnteredFromHold, toggleStopArmed else { return nil }
reset()
return .stop
case .copyAgainTriggered:
return nil
}
Expand All @@ -70,16 +122,24 @@ final class DictationShortcutSessionController {

func beginManual(mode: RecordingTriggerMode) {
activeMode = mode
toggleEnteredFromHold = false
toggleStopArmed = false
}

func forceToggleMode() {
activeMode = .toggle
toggleEnteredFromHold = false
toggleStopArmed = false
}

func reset() {
activeMode = nil
toggleEnteredFromHold = false
toggleStopArmed = false
}

private func isDoubleTap() -> Bool {
guard doubleTapLatchEnabled, let last = lastHoldReleaseAt else { return false }
return now().timeIntervalSince(last) <= doubleTapWindow
Comment on lines +141 to +143

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject negative double-tap intervals.

Date is a wall clock and can move backward. A negative interval currently satisfies <= doubleTapWindow, so a hold activation can incorrectly enter toggle mode after a clock correction. Require an elapsed interval in the range 0...doubleTapWindow.

Proposed fix
 private func isDoubleTap() -> Bool {
     guard doubleTapLatchEnabled, let last = lastHoldReleaseAt else { return false }
-    return now().timeIntervalSince(last) <= doubleTapWindow
+    let elapsed = now().timeIntervalSince(last)
+    return elapsed >= 0 && elapsed <= doubleTapWindow
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private func isDoubleTap() -> Bool {
guard doubleTapLatchEnabled, let last = lastHoldReleaseAt else { return false }
return now().timeIntervalSince(last) <= doubleTapWindow
private func isDoubleTap() -> Bool {
guard doubleTapLatchEnabled, let last = lastHoldReleaseAt else { return false }
let elapsed = now().timeIntervalSince(last)
return elapsed >= 0 && elapsed <= doubleTapWindow
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/ShortcutCore/DictationShortcutSessionController.swift` around lines
141 - 143, Update isDoubleTap() to require the interval from lastHoldReleaseAt
to now() to be nonnegative and no greater than doubleTapWindow, so backward
wall-clock adjustments cannot qualify as a double tap.

}
}
171 changes: 171 additions & 0 deletions Tests/DictationShortcutSessionControllerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import Foundation

@main
struct DictationShortcutSessionControllerTests {
static func main() {
testSingleHoldStillStartsAndStopsOnRelease()
testDoubleTapLatchesIntoToggle()
testDoubleTapLatchSurvivesTranscriptionOfTheFirstTap()
testLatchedSessionIsStoppedByTheNextTap()
testReleaseOfTheSecondTapDoesNotStop()
testSlowSecondTapIsAnOrdinaryHold()
testDoubleTapCanBeDisabled()
testToggleShortcutSessionIsUnaffectedByHoldEvents()
testHoldThenToggleStillSwitchesToToggle()
print("DictationShortcutSessionControllerTests passed")
}

// MARK: - Existing behaviour must not change

private static func testSingleHoldStillStartsAndStopsOnRelease() {
let clock = TestClock()
let controller = makeController(clock: clock)

expectEqual(controller.handle(event: .holdActivated, isTranscribing: false), .start(.hold))
clock.advance(1.0)
expectEqual(controller.handle(event: .holdDeactivated, isTranscribing: false), .stop)
expect(controller.activeMode == nil, "Session should be over after the key is released")
}

private static func testToggleShortcutSessionIsUnaffectedByHoldEvents() {
let clock = TestClock()
let controller = makeController(clock: clock)

expectEqual(controller.handle(event: .toggleActivated, isTranscribing: false), .start(.toggle))
expectEqual(controller.handle(event: .holdActivated, isTranscribing: false), nil)
expectEqual(controller.handle(event: .holdDeactivated, isTranscribing: false), nil)
expect(controller.activeMode == .toggle, "A toggle session must ignore hold events")

expectEqual(controller.handle(event: .toggleDeactivated, isTranscribing: false), nil)
expectEqual(controller.handle(event: .toggleActivated, isTranscribing: false), .stop)
}

private static func testHoldThenToggleStillSwitchesToToggle() {
let clock = TestClock()
let controller = makeController(clock: clock)

expectEqual(controller.handle(event: .holdActivated, isTranscribing: false), .start(.hold))
expectEqual(controller.handle(event: .toggleActivated, isTranscribing: false), .switchedToToggle)
// Entered from the toggle shortcut, so the hold key must not end it.
expectEqual(controller.handle(event: .holdDeactivated, isTranscribing: false), nil)
expect(controller.activeMode == .toggle, "Latched session should still be running")
}

// MARK: - Double tap

private static func testDoubleTapLatchesIntoToggle() {
let clock = TestClock()
let controller = makeController(clock: clock)

expectEqual(controller.handle(event: .holdActivated, isTranscribing: false), .start(.hold))
clock.advance(0.09)
expectEqual(controller.handle(event: .holdDeactivated, isTranscribing: false), .stop)
clock.advance(0.15)
expectEqual(controller.handle(event: .holdActivated, isTranscribing: false), .start(.toggle))
expect(controller.activeMode == .toggle, "The second tap should latch")
}

private static func testDoubleTapLatchSurvivesTranscriptionOfTheFirstTap() {
let clock = TestClock()
let controller = makeController(clock: clock)

_ = controller.handle(event: .holdActivated, isTranscribing: false)
clock.advance(0.08)
_ = controller.handle(event: .holdDeactivated, isTranscribing: false)
// The first tap's near-empty clip is still in flight. The second tap
// must not be swallowed, or the speaker gets nothing at all.
clock.advance(0.12)
expectEqual(controller.handle(event: .holdActivated, isTranscribing: true), .start(.toggle))
}

private static func testLatchedSessionIsStoppedByTheNextTap() {
let clock = TestClock()
let controller = makeController(clock: clock)

_ = controller.handle(event: .holdActivated, isTranscribing: false)
clock.advance(0.08)
_ = controller.handle(event: .holdDeactivated, isTranscribing: false)
clock.advance(0.12)
expectEqual(controller.handle(event: .holdActivated, isTranscribing: false), .start(.toggle))

clock.advance(0.05)
expectEqual(controller.handle(event: .holdDeactivated, isTranscribing: false), nil)
clock.advance(12.0)
expectEqual(controller.handle(event: .holdActivated, isTranscribing: false), .stop)
expect(controller.activeMode == nil, "The third tap should end the latched session")
}

private static func testReleaseOfTheSecondTapDoesNotStop() {
let clock = TestClock()
let controller = makeController(clock: clock)

_ = controller.handle(event: .holdActivated, isTranscribing: false)
clock.advance(0.08)
_ = controller.handle(event: .holdDeactivated, isTranscribing: false)
clock.advance(0.12)
_ = controller.handle(event: .holdActivated, isTranscribing: false)

clock.advance(0.06)
expectEqual(controller.handle(event: .holdDeactivated, isTranscribing: false), nil)
expect(controller.activeMode == .toggle, "Letting go of the second tap must keep recording")
}

private static func testSlowSecondTapIsAnOrdinaryHold() {
let clock = TestClock()
let controller = makeController(clock: clock)

_ = controller.handle(event: .holdActivated, isTranscribing: false)
clock.advance(0.5)
_ = controller.handle(event: .holdDeactivated, isTranscribing: false)
clock.advance(0.9)
expectEqual(controller.handle(event: .holdActivated, isTranscribing: false), .start(.hold))
expect(controller.activeMode == .hold, "A slow second press is just another hold")
}

private static func testDoubleTapCanBeDisabled() {
let clock = TestClock()
let controller = makeController(clock: clock, enabled: false)

_ = controller.handle(event: .holdActivated, isTranscribing: false)
clock.advance(0.08)
_ = controller.handle(event: .holdDeactivated, isTranscribing: false)
clock.advance(0.12)
expectEqual(controller.handle(event: .holdActivated, isTranscribing: false), .start(.hold))
}

// MARK: - Helpers

private final class TestClock {
private var current = Date(timeIntervalSince1970: 1_000_000)
func advance(_ seconds: TimeInterval) { current = current.addingTimeInterval(seconds) }
func now() -> Date { current }
}

private static func makeController(clock: TestClock, enabled: Bool = true) -> DictationShortcutSessionController {
DictationShortcutSessionController(
doubleTapLatchEnabled: enabled,
doubleTapWindow: 0.4,
now: { clock.now() }
)
}

private static func expectEqual(
_ actual: DictationShortcutAction?,
_ expected: DictationShortcutAction?,
file: StaticString = #file,
line: UInt = #line
) {
expect(
actual == expected,
"Expected \(String(describing: expected)), got \(String(describing: actual))",
file: file,
line: line
)
}

private static func expect(_ condition: Bool, _ message: String, file: StaticString = #file, line: UInt = #line) {
if !condition {
fatalError("\(file):\(line): \(message)")
}
}
}