diff --git a/README.md b/README.md index cd1277f..e39b579 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ record_timeout_sec = 120 whisper_timeout_sec = 300 segment_sec = 5 streaming = false +injection_backend = "clipboard" ``` Env overrides: @@ -39,7 +40,7 @@ Env overrides: - `WHISPER_MODEL`, `WHISPER_LANG`, `WHISPER_DEVICE`, `WHISPER_EXTRA_ARGS` - `WHISPER_FFMPEG_IN` - `WHISPER_STREAM`, `WHISPER_SEGMENT_SEC`, `WHISPER_TIMEOUT_SEC` -- `LEL_RECORD_TIMEOUT_SEC`, `LEL_HOTKEY` +- `LEL_RECORD_TIMEOUT_SEC`, `LEL_HOTKEY`, `LEL_INJECTION_BACKEND` ## Run manually @@ -69,6 +70,11 @@ systemctl --user enable --now lel - Press it again to stop and transcribe. - The transcript is logged to stderr. +Injection backends: + +- `clipboard`: copy to clipboard and inject via Ctrl+V (requires `xclip` + `xdotool`) +- `injection`: type the text with simulated keypresses (requires `xdotool`) + Control: ```bash diff --git a/cmd/leld/main.go b/cmd/leld/main.go index e4b422c..d5a76a2 100644 --- a/cmd/leld/main.go +++ b/cmd/leld/main.go @@ -11,8 +11,10 @@ import ( "strings" "syscall" + "lel/internal/clip" "lel/internal/config" "lel/internal/daemon" + "lel/internal/inject" "lel/internal/ui" "lel/internal/x11" @@ -63,7 +65,16 @@ func main() { } defer x.UngrabHotkey(mods, keycode) - d := daemon.New(cfg, x, logger) + 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) + } + + d := daemon.New(cfg, x, logger, backend) sockPath := filepath.Join(runtimeDir, "ctl.sock") if err := os.RemoveAll(sockPath); err != nil { @@ -221,10 +232,21 @@ func handleConn(logger *log.Logger, conn net.Conn, d *daemon.Daemon, cfg *config 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 + } + *mods = newMods *keycode = newKeycode *cfg = newCfg d.UpdateConfig(newCfg) + d.UpdateBackend(backend) _, _ = fmt.Fprintf(conn, "reloaded\n") default: diff --git a/internal/config/config.go b/internal/config/config.go index bc8ea09..9c036d3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ type Config struct { WhisperTimeoutSec int `toml:"whisper_timeout_sec"` SegmentSec int `toml:"segment_sec"` Streaming bool `toml:"streaming"` + InjectionBackend string `toml:"injection_backend"` } func DefaultPath() string { @@ -40,6 +41,7 @@ func Defaults() Config { WhisperTimeoutSec: 300, SegmentSec: 5, Streaming: false, + InjectionBackend: "clipboard", } } @@ -108,6 +110,9 @@ func applyEnv(cfg *Config) { if v := os.Getenv("LEL_HOTKEY"); v != "" { cfg.Hotkey = v } + if v := os.Getenv("LEL_INJECTION_BACKEND"); v != "" { + cfg.InjectionBackend = v + } } func parseBool(v string) bool { diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 86fea3f..7e62b23 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -13,6 +13,7 @@ import ( "lel/internal/audio" "lel/internal/clip" "lel/internal/config" + "lel/internal/inject" "lel/internal/whisper" "lel/internal/x11" ) @@ -29,6 +30,7 @@ type Daemon struct { cfg config.Config x11 *x11.Conn log *log.Logger + inj inject.Backend mu sync.Mutex state State @@ -39,9 +41,9 @@ type Daemon struct { stateCh chan State } -func New(cfg config.Config, x *x11.Conn, logger *log.Logger) *Daemon { +func New(cfg config.Config, x *x11.Conn, logger *log.Logger, inj inject.Backend) *Daemon { r := &audio.Recorder{Input: cfg.FfmpegInput} - return &Daemon{cfg: cfg, x11: x, log: logger, state: StateIdle, ffmpeg: r, stateCh: make(chan State, 4)} + return &Daemon{cfg: cfg, x11: x, log: logger, inj: inj, state: StateIdle, ffmpeg: r, stateCh: make(chan State, 4)} } func (d *Daemon) UpdateConfig(cfg config.Config) { @@ -53,6 +55,12 @@ func (d *Daemon) UpdateConfig(cfg config.Config) { d.mu.Unlock() } +func (d *Daemon) UpdateBackend(inj inject.Backend) { + d.mu.Lock() + d.inj = inj + d.mu.Unlock() +} + func (d *Daemon) State() State { d.mu.Lock() defer d.mu.Unlock() @@ -171,6 +179,14 @@ func (d *Daemon) stopAndProcess(reason string) { status = "clipboard failed: " + err.Error() return } + + injCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if d.inj != nil { + if err := d.inj.Inject(injCtx, text); err != nil { + d.log.Printf("inject failed: %v", err) + } + } } func (d *Daemon) setIdle(msg string) { diff --git a/internal/inject/inject.go b/internal/inject/inject.go new file mode 100644 index 0000000..2389da1 --- /dev/null +++ b/internal/inject/inject.go @@ -0,0 +1,72 @@ +package inject + +import ( + "context" + "errors" + "strings" +) + +type Backend interface { + Inject(ctx context.Context, text string) error +} + +type ClipboardWriter interface { + WriteClipboard(ctx context.Context, text string) error +} + +type ClipboardWriterFunc func(ctx context.Context, text string) error + +func (f ClipboardWriterFunc) WriteClipboard(ctx context.Context, text string) error { + return f(ctx, text) +} + +type Paster interface { + Paste(ctx context.Context) error +} + +type Typer interface { + TypeText(ctx context.Context, text string) error +} + +type Deps struct { + Clipboard ClipboardWriter + Paster Paster + Typer Typer +} + +type ClipboardBackend struct { + Writer ClipboardWriter + Paster Paster +} + +func (b ClipboardBackend) Inject(ctx context.Context, text string) error { + if b.Writer == nil || b.Paster == nil { + return errors.New("clipboard backend missing dependencies") + } + if err := b.Writer.WriteClipboard(ctx, text); err != nil { + return err + } + return b.Paster.Paste(ctx) +} + +type InjectionBackend struct { + Typer Typer +} + +func (b InjectionBackend) Inject(ctx context.Context, text string) error { + if b.Typer == nil { + return errors.New("injection backend missing dependencies") + } + return b.Typer.TypeText(ctx, text) +} + +func NewBackend(name string, deps Deps) (Backend, error) { + switch strings.ToLower(strings.TrimSpace(name)) { + case "", "clipboard": + return ClipboardBackend{Writer: deps.Clipboard, Paster: deps.Paster}, nil + case "injection": + return InjectionBackend{Typer: deps.Typer}, nil + default: + return nil, errors.New("unknown injection backend") + } +} diff --git a/internal/inject/inject_test.go b/internal/inject/inject_test.go new file mode 100644 index 0000000..10c6dba --- /dev/null +++ b/internal/inject/inject_test.go @@ -0,0 +1,102 @@ +package inject + +import ( + "context" + "errors" + "testing" +) + +type fakeClipboard struct { + called bool + err error +} + +func (f *fakeClipboard) WriteClipboard(ctx context.Context, text string) error { + f.called = true + return f.err +} + +type fakePaster struct { + called bool + err error +} + +func (f *fakePaster) Paste(ctx context.Context) error { + f.called = true + return f.err +} + +type fakeTyper struct { + called bool + err error +} + +func (f *fakeTyper) TypeText(ctx context.Context, text string) error { + f.called = true + return f.err +} + +func TestClipboardBackend(t *testing.T) { + cb := &fakeClipboard{} + p := &fakePaster{} + b := ClipboardBackend{Writer: cb, Paster: p} + + err := b.Inject(context.Background(), "hello") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !cb.called || !p.called { + t.Fatalf("expected clipboard and paster to be called") + } +} + +func TestClipboardBackendClipboardError(t *testing.T) { + cb := &fakeClipboard{err: errors.New("boom")} + p := &fakePaster{} + b := ClipboardBackend{Writer: cb, Paster: p} + + err := b.Inject(context.Background(), "hello") + if err == nil { + t.Fatalf("expected error") + } + if !cb.called { + t.Fatalf("expected clipboard to be called") + } + if p.called { + t.Fatalf("did not expect paster to be called") + } +} + +func TestInjectionBackend(t *testing.T) { + typ := &fakeTyper{} + b := InjectionBackend{Typer: typ} + + err := b.Inject(context.Background(), "hello") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !typ.called { + t.Fatalf("expected typer to be called") + } +} + +func TestNewBackend(t *testing.T) { + cb := &fakeClipboard{} + p := &fakePaster{} + typ := &fakeTyper{} + + b, err := NewBackend("clipboard", Deps{Clipboard: cb, Paster: p, Typer: typ}) + if err != nil || b == nil { + t.Fatalf("expected clipboard backend") + } + + b, err = NewBackend("injection", Deps{Clipboard: cb, Paster: p, Typer: typ}) + if err != nil || b == nil { + t.Fatalf("expected injection backend") + } + + b, err = NewBackend("unknown", Deps{Clipboard: cb, Paster: p, Typer: typ}) + if err == nil || b != nil { + t.Fatalf("expected error for unknown backend") + } +} diff --git a/internal/inject/xdotool.go b/internal/inject/xdotool.go new file mode 100644 index 0000000..856b648 --- /dev/null +++ b/internal/inject/xdotool.go @@ -0,0 +1,63 @@ +package inject + +import ( + "context" + "errors" + "os/exec" + "strings" +) + +type Runner func(ctx context.Context, name string, args ...string) ([]byte, error) + +func DefaultRunner(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + return cmd.CombinedOutput() +} + +type XdotoolPaster struct { + Run Runner +} + +func NewXdotoolPaster(run Runner) XdotoolPaster { + if run == nil { + run = DefaultRunner + } + return XdotoolPaster{Run: run} +} + +func (p XdotoolPaster) Paste(ctx context.Context) error { + out, err := p.Run(ctx, "xdotool", "key", "--clearmodifiers", "ctrl+v") + if err != nil { + return formatRunError(out, err) + } + return nil +} + +type XdotoolTyper struct { + Run Runner +} + +func NewXdotoolTyper(run Runner) XdotoolTyper { + if run == nil { + run = DefaultRunner + } + return XdotoolTyper{Run: run} +} + +func (t XdotoolTyper) TypeText(ctx context.Context, text string) error { + if strings.TrimSpace(text) == "" { + return errors.New("empty transcript") + } + out, err := t.Run(ctx, "xdotool", "type", "--clearmodifiers", "--delay", "1", text) + if err != nil { + return formatRunError(out, err) + } + return nil +} + +func formatRunError(out []byte, err error) error { + if len(out) > 0 { + return errors.New(strings.TrimSpace(string(out))) + } + return err +}