Skip to content
Merged
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
89 changes: 40 additions & 49 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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" {
Expand Down Expand Up @@ -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 := ""
Expand Down Expand Up @@ -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)
Expand All @@ -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: } }
Expand All @@ -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
}
}
Expand Down
36 changes: 34 additions & 2 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,31 @@ 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):
t.Fatal("tray stop did not end session")
}

// 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

// 4. New tap should start a session that stays alive
hk.SimKeydown()
sess2 := <-sessions
isRecording.Store(true)
time.Sleep(10 * time.Millisecond)
hk.SimKeyup()

Expand All @@ -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):
}
}