Add injection backends

This commit is contained in:
Thales Maciel 2026-02-06 11:50:30 -03:00
parent a7f50fed75
commit 9ee301fbeb
7 changed files with 290 additions and 4 deletions

View file

@ -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

View file

@ -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:

View file

@ -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 {

View file

@ -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) {

72
internal/inject/inject.go Normal file
View file

@ -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")
}
}

View file

@ -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")
}
}

View file

@ -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
}