Handle shutdown signals

This commit is contained in:
Thales Maciel 2026-02-06 15:31:00 -03:00
parent 9ee301fbeb
commit 123dc0160b
2 changed files with 122 additions and 13 deletions

View file

@ -2,15 +2,19 @@ package main
import ( import (
"bufio" "bufio"
"context"
"flag" "flag"
"fmt" "fmt"
"log" "log"
"net" "net"
"os" "os"
"os/signal"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall" "syscall"
"time"
"lel/internal/aiprocess"
"lel/internal/clip" "lel/internal/clip"
"lel/internal/config" "lel/internal/config"
"lel/internal/daemon" "lel/internal/daemon"
@ -26,7 +30,7 @@ func main() {
var configPath string var configPath string
var dryRun bool var dryRun bool
var noTray bool var noTray bool
flag.StringVar(&configPath, "config", "", "path to config.toml") flag.StringVar(&configPath, "config", "", "path to config.json")
flag.BoolVar(&dryRun, "dry-run", false, "register hotkey and log events without recording") flag.BoolVar(&dryRun, "dry-run", false, "register hotkey and log events without recording")
flag.BoolVar(&noTray, "no-tray", false, "disable system tray icon") flag.BoolVar(&noTray, "no-tray", false, "disable system tray icon")
flag.Parse() flag.Parse()
@ -74,7 +78,21 @@ func main() {
logger.Fatalf("backend error: %v", err) logger.Fatalf("backend error: %v", err)
} }
d := daemon.New(cfg, x, logger, backend) processor, err := aiprocess.New(aiprocess.Config{
Enabled: cfg.AIEnabled,
Provider: cfg.AIProvider,
Model: cfg.AIModel,
Temperature: cfg.AITemperature,
SystemPromptFile: cfg.AISystemPromptFile,
BaseURL: cfg.AIBaseURL,
APIKey: cfg.AIAPIKey,
TimeoutSec: cfg.AITimeoutSec,
})
if err != nil {
logger.Fatalf("ai processor error: %v", err)
}
d := daemon.New(cfg, x, logger, backend, processor)
sockPath := filepath.Join(runtimeDir, "ctl.sock") sockPath := filepath.Join(runtimeDir, "ctl.sock")
if err := os.RemoveAll(sockPath); err != nil { if err := os.RemoveAll(sockPath); err != nil {
@ -95,6 +113,7 @@ func main() {
logger.Printf("ready (hotkey: %s)", cfg.Hotkey) logger.Printf("ready (hotkey: %s)", cfg.Hotkey)
if noTray { if noTray {
go handleSignals(logger, d)
runX11Loop(logger, x, d, mods, keycode, dryRun) runX11Loop(logger, x, d, mods, keycode, dryRun)
return return
} }
@ -119,6 +138,10 @@ func main() {
systray.SetIcon(ui.IconTranscribing()) systray.SetIcon(ui.IconTranscribing())
systray.SetTooltip("lel: transcribing") systray.SetTooltip("lel: transcribing")
status.SetTitle("Transcribing") status.SetTitle("Transcribing")
case daemon.StateProcessing:
systray.SetIcon(ui.IconProcessing())
systray.SetTooltip("lel: ai processing")
status.SetTitle("AI Processing")
default: default:
systray.SetIcon(ui.IconIdle()) systray.SetIcon(ui.IconIdle())
systray.SetTooltip("lel: idle") systray.SetTooltip("lel: idle")
@ -133,6 +156,7 @@ func main() {
} }
}() }()
go handleSignals(logger, d)
go runX11Loop(logger, x, d, mods, keycode, dryRun) go runX11Loop(logger, x, d, mods, keycode, dryRun)
} }
@ -164,6 +188,20 @@ func runX11Loop(logger *log.Logger, x *x11.Conn, d *daemon.Daemon, mods uint16,
} }
} }
func handleSignals(logger *log.Logger, d *daemon.Daemon) {
sigCh := make(chan os.Signal, 2)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
sig := <-sigCh
logger.Printf("signal received: %v, shutting down", sig)
d.StopRecording("signal")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if !d.WaitForIdle(ctx) {
logger.Printf("shutdown timeout, exiting")
}
os.Exit(0)
}
func ensureRuntimeDir(logger *log.Logger) string { func ensureRuntimeDir(logger *log.Logger) string {
dir := os.Getenv("XDG_RUNTIME_DIR") dir := os.Getenv("XDG_RUNTIME_DIR")
if dir == "" { if dir == "" {
@ -242,11 +280,27 @@ func handleConn(logger *log.Logger, conn net.Conn, d *daemon.Daemon, cfg *config
return return
} }
processor, err := aiprocess.New(aiprocess.Config{
Enabled: newCfg.AIEnabled,
Provider: newCfg.AIProvider,
Model: newCfg.AIModel,
Temperature: newCfg.AITemperature,
SystemPromptFile: newCfg.AISystemPromptFile,
BaseURL: newCfg.AIBaseURL,
APIKey: newCfg.AIAPIKey,
TimeoutSec: newCfg.AITimeoutSec,
})
if err != nil {
_, _ = fmt.Fprintf(conn, "reload error: %v\n", err)
return
}
*mods = newMods *mods = newMods
*keycode = newKeycode *keycode = newKeycode
*cfg = newCfg *cfg = newCfg
d.UpdateConfig(newCfg) d.UpdateConfig(newCfg)
d.UpdateBackend(backend) d.UpdateBackend(backend)
d.UpdateAI(processor)
_, _ = fmt.Fprintf(conn, "reloaded\n") _, _ = fmt.Fprintf(conn, "reloaded\n")
default: default:

View file

@ -10,6 +10,7 @@ import (
"sync" "sync"
"time" "time"
"lel/internal/aiprocess"
"lel/internal/audio" "lel/internal/audio"
"lel/internal/clip" "lel/internal/clip"
"lel/internal/config" "lel/internal/config"
@ -24,6 +25,7 @@ const (
StateIdle State = "idle" StateIdle State = "idle"
StateRecording State = "recording" StateRecording State = "recording"
StateTranscribing State = "transcribing" StateTranscribing State = "transcribing"
StateProcessing State = "processing"
) )
type Daemon struct { type Daemon struct {
@ -31,6 +33,7 @@ type Daemon struct {
x11 *x11.Conn x11 *x11.Conn
log *log.Logger log *log.Logger
inj inject.Backend inj inject.Backend
ai aiprocess.Processor
mu sync.Mutex mu sync.Mutex
state State state State
@ -41,9 +44,9 @@ type Daemon struct {
stateCh chan State stateCh chan State
} }
func New(cfg config.Config, x *x11.Conn, logger *log.Logger, inj inject.Backend) *Daemon { func New(cfg config.Config, x *x11.Conn, logger *log.Logger, inj inject.Backend, ai aiprocess.Processor) *Daemon {
r := &audio.Recorder{Input: cfg.FfmpegInput} r := &audio.Recorder{Input: cfg.FfmpegInput}
return &Daemon{cfg: cfg, x11: x, log: logger, inj: inj, state: StateIdle, ffmpeg: r, stateCh: make(chan State, 4)} return &Daemon{cfg: cfg, x11: x, log: logger, inj: inj, ai: ai, state: StateIdle, ffmpeg: r, stateCh: make(chan State, 4)}
} }
func (d *Daemon) UpdateConfig(cfg config.Config) { func (d *Daemon) UpdateConfig(cfg config.Config) {
@ -61,6 +64,24 @@ func (d *Daemon) UpdateBackend(inj inject.Backend) {
d.mu.Unlock() d.mu.Unlock()
} }
func (d *Daemon) UpdateAI(proc aiprocess.Processor) {
d.mu.Lock()
d.ai = proc
d.mu.Unlock()
}
func (d *Daemon) setState(state State) {
d.mu.Lock()
d.state = state
d.notify(state)
d.mu.Unlock()
}
func (d *Daemon) setStateLocked(state State) {
d.state = state
d.notify(state)
}
func (d *Daemon) State() State { func (d *Daemon) State() State {
d.mu.Lock() d.mu.Lock()
defer d.mu.Unlock() defer d.mu.Unlock()
@ -79,8 +100,7 @@ func (d *Daemon) Toggle() {
d.log.Printf("record start failed: %v", err) d.log.Printf("record start failed: %v", err)
} }
case StateRecording: case StateRecording:
d.state = StateTranscribing d.setStateLocked(StateTranscribing)
d.notify(StateTranscribing)
d.mu.Unlock() d.mu.Unlock()
go d.stopAndProcess("user") go d.stopAndProcess("user")
return return
@ -90,6 +110,32 @@ func (d *Daemon) Toggle() {
d.mu.Unlock() d.mu.Unlock()
} }
func (d *Daemon) StopRecording(reason string) {
d.mu.Lock()
if d.state != StateRecording {
d.mu.Unlock()
return
}
d.setStateLocked(StateTranscribing)
d.mu.Unlock()
go d.stopAndProcess(reason)
}
func (d *Daemon) WaitForIdle(ctx context.Context) bool {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
if d.State() == StateIdle {
return true
}
select {
case <-ctx.Done():
return false
case <-ticker.C:
}
}
}
func (d *Daemon) startRecordingLocked() error { func (d *Daemon) startRecordingLocked() error {
if d.state != StateIdle { if d.state != StateIdle {
return errors.New("not idle") return errors.New("not idle")
@ -114,8 +160,7 @@ func (d *Daemon) startRecordingLocked() error {
d.mu.Unlock() d.mu.Unlock()
return return
} }
d.state = StateTranscribing d.setStateLocked(StateTranscribing)
d.notify(StateTranscribing)
d.mu.Unlock() d.mu.Unlock()
go d.stopAndProcess("timeout") go d.stopAndProcess("timeout")
}) })
@ -171,8 +216,21 @@ func (d *Daemon) stopAndProcess(reason string) {
status = "whisper failed: " + err.Error() status = "whisper failed: " + err.Error()
return return
} }
d.log.Printf("transcript: %s", text) d.log.Printf("transcript: %s", text)
if d.ai != nil && d.cfg.AIEnabled {
d.setState(StateProcessing)
aiCtx, cancel := context.WithTimeout(context.Background(), time.Duration(d.cfg.AITimeoutSec)*time.Second)
cleaned, err := d.ai.Process(aiCtx, text)
cancel()
if err != nil {
d.log.Printf("ai process failed: %v", err)
} else if cleaned != "" {
text = cleaned
}
}
d.log.Printf("output: %s", text)
clipCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) clipCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
if err := clip.WriteClipboard(clipCtx, text); err != nil { if err := clip.WriteClipboard(clipCtx, text); err != nil {
@ -190,10 +248,7 @@ func (d *Daemon) stopAndProcess(reason string) {
} }
func (d *Daemon) setIdle(msg string) { func (d *Daemon) setIdle(msg string) {
d.mu.Lock() d.setState(StateIdle)
d.state = StateIdle
d.notify(StateIdle)
d.mu.Unlock()
d.log.Printf("idle (%s)", msg) d.log.Printf("idle (%s)", msg)
} }