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: ", 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") } }