Add injection backends
This commit is contained in:
parent
a7f50fed75
commit
9ee301fbeb
7 changed files with 290 additions and 4 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
72
internal/inject/inject.go
Normal 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")
|
||||
}
|
||||
}
|
||||
102
internal/inject/inject_test.go
Normal file
102
internal/inject/inject_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
63
internal/inject/xdotool.go
Normal file
63
internal/inject/xdotool.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue