diff --git a/main.go b/main.go index bf41211..34a807d 100644 --- a/main.go +++ b/main.go @@ -72,8 +72,34 @@ type recordingConfig struct { var configMu sync.Mutex var trayRecordChan = make(chan struct{}, 1) -var trayStopMu sync.Mutex -var trayStopChan chan struct{} +var isRecording atomic.Bool + +var ( + stopMu sync.Mutex + stopCh chan struct{} // closed to stop the active recording + stopOnce sync.Once +) + +// resetStop prepares a fresh stop channel for a new recording. +func resetStop() <-chan struct{} { + stopMu.Lock() + stopCh = make(chan struct{}) + stopOnce = sync.Once{} + ch := stopCh + stopMu.Unlock() + return ch +} + +// requestStop stops the active recording (safe to call from any goroutine, multiple times). +func requestStop() { + stopMu.Lock() + once := &stopOnce + ch := stopCh + stopMu.Unlock() + if ch != nil { + once.Do(func() { close(ch) }) + } +} var shutdownOnce sync.Once @@ -91,44 +117,6 @@ func gracefulShutdown() { }) } -func newTrayStop() <-chan struct{} { - trayStopMu.Lock() - trayStopChan = make(chan struct{}) - ch := trayStopChan - trayStopMu.Unlock() - return ch -} - -func fireTrayStop() { - trayStopMu.Lock() - if trayStopChan != nil { - select { - case trayStopChan <- struct{}{}: - default: - } - } - trayStopMu.Unlock() -} - -// mergeStop returns a channel that closes when any source fires. -func mergeStop(sources ...<-chan struct{}) chan struct{} { - out := make(chan struct{}) - var once sync.Once - for _, s := range sources { - if s == nil { - continue - } - go func(ch <-chan struct{}) { - select { - case <-ch: - once.Do(func() { close(out) }) - case <-out: - } - }(s) - } - return out -} - func run() { if len(os.Args) > 1 && os.Args[1] == "update" { if version == "dev" { @@ -356,7 +344,7 @@ func run() { tray.OnCopyLast(clip.CopyLast) tray.OnRecord( func() { select { case trayRecordChan <- struct{}{}: default: } }, - func() { fireTrayStop() }, + func() { requestStop() }, ) // preferredDevice remembers the user's choice so we can auto-reconnect preferredDevice := "" @@ -543,18 +531,19 @@ func run() { go func() { for range trayRecordChan { - stop := mergeStop(newTrayStop()) - sessions <- recSession{Stop: stop, SilenceClose: &atomic.Bool{}} + sessions <- recSession{Stop: resetStop(), SilenceClose: &atomic.Bool{}} } }() for sess := range sessions { log.Info("recording_start") logRecordDevice() + isRecording.Store(true) tray.SetRecording(true) go beep.PlayStart() _, err := handleRecording(captureDevice, sess) + isRecording.Store(false) tray.SetRecording(false) if err != nil { log.Errorf("recording error: %v", err) @@ -570,21 +559,23 @@ func listenHotkey(hk hotkey.Hotkey, longPress time.Duration, sessions chan<- rec toggleRecording ) - stopCh := make(chan struct{}, 1) st := idle for { switch st { case idle: <-hk.Keydown() - select { case <-stopCh: default: } // drain stale stop from tray-cancel + if isRecording.Load() { + <-hk.Keyup() + requestStop() + continue + } sc := &atomic.Bool{} - stop := mergeStop(stopCh, newTrayStop()) - sessions <- recSession{Stop: stop, SilenceClose: sc} + sessions <- recSession{Stop: resetStop(), SilenceClose: sc} timer := time.NewTimer(longPress) select { case <-timer.C: <-hk.Keyup() - select { case stopCh <- struct{}{}: default: } + requestStop() st = idle case <-hk.Keyup(): if !timer.Stop() { select { case <-timer.C: default: } } @@ -594,7 +585,7 @@ func listenHotkey(hk hotkey.Hotkey, longPress time.Duration, sessions chan<- rec case toggleRecording: <-hk.Keydown() <-hk.Keyup() - select { case stopCh <- struct{}{}: default: } + requestStop() st = idle } } diff --git a/main_test.go b/main_test.go index a799cdc..9016188 100644 --- a/main_test.go +++ b/main_test.go @@ -14,13 +14,16 @@ func TestListenHotkey_TrayStopNoStaleSignal(t *testing.T) { go listenHotkey(hk, longPress, sessions) // 1. Short tap → enters toggle mode + isRecording.Store(false) hk.SimKeydown() sess1 := <-sessions + isRecording.Store(true) time.Sleep(10 * time.Millisecond) hk.SimKeyup() // 2. Tray stop ends the recording externally - fireTrayStop() + requestStop() + isRecording.Store(false) select { case <-sess1.Stop: case <-time.After(time.Second): @@ -28,7 +31,6 @@ func TestListenHotkey_TrayStopNoStaleSignal(t *testing.T) { } // 3. Still in toggleRecording — this tap transitions back to idle - // and sends a (now stale) stop signal to stopCh hk.SimKeydown() hk.SimKeyup() time.Sleep(20 * time.Millisecond) // let state machine settle @@ -36,6 +38,7 @@ func TestListenHotkey_TrayStopNoStaleSignal(t *testing.T) { // 4. New tap should start a session that stays alive hk.SimKeydown() sess2 := <-sessions + isRecording.Store(true) time.Sleep(10 * time.Millisecond) hk.SimKeyup() @@ -46,3 +49,32 @@ func TestListenHotkey_TrayStopNoStaleSignal(t *testing.T) { // session stayed alive — fix works } } + +func TestListenHotkey_StopsTrayRecording(t *testing.T) { + hk := hotkey.NewFake() + sessions := make(chan recSession, 3) + longPress := 100 * time.Millisecond + + go listenHotkey(hk, longPress, sessions) + + // Simulate tray-initiated recording + stop := resetStop() + isRecording.Store(true) + + // Hotkey press should stop it, not start a new one + hk.SimKeydown() + hk.SimKeyup() + + select { + case <-stop: + case <-time.After(time.Second): + t.Fatal("hotkey did not stop tray-initiated recording") + } + + // Should not have queued a new session + select { + case <-sessions: + t.Fatal("hotkey started a new session while recording was active") + case <-time.After(100 * time.Millisecond): + } +}