aman/cmd/leld/main.go

322 lines
8.1 KiB
Go

package main
import (
"bufio"
"context"
"encoding/json"
"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)
logConfig(logger, cfg, reloadPath)
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 logConfig(logger *log.Logger, cfg config.Config, path string) {
safe := cfg
safe.AIAPIKey = ""
data, err := json.MarshalIndent(safe, "", " ")
if err != nil {
logger.Printf("config: <error: %v>", err)
return
}
logger.Printf("config (%s):\n%s", path, string(data))
}
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")
}
}