309 lines
7.7 KiB
Go
309 lines
7.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"lel/internal/aiprocess"
|
|
"lel/internal/clip"
|
|
"lel/internal/config"
|
|
"lel/internal/daemon"
|
|
"lel/internal/inject"
|
|
"lel/internal/ui"
|
|
"lel/internal/x11"
|
|
|
|
"github.com/BurntSushi/xgb/xproto"
|
|
"github.com/getlantern/systray"
|
|
)
|
|
|
|
func main() {
|
|
var configPath string
|
|
var dryRun bool
|
|
var noTray bool
|
|
flag.StringVar(&configPath, "config", "", "path to config.json")
|
|
flag.BoolVar(&dryRun, "dry-run", false, "register hotkey and log events without recording")
|
|
flag.BoolVar(&noTray, "no-tray", false, "disable system tray icon")
|
|
flag.Parse()
|
|
|
|
logger := log.New(os.Stderr, "leld: ", log.LstdFlags)
|
|
|
|
cfg, err := config.Load(configPath)
|
|
if err != nil {
|
|
logger.Fatalf("config error: %v", err)
|
|
}
|
|
|
|
if cfg.Streaming {
|
|
logger.Printf("streaming mode is not supported; falling back to non-streaming")
|
|
}
|
|
|
|
runtimeDir := ensureRuntimeDir(logger)
|
|
lockFile := filepath.Join(runtimeDir, "lel.lock")
|
|
lock, err := lockSingleInstance(lockFile)
|
|
if err != nil {
|
|
logger.Fatalf("another instance is running (lock %s): %v", lockFile, err)
|
|
}
|
|
defer lock.Close()
|
|
|
|
x, err := x11.New()
|
|
if err != nil {
|
|
logger.Fatalf("x11 connection failed: %v", err)
|
|
}
|
|
defer x.Close()
|
|
|
|
mods, keycode, err := x.ParseHotkey(cfg.Hotkey)
|
|
if err != nil {
|
|
logger.Fatalf("hotkey parse failed: %v", err)
|
|
}
|
|
if err := x.GrabHotkey(mods, keycode); err != nil {
|
|
logger.Fatalf("grab hotkey failed: %v", err)
|
|
}
|
|
defer x.UngrabHotkey(mods, keycode)
|
|
|
|
backend, err := inject.NewBackend(cfg.InjectionBackend, inject.Deps{
|
|
Clipboard: inject.ClipboardWriterFunc(clip.WriteClipboard),
|
|
Paster: inject.NewXdotoolPaster(nil),
|
|
Typer: inject.NewXdotoolTyper(nil),
|
|
})
|
|
if err != nil {
|
|
logger.Fatalf("backend error: %v", err)
|
|
}
|
|
|
|
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")
|
|
if err := os.RemoveAll(sockPath); err != nil {
|
|
logger.Fatalf("remove socket failed: %v", err)
|
|
}
|
|
ln, err := net.Listen("unix", sockPath)
|
|
if err != nil {
|
|
logger.Fatalf("listen socket failed: %v", err)
|
|
}
|
|
defer ln.Close()
|
|
|
|
reloadPath := configPath
|
|
if reloadPath == "" {
|
|
reloadPath = config.DefaultPath()
|
|
}
|
|
go serveControl(logger, ln, d, &cfg, x, &mods, &keycode, reloadPath)
|
|
|
|
logger.Printf("ready (hotkey: %s)", cfg.Hotkey)
|
|
|
|
if noTray {
|
|
go handleSignals(logger, d)
|
|
runX11Loop(logger, x, d, mods, keycode, dryRun)
|
|
return
|
|
}
|
|
|
|
onReady := func() {
|
|
systray.SetTitle("lel")
|
|
systray.SetTooltip("lel: idle")
|
|
systray.SetIcon(ui.IconIdle())
|
|
status := systray.AddMenuItem("Idle", "")
|
|
status.Disable()
|
|
systray.AddSeparator()
|
|
quit := systray.AddMenuItem("Quit", "Quit lel")
|
|
|
|
go func() {
|
|
for st := range d.StateChanges() {
|
|
switch st {
|
|
case daemon.StateRecording:
|
|
systray.SetIcon(ui.IconRecording())
|
|
systray.SetTooltip("lel: recording")
|
|
status.SetTitle("Recording")
|
|
case daemon.StateTranscribing:
|
|
systray.SetIcon(ui.IconTranscribing())
|
|
systray.SetTooltip("lel: transcribing")
|
|
status.SetTitle("Transcribing")
|
|
case daemon.StateProcessing:
|
|
systray.SetIcon(ui.IconProcessing())
|
|
systray.SetTooltip("lel: ai processing")
|
|
status.SetTitle("AI Processing")
|
|
default:
|
|
systray.SetIcon(ui.IconIdle())
|
|
systray.SetTooltip("lel: idle")
|
|
status.SetTitle("Idle")
|
|
}
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
for range quit.ClickedCh {
|
|
os.Exit(0)
|
|
}
|
|
}()
|
|
|
|
go handleSignals(logger, d)
|
|
go runX11Loop(logger, x, d, mods, keycode, dryRun)
|
|
}
|
|
|
|
systray.Run(onReady, func() {})
|
|
}
|
|
|
|
func matchMods(state uint16, want uint16) bool {
|
|
masked := state & ^uint16(xproto.ModMaskLock|xproto.ModMask2)
|
|
return masked == want
|
|
}
|
|
|
|
func runX11Loop(logger *log.Logger, x *x11.Conn, d *daemon.Daemon, mods uint16, keycode xproto.Keycode, dryRun bool) {
|
|
for {
|
|
ev, err := x.X.WaitForEvent()
|
|
if err != nil {
|
|
logger.Printf("x11 event error: %v", err)
|
|
continue
|
|
}
|
|
switch e := ev.(type) {
|
|
case xproto.KeyPressEvent:
|
|
if e.Detail == keycode && matchMods(e.State, mods) {
|
|
if dryRun {
|
|
logger.Printf("hotkey pressed (dry-run)")
|
|
continue
|
|
}
|
|
d.Toggle()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
dir := os.Getenv("XDG_RUNTIME_DIR")
|
|
if dir == "" {
|
|
dir = "/tmp"
|
|
}
|
|
dir = filepath.Join(dir, "lel")
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
logger.Fatalf("runtime dir error: %v", err)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func lockSingleInstance(path string) (*os.File, error) {
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
|
|
_ = f.Close()
|
|
return nil, err
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
func serveControl(logger *log.Logger, ln net.Listener, d *daemon.Daemon, cfg *config.Config, x *x11.Conn, mods *uint16, keycode *xproto.Keycode, configPath string) {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
logger.Printf("control accept error: %v", err)
|
|
continue
|
|
}
|
|
go handleConn(logger, conn, d, cfg, x, mods, keycode, configPath)
|
|
}
|
|
}
|
|
|
|
func handleConn(logger *log.Logger, conn net.Conn, d *daemon.Daemon, cfg *config.Config, x *x11.Conn, mods *uint16, keycode *xproto.Keycode, configPath string) {
|
|
defer conn.Close()
|
|
|
|
reader := bufio.NewReader(conn)
|
|
line, _ := reader.ReadString('\n')
|
|
line = strings.TrimSpace(line)
|
|
|
|
switch line {
|
|
case "status":
|
|
_, _ = fmt.Fprintf(conn, "state=%s\n", d.State())
|
|
case "stop":
|
|
_, _ = fmt.Fprintf(conn, "stopping\n")
|
|
logger.Printf("stop requested")
|
|
os.Exit(0)
|
|
case "reload":
|
|
newCfg, err := config.Load(configPath)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(conn, "reload error: %v\n", err)
|
|
return
|
|
}
|
|
|
|
newMods, newKeycode, err := x.ParseHotkey(newCfg.Hotkey)
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(conn, "reload error: %v\n", err)
|
|
return
|
|
}
|
|
|
|
x.UngrabHotkey(*mods, *keycode)
|
|
if err := x.GrabHotkey(newMods, newKeycode); err != nil {
|
|
_, _ = fmt.Fprintf(conn, "reload error: %v\n", err)
|
|
return
|
|
}
|
|
|
|
backend, err := inject.NewBackend(newCfg.InjectionBackend, inject.Deps{
|
|
Clipboard: inject.ClipboardWriterFunc(clip.WriteClipboard),
|
|
Paster: inject.NewXdotoolPaster(nil),
|
|
Typer: inject.NewXdotoolTyper(nil),
|
|
})
|
|
if err != nil {
|
|
_, _ = fmt.Fprintf(conn, "reload error: %v\n", err)
|
|
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
|
|
*keycode = newKeycode
|
|
*cfg = newCfg
|
|
d.UpdateConfig(newCfg)
|
|
d.UpdateBackend(backend)
|
|
d.UpdateAI(processor)
|
|
|
|
_, _ = fmt.Fprintf(conn, "reloaded\n")
|
|
default:
|
|
_, _ = fmt.Fprintf(conn, "unknown command\n")
|
|
}
|
|
}
|