Skip to content

Commit ea386a8

Browse files
authored
fix: sync onboarding hotkey mode immediately (#133)
* fix: sync onboarding hotkey mode immediately The setup wizard saved activation mode changes through @AppStorage, but the global hotkey listener could already be running with the default push-to-talk configuration. That left first-time users in a confusing state where double-tap was saved in preferences while the live event tap still behaved as press-to-talk until the app restarted. Add AppState.syncHotKeyConfiguration() as the shared path for applying persisted hotkey settings to the active HotKeyManager. Onboarding now refreshes the live listener when activation mode, hotkey, or double-tap threshold changes, and completeOnboarding() applies the current configuration again before marking setup complete. Settings now uses the same sync path, including max recording duration so the safety timeout remains aligned. Tests extend the hotkey mock to record configuration updates and cover both direct sync and onboarding completion sync. Local verification: swift test was attempted; the sandboxed run could not write SwiftPM caches, the escalated debug run was blocked by the existing SwiftUI #Preview macro/PreviewsMacros command-line toolchain issue, and the release test run was blocked by local XCTest availability. Manual app testing confirmed the setup wizard hotkey mode issue is fixed. * fix: address hotkey sync review feedback The PR review called out a few places where the hotkey listener sync path could drift or create avoidable noise. This follow-up keeps the live listener updates focused while preserving the immediate onboarding behavior the original fix introduced. Centralize the hotkey safety timeout calculation so startup, permission-grant startup, and explicit syncs all use the same max-recording-duration slack. Demote the sync log to debug, sync double-tap threshold sliders only when editing commits, and document that onboarding's live onChange handlers cover only the fields shown in that step. Also reset the hotkey key-state when onboarding completes, and extend AppState tests to cover the default sync tuple plus the completion reset. Caveat: the first sandboxed swift test run could not write SwiftPM/Clang caches; the escalated run completed successfully. Verification: swift test (171 tests, 0 failures). * fix: guard onboarding hotkey reset while recording Completing onboarding syncs the latest hotkey configuration and then clears stale key state, but resetting key state while a recording is active can cancel the push-to-talk safety timer. That creates a narrow path where a held-key release could be ignored and the safety backstop would be unavailable until the audio engine max-duration callback fires. Keep the configuration sync on completion, but only reset hotkey key state when AppState is not recording. Add onboarding coverage that locks in the active-recording behavior so the hotkey reset path cannot regress silently. Verification: swift test --filter AppStateOnboardingTests; swift test. --------- Co-authored-by: d1scolor <d1scolor@users.noreply.github.com>
1 parent c805fe1 commit ea386a8

5 files changed

Lines changed: 142 additions & 12 deletions

File tree

Sources/VocaMac/Models/AppState.swift

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ final class AppState: ObservableObject {
108108
@AppStorage("vocamac.translationEnabled") var translationEnabled: Bool = false
109109
@AppStorage("vocamac.logLevel") var logLevel: String = "info"
110110

111+
private var hotKeySafetyTimeout: Double {
112+
Double(maxRecordingDuration) + 5.0
113+
}
114+
111115
// MARK: - Services
112116

113117
let audioEngine: AudioRecording
@@ -374,7 +378,7 @@ final class AppState: ObservableObject {
374378
keyCode: self.hotKeyCode,
375379
mode: self.activationMode,
376380
doubleTapThreshold: self.doubleTapThreshold,
377-
safetyTimeout: Double(self.maxRecordingDuration) + 5.0
381+
safetyTimeout: self.hotKeySafetyTimeout
378382
)
379383
VocaLogger.info(.appState, "Hotkey listener started after permission grant")
380384
}
@@ -399,6 +403,21 @@ final class AppState: ObservableObject {
399403
func requestAccessibilityPermission() { permissionManager.requestAccessibilityPermission() }
400404
func requestInputMonitoringPermission() { permissionManager.requestInputMonitoringPermission() }
401405

406+
// MARK: - Hotkey Configuration
407+
408+
/// Apply persisted hotkey settings to the active listener.
409+
/// `@AppStorage` updates save preferences immediately, but an already-running
410+
/// event tap also needs its in-memory configuration refreshed.
411+
func syncHotKeyConfiguration() {
412+
hotKeyManager.updateConfiguration(
413+
keyCode: hotKeyCode,
414+
mode: activationMode,
415+
doubleTapThreshold: doubleTapThreshold,
416+
safetyTimeout: hotKeySafetyTimeout
417+
)
418+
VocaLogger.debug(.appState, "Hotkey configuration synced (keyCode=\(hotKeyCode), mode=\(activationMode.rawValue))")
419+
}
420+
402421
// MARK: - Force Recovery
403422

404423
/// Forcibly reset the entire recording pipeline to idle state.
@@ -757,7 +776,7 @@ final class AppState: ObservableObject {
757776
keyCode: hotKeyCode,
758777
mode: activationMode,
759778
doubleTapThreshold: doubleTapThreshold,
760-
safetyTimeout: Double(maxRecordingDuration) + 5.0
779+
safetyTimeout: hotKeySafetyTimeout
761780
)
762781
if hotKeyManager.isListening {
763782
VocaLogger.info(.appState, "Hotkey listener active (keyCode=\(hotKeyCode), mode=\(activationMode.rawValue))")
@@ -770,6 +789,10 @@ final class AppState: ObservableObject {
770789
VocaLogger.info(.appState, "Startup complete!")
771790
}
772791
func completeOnboarding() {
792+
syncHotKeyConfiguration()
793+
if !isRecording {
794+
hotKeyManager.resetKeyState()
795+
}
773796
hasCompletedOnboarding = true
774797
VocaLogger.info(.appState, "Onboarding completed")
775798
}

Sources/VocaMac/Views/OnboardingView.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -665,7 +665,12 @@ struct HotkeyConfigStep: View {
665665
Slider(
666666
value: $appState.doubleTapThreshold,
667667
in: 0.2...0.8,
668-
step: 0.05
668+
step: 0.05,
669+
onEditingChanged: { isEditing in
670+
if !isEditing {
671+
appState.syncHotKeyConfiguration()
672+
}
673+
}
669674
)
670675
Text("\(String(format: "%.2f", appState.doubleTapThreshold))s")
671676
.monospacedDigit()
@@ -685,6 +690,14 @@ struct HotkeyConfigStep: View {
685690
Spacer()
686691
}
687692
.padding()
693+
// Keep the live listener aligned with wizard fields.
694+
// Completion syncs the full persisted config.
695+
.onChange(of: appState.activationMode) { _ in
696+
appState.syncHotKeyConfiguration()
697+
}
698+
.onChange(of: appState.hotKeyCode) { _ in
699+
appState.syncHotKeyConfiguration()
700+
}
688701
}
689702
}
690703

Sources/VocaMac/Views/SettingsView.swift

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ struct GeneralSettingsTab: View {
6060
}
6161
}
6262
.pickerStyle(.radioGroup)
63-
.onChange(of: appState.activationMode) { newMode in
64-
appState.hotKeyManager.updateConfiguration(mode: newMode)
63+
.onChange(of: appState.activationMode) { _ in
64+
appState.syncHotKeyConfiguration()
6565
}
6666

6767
Text(appState.activationMode.description)
@@ -76,8 +76,8 @@ struct GeneralSettingsTab: View {
7676
Text(hotKey.name).tag(hotKey.keyCode)
7777
}
7878
}
79-
.onChange(of: appState.hotKeyCode) { newCode in
80-
appState.hotKeyManager.updateConfiguration(keyCode: newCode)
79+
.onChange(of: appState.hotKeyCode) { _ in
80+
appState.syncHotKeyConfiguration()
8181
}
8282

8383
if appState.activationMode == .doubleTapToggle {
@@ -86,15 +86,17 @@ struct GeneralSettingsTab: View {
8686
Slider(
8787
value: $appState.doubleTapThreshold,
8888
in: 0.2...0.8,
89-
step: 0.05
89+
step: 0.05,
90+
onEditingChanged: { isEditing in
91+
if !isEditing {
92+
appState.syncHotKeyConfiguration()
93+
}
94+
}
9095
)
9196
Text("\(String(format: "%.2f", appState.doubleTapThreshold))s")
9297
.monospacedDigit()
9398
.frame(width: 40)
9499
}
95-
.onChange(of: appState.doubleTapThreshold) { newVal in
96-
appState.hotKeyManager.updateConfiguration(doubleTapThreshold: newVal)
97-
}
98100

99101
Text("Shorter = faster double-tap required. Longer = more forgiving.")
100102
.font(.caption)
@@ -548,6 +550,9 @@ struct AudioSettingsTab: View {
548550
Text("120 seconds").tag(120)
549551
Text("300 seconds (5 min)").tag(300)
550552
}
553+
.onChange(of: appState.maxRecordingDuration) { _ in
554+
appState.syncHotKeyConfiguration()
555+
}
551556

552557
Text("Recording will automatically stop after this duration.")
553558
.font(.caption)

Tests/VocaMacTests/AppStateTests.swift

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,22 @@ final class AppStateOnboardingTests: XCTestCase {
139139

140140
override func setUp() {
141141
super.setUp()
142-
UserDefaults.standard.removeObject(forKey: "vocamac.hasCompletedOnboarding")
142+
clearPersistedSettings()
143+
}
144+
145+
override func tearDown() {
146+
clearPersistedSettings()
147+
super.tearDown()
148+
}
149+
150+
private func clearPersistedSettings() {
151+
[
152+
"vocamac.hasCompletedOnboarding",
153+
"vocamac.activationMode",
154+
"vocamac.hotKeyCode",
155+
"vocamac.doubleTapThreshold",
156+
"vocamac.maxRecordingDuration",
157+
].forEach { UserDefaults.standard.removeObject(forKey: $0) }
143158
}
144159

145160
@MainActor
@@ -158,6 +173,66 @@ final class AppStateOnboardingTests: XCTestCase {
158173
XCTAssertTrue(appState.hasCompletedOnboarding)
159174
}
160175

176+
@MainActor
177+
func testCompleteOnboardingSyncsHotKeyConfiguration() {
178+
let (appState, mocks) = AppState.makeTestState()
179+
appState.activationMode = .doubleTapToggle
180+
appState.hotKeyCode = 58
181+
appState.doubleTapThreshold = 0.55
182+
appState.maxRecordingDuration = 120
183+
184+
appState.completeOnboarding()
185+
186+
XCTAssertEqual(mocks.hotKeyManager.updateConfigurationCallCount, 1)
187+
XCTAssertEqual(mocks.hotKeyManager.lastMode, .doubleTapToggle)
188+
XCTAssertEqual(mocks.hotKeyManager.lastKeyCode, 58)
189+
XCTAssertEqual(mocks.hotKeyManager.lastDoubleTapThreshold, 0.55)
190+
XCTAssertEqual(mocks.hotKeyManager.lastSafetyTimeout, 125.0)
191+
XCTAssertEqual(mocks.hotKeyManager.resetKeyStateCallCount, 1)
192+
}
193+
194+
@MainActor
195+
func testCompleteOnboardingDoesNotResetHotKeyStateWhileRecording() {
196+
let (appState, mocks) = AppState.makeTestState()
197+
appState.isRecording = true
198+
199+
appState.completeOnboarding()
200+
201+
XCTAssertEqual(mocks.hotKeyManager.updateConfigurationCallCount, 1)
202+
XCTAssertEqual(mocks.hotKeyManager.resetKeyStateCallCount, 0)
203+
XCTAssertTrue(appState.hasCompletedOnboarding)
204+
}
205+
206+
@MainActor
207+
func testSyncHotKeyConfigurationAppliesCurrentSettings() {
208+
let (appState, mocks) = AppState.makeTestState()
209+
appState.activationMode = .doubleTapToggle
210+
appState.hotKeyCode = 54
211+
appState.doubleTapThreshold = 0.3
212+
appState.maxRecordingDuration = 30
213+
214+
appState.syncHotKeyConfiguration()
215+
216+
XCTAssertEqual(mocks.hotKeyManager.updateConfigurationCallCount, 1)
217+
XCTAssertEqual(mocks.hotKeyManager.lastMode, .doubleTapToggle)
218+
XCTAssertEqual(mocks.hotKeyManager.lastKeyCode, 54)
219+
XCTAssertEqual(mocks.hotKeyManager.lastDoubleTapThreshold, 0.3)
220+
XCTAssertEqual(mocks.hotKeyManager.lastSafetyTimeout, 35.0)
221+
}
222+
223+
@MainActor
224+
func testSyncHotKeyConfigurationAppliesDefaultSettings() {
225+
let (appState, mocks) = AppState.makeTestState()
226+
227+
appState.syncHotKeyConfiguration()
228+
229+
XCTAssertEqual(mocks.hotKeyManager.updateConfigurationCallCount, 1)
230+
XCTAssertEqual(mocks.hotKeyManager.lastMode, .pushToTalk)
231+
XCTAssertEqual(mocks.hotKeyManager.lastKeyCode, 61)
232+
XCTAssertEqual(mocks.hotKeyManager.lastDoubleTapThreshold, 0.4)
233+
XCTAssertEqual(mocks.hotKeyManager.lastSafetyTimeout, 65.0)
234+
}
235+
161236
@MainActor
162237
func testOnboardingFlagPersistence() {
163238
UserDefaults.standard.set(true, forKey: "vocamac.hasCompletedOnboarding")

Tests/VocaMacTests/Mocks/MockServices.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ final class MockHotKeyManager: HotKeyMonitoring {
9696
var lastDoubleTapThreshold: Double?
9797
var lastSafetyTimeout: Double?
9898
var resetKeyStateCallCount = 0
99+
var updateConfigurationCallCount = 0
99100

100101
private var accessibilityPermission = false
101102

@@ -125,6 +126,19 @@ final class MockHotKeyManager: HotKeyMonitoring {
125126
}
126127

127128
func _updateConfiguration(keyCode: Int?, mode: ActivationMode?, doubleTapThreshold: Double?, safetyTimeout: Double?) {
129+
updateConfigurationCallCount += 1
130+
if let keyCode = keyCode {
131+
lastKeyCode = keyCode
132+
}
133+
if let mode = mode {
134+
lastMode = mode
135+
}
136+
if let doubleTapThreshold = doubleTapThreshold {
137+
lastDoubleTapThreshold = doubleTapThreshold
138+
}
139+
if let safetyTimeout = safetyTimeout {
140+
lastSafetyTimeout = safetyTimeout
141+
}
128142
}
129143
}
130144

0 commit comments

Comments
 (0)