From 13aa3e3b8021f71a9fc99a2046ee087a73b334fb Mon Sep 17 00:00:00 2001 From: leonardotrapani Date: Fri, 19 Dec 2025 16:31:54 +0100 Subject: [PATCH] new injection strategies --- README.md | 72 ++++-- cmd/hyprvoice/main.go | 100 +++++--- internal/config/config.go | 86 +++++-- internal/config/config_test.go | 253 ++++++++++++++++--- internal/injection/backend.go | 13 + internal/injection/clipboard.go | 52 ++-- internal/injection/injection.go | 103 ++++---- internal/injection/injection_test.go | 269 ++++++++------------- internal/injection/{typing.go => wtype.go} | 40 +-- internal/injection/ydotool.go | 87 +++++++ internal/pipeline/pipeline_test.go | 21 +- internal/testutil/testutil.go | 11 +- packaging/PKGBUILD | 1 + 13 files changed, 734 insertions(+), 374 deletions(-) create mode 100644 internal/injection/backend.go rename internal/injection/{typing.go => wtype.go} (63%) create mode 100644 internal/injection/ydotool.go diff --git a/README.md b/README.md index 94c6936..a289fcd 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,8 @@ export PATH="$HOME/.local/bin:$PATH" - `pipewire`, `pipewire-pulse`, `pipewire-audio` - Audio capture - `wl-clipboard` - Clipboard integration -- `wtype` - Text typing +- `wtype` - Text typing (Wayland) +- `ydotool` - Text typing (universal, recommended for Chromium apps) - `libnotify` - Desktop notifications - `systemd` - User service management @@ -76,10 +77,15 @@ For manual installation on other distros: ```bash # Ubuntu/Debian -sudo apt install pipewire-pulse pipewire-bin wl-clipboard wtype libnotify-bin +sudo apt install pipewire-pulse pipewire-bin wl-clipboard wtype ydotool libnotify-bin # Fedora -sudo dnf install pipewire-utils wl-clipboard wtype libnotify +sudo dnf install pipewire-utils wl-clipboard wtype ydotool libnotify + +# For ydotool, you also need to start the daemon: +systemctl --user enable --now ydotool +# Or add user to input group for uinput access: +sudo usermod -aG input $USER ``` ## Quick Start @@ -297,9 +303,9 @@ The daemon automatically creates `~/.config/hyprvoice/config.toml` with helpful # Text Injection Configuration [injection] - mode = "fallback" # Injection method ("clipboard", "type", "fallback") - restore_clipboard = true # Restore original clipboard after injection - wtype_timeout = "5s" # Timeout for direct typing via wtype + backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain + ydotool_timeout = "5s" # Timeout for ydotool commands + wtype_timeout = "5s" # Timeout for wtype commands clipboard_timeout = "3s" # Timeout for clipboard operations # Desktop Notification Configuration @@ -343,26 +349,62 @@ timeout = "5m" # Maximum recording duration (prevents runaway record #### Text Injection -Configurable text injection with multiple modes: +Configurable text injection with multiple backends: ```toml [injection] -mode = "fallback" # "clipboard", "type", or "fallback" -restore_clipboard = true +backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain +ydotool_timeout = "5s" wtype_timeout = "5s" clipboard_timeout = "3s" ``` -**Injection Modes:** +**Injection Backends:** -- **`fallback`** (default): Try direct typing first, fallback to clipboard -- **`type`**: Direct typing using wtype only -- **`clipboard`**: Copy to clipboard only +- **`ydotool`**: Uses ydotool (requires `ydotoold` daemon). Most compatible with Chromium/Electron apps. +- **`wtype`**: Uses wtype for Wayland. May have issues with some Chromium-based apps (known upstream bug). +- **`clipboard`**: Copies text to clipboard only. Most reliable, but requires manual paste. + +**Fallback Chain:** + +Backends are tried in order. The first successful one wins. Example configurations: + +```toml +# Clipboard only (safest, always works) +backends = ["clipboard"] + +# wtype with clipboard fallback +backends = ["wtype", "clipboard"] + +# Full fallback chain (default) - best compatibility +backends = ["ydotool", "wtype", "clipboard"] + +# ydotool only (if you have it set up) +backends = ["ydotool"] +``` + +**ydotool Setup:** + +ydotool requires the `ydotoold` daemon running and access to `/dev/uinput`: + +```bash +# Start ydotool daemon (systemd) +systemctl --user enable --now ydotool + +# Or add user to input group +sudo usermod -aG input $USER +# Then logout/login + +# For Hyprland, add to config to set correct keyboard layout: +# device:ydotoold-virtual-device { +# kb_layout = us +# } +``` **Behavior:** -- `restore_clipboard = true`: Save and restore original clipboard content -- Smart fallback ensures text injection always succeeds when possible +- Backends are tried in order until one succeeds +- Include `clipboard` in the chain if you want text copied to clipboard as fallback #### Notifications diff --git a/cmd/hyprvoice/main.go b/cmd/hyprvoice/main.go index 501f2bf..2a95601 100644 --- a/cmd/hyprvoice/main.go +++ b/cmd/hyprvoice/main.go @@ -179,7 +179,8 @@ func runInteractiveConfig() error { } // Model selection based on provider - if cfg.Transcription.Provider == "openai" { + switch cfg.Transcription.Provider { + case "openai": fmt.Println("\nOpenAI Model:") fmt.Printf("Model (current: %s): ", cfg.Transcription.Model) if scanner.Scan() { @@ -190,7 +191,7 @@ func runInteractiveConfig() error { cfg.Transcription.Model = "whisper-1" } } - } else if cfg.Transcription.Provider == "groq-transcription" { + case "groq-transcription": fmt.Println("\nGroq Transcription Model:") fmt.Println(" 1. whisper-large-v3 - Standard model") fmt.Println(" 2. whisper-large-v3-turbo - Faster model") @@ -210,7 +211,7 @@ func runInteractiveConfig() error { } } } - } else if cfg.Transcription.Provider == "groq-translation" { + case "groq-translation": fmt.Println("\nGroq Translation Model:") fmt.Println(" Note: Translation only supports whisper-large-v3 (turbo not available)") fmt.Printf("Model (current: %s, press Enter for whisper-large-v3): ", cfg.Transcription.Model) @@ -257,21 +258,40 @@ func runInteractiveConfig() error { // Configure injection fmt.Println("⌨️ Text Injection Configuration") fmt.Println("--------------------------------") - fmt.Printf("Injection mode [clipboard/type/fallback] (current: %s): ", cfg.Injection.Mode) + fmt.Println("Backends are tried in order until one succeeds (fallback chain):") + fmt.Println(" - ydotool: Best for Chromium/Electron apps (requires ydotoold daemon)") + fmt.Println(" - wtype: Native Wayland typing (may fail on some Chromium apps)") + fmt.Println(" - clipboard: Copies to clipboard only (most reliable, needs manual paste)") + fmt.Println() + fmt.Println("Recommended: ydotool,wtype,clipboard (full fallback chain)") + fmt.Println() + fmt.Printf("Backends (comma-separated) (current: %s): ", strings.Join(cfg.Injection.Backends, ",")) if scanner.Scan() { input := strings.TrimSpace(scanner.Text()) - if input != "" && (input == "clipboard" || input == "type" || input == "fallback") { - cfg.Injection.Mode = input + if input != "" { + backends := strings.Split(input, ",") + validBackends := make([]string, 0) + for _, b := range backends { + b = strings.TrimSpace(b) + if b == "ydotool" || b == "wtype" || b == "clipboard" { + validBackends = append(validBackends, b) + } + } + if len(validBackends) > 0 { + cfg.Injection.Backends = validBackends + } } } - fmt.Printf("Restore clipboard after injection [y/n] (current: %v): ", cfg.Injection.RestoreClipboard) - if scanner.Scan() { - switch strings.TrimSpace(strings.ToLower(scanner.Text())) { - case "y", "yes": - cfg.Injection.RestoreClipboard = true - case "n", "no": - cfg.Injection.RestoreClipboard = false + // Check if ydotool is selected and warn about daemon requirement + for _, b := range cfg.Injection.Backends { + if b == "ydotool" { + fmt.Println() + fmt.Println("⚠️ ydotool requires the ydotoold daemon to be running!") + fmt.Println(" Start it with: systemctl --user enable --now ydotool") + fmt.Println(" Or ensure your user has access to /dev/uinput (input group)") + fmt.Println() + break } } @@ -329,15 +349,29 @@ func runInteractiveConfig() error { serviceRunning = true } + // Check if ydotool is in backends + hasYdotool := false + for _, b := range cfg.Injection.Backends { + if b == "ydotool" { + hasYdotool = true + break + } + } + // Show next steps fmt.Println("🚀 Next Steps:") - if !serviceRunning { - fmt.Println("1. Start the service: systemctl --user start hyprvoice.service") - fmt.Println("2. Test voice input: hyprvoice toggle") - } else { - fmt.Println("1. Restart the service to apply changes: systemctl --user restart hyprvoice.service") - fmt.Println("2. Test voice input: hyprvoice toggle") + step := 1 + if hasYdotool { + fmt.Printf("%d. Ensure ydotoold is running: systemctl --user enable --now ydotool\n", step) + step++ } + if !serviceRunning { + fmt.Printf("%d. Start the service: systemctl --user start hyprvoice.service\n", step) + } else { + fmt.Printf("%d. Restart the service to apply changes: systemctl --user restart hyprvoice.service\n", step) + } + step++ + fmt.Printf("%d. Test voice input: hyprvoice toggle (or use keybind you configured in hyprland config)\n", step) fmt.Println() configPath, _ := config.GetConfigPath() @@ -346,6 +380,14 @@ func runInteractiveConfig() error { return nil } +func formatBackends(backends []string) string { + quoted := make([]string, len(backends)) + for i, b := range backends { + quoted[i] = fmt.Sprintf(`"%s"`, b) + } + return strings.Join(quoted, ", ") +} + func maskAPIKey(key string) string { if key == "" { return "" @@ -391,9 +433,9 @@ func saveConfig(cfg *config.Config) error { # Text Injection Configuration [injection] - mode = "%s" # Injection method ("clipboard", "type", "fallback") - restore_clipboard = %v # Restore original clipboard after injection - wtype_timeout = "%s" # Timeout for direct typing via wtype + backends = [%s] # Ordered fallback chain (tries each until one succeeds) + ydotool_timeout = "%s" # Timeout for ydotool commands + wtype_timeout = "%s" # Timeout for wtype commands clipboard_timeout = "%s" # Timeout for clipboard operations # Desktop Notification Configuration @@ -401,10 +443,12 @@ func saveConfig(cfg *config.Config) error { enabled = %v # Enable desktop notifications type = "%s" # Notification type ("desktop", "log", "none") -# Mode explanations: -# - "clipboard": Copy text to clipboard only -# - "type": Direct typing via wtype only -# - "fallback": Try typing first, fallback to clipboard if it fails +# Backend explanations: +# - "ydotool": Uses ydotool (requires ydotoold daemon running). Most compatible with Chromium/Electron apps. +# - "wtype": Uses wtype for Wayland. May have issues with some Chromium-based apps. +# - "clipboard": Copies text to clipboard only (most reliable, but requires manual paste). +# +# The backends are tried in order. First successful one wins. # # Provider explanations: # - "openai": OpenAI Whisper API (cloud-based, requires OPENAI_API_KEY) @@ -428,8 +472,8 @@ func saveConfig(cfg *config.Config) error { cfg.Transcription.APIKey, cfg.Transcription.Language, cfg.Transcription.Model, - cfg.Injection.Mode, - cfg.Injection.RestoreClipboard, + formatBackends(cfg.Injection.Backends), + cfg.Injection.YdotoolTimeout, cfg.Injection.WtypeTimeout, cfg.Injection.ClipboardTimeout, cfg.Notifications.Enabled, diff --git a/internal/config/config.go b/internal/config/config.go index f93e0c8..b65b36b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,8 +38,8 @@ type TranscriptionConfig struct { } type InjectionConfig struct { - Mode string `toml:"mode"` - RestoreClipboard bool `toml:"restore_clipboard"` + Backends []string `toml:"backends"` + YdotoolTimeout time.Duration `toml:"ydotool_timeout"` WtypeTimeout time.Duration `toml:"wtype_timeout"` ClipboardTimeout time.Duration `toml:"clipboard_timeout"` } @@ -84,8 +84,8 @@ func (c *Config) ToTranscriberConfig() transcriber.Config { func (c *Config) ToInjectionConfig() injection.Config { return injection.Config{ - Mode: c.Injection.Mode, - RestoreClipboard: c.Injection.RestoreClipboard, + Backends: c.Injection.Backends, + YdotoolTimeout: c.Injection.YdotoolTimeout, WtypeTimeout: c.Injection.WtypeTimeout, ClipboardTimeout: c.Injection.ClipboardTimeout, } @@ -181,9 +181,17 @@ func (c *Config) Validate() error { } // Injection - validModes := map[string]bool{"clipboard": true, "type": true, "fallback": true} - if !validModes[c.Injection.Mode] { - return fmt.Errorf("invalid injection.mode: %s (must be clipboard, type, or fallback)", c.Injection.Mode) + if len(c.Injection.Backends) == 0 { + return fmt.Errorf("invalid injection.backends: empty (must have at least one backend)") + } + validBackends := map[string]bool{"ydotool": true, "wtype": true, "clipboard": true} + for _, backend := range c.Injection.Backends { + if !validBackends[backend] { + return fmt.Errorf("invalid injection.backends: unknown backend %q (must be ydotool, wtype, or clipboard)", backend) + } + } + if c.Injection.YdotoolTimeout <= 0 { + return fmt.Errorf("invalid injection.ydotool_timeout: %v", c.Injection.YdotoolTimeout) } if c.Injection.WtypeTimeout <= 0 { return fmt.Errorf("invalid injection.wtype_timeout: %v", c.Injection.WtypeTimeout) @@ -235,6 +243,15 @@ func GetConfigPath() (string, error) { return filepath.Join(hyprvoiceDir, "config.toml"), nil } +// legacyInjectionConfig for migration from old mode-based config +type legacyInjectionConfig struct { + Mode string `toml:"mode"` +} + +type legacyConfig struct { + Injection legacyInjectionConfig `toml:"injection"` +} + func Load() (*Config, error) { configPath, err := GetConfigPath() if err != nil { @@ -257,10 +274,45 @@ func Load() (*Config, error) { return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err) } + // Migrate legacy mode-based config to backends + if len(config.Injection.Backends) == 0 { + var legacy legacyConfig + toml.DecodeFile(configPath, &legacy) + config.migrateInjectionMode(legacy.Injection.Mode) + } + log.Printf("Config: configuration loaded successfully") return &config, nil } +// migrateInjectionMode converts old mode field to new backends array +func (c *Config) migrateInjectionMode(mode string) { + switch mode { + case "clipboard": + c.Injection.Backends = []string{"clipboard"} + log.Printf("Config: migrated injection.mode='clipboard' to backends=['clipboard']") + case "type": + c.Injection.Backends = []string{"wtype"} + log.Printf("Config: migrated injection.mode='type' to backends=['wtype']") + case "fallback": + c.Injection.Backends = []string{"wtype", "clipboard"} + log.Printf("Config: migrated injection.mode='fallback' to backends=['wtype', 'clipboard']") + default: + // Default for new installs or unknown modes + c.Injection.Backends = []string{"ydotool", "wtype", "clipboard"} + if mode != "" { + log.Printf("Config: unknown injection.mode='%s', using default backends", mode) + } + } + + // Set default ydotool timeout if not set + if c.Injection.YdotoolTimeout == 0 { + c.Injection.YdotoolTimeout = 5 * time.Second + } + + log.Printf("Config: legacy 'mode' config detected - please update your config.toml to use 'backends' instead") +} + func SaveDefaultConfig() error { configPath, err := GetConfigPath() if err != nil { @@ -296,9 +348,9 @@ func SaveDefaultConfig() error { # Text Injection Configuration [injection] - mode = "fallback" # Injection method ("clipboard", "type", "fallback") - restore_clipboard = true # Restore original clipboard after injection - wtype_timeout = "5s" # Timeout for direct typing via wtype + backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain (tries each until one succeeds) + ydotool_timeout = "5s" # Timeout for ydotool commands + wtype_timeout = "5s" # Timeout for wtype commands clipboard_timeout = "3s" # Timeout for clipboard operations # Desktop Notification Configuration @@ -306,10 +358,16 @@ func SaveDefaultConfig() error { enabled = true # Enable desktop notifications type = "desktop" # Notification type ("desktop", "log", "none") -# Mode explanations: -# - "clipboard": Copy text to clipboard only -# - "type": Direct typing via wtype only -# - "fallback": Try typing first, fallback to clipboard if it fails +# Backend explanations: +# - "ydotool": Uses ydotool (requires ydotoold daemon running). Most compatible with Chromium/Electron apps. +# - "wtype": Uses wtype for Wayland. May have issues with some Chromium-based apps. +# - "clipboard": Copies text to clipboard only (most reliable, but requires manual paste). +# +# The backends are tried in order. First successful one wins. +# Example configurations: +# backends = ["clipboard"] # Clipboard only (safest) +# backends = ["wtype", "clipboard"] # wtype with clipboard fallback +# backends = ["ydotool", "wtype", "clipboard"] # Full fallback chain (default) # # Provider explanations: # - "openai": OpenAI Whisper API (cloud-based, requires OPENAI_API_KEY) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index caa2f63..3a0fa78 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -26,8 +26,7 @@ func createTestConfig() *Config { Model: "whisper-1", }, Injection: InjectionConfig{ - Mode: "fallback", - RestoreClipboard: true, + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, @@ -55,9 +54,9 @@ func createTestConfigWithInvalidValues() *Config { Model: "", // Invalid }, Injection: InjectionConfig{ - Mode: "invalid", // Invalid - WtypeTimeout: 0, // Invalid - ClipboardTimeout: 0, // Invalid + Backends: []string{"invalid"}, YdotoolTimeout: 5 * time.Second, // Invalid + WtypeTimeout: 0, // Invalid + ClipboardTimeout: 0, // Invalid }, Notifications: NotificationsConfig{ Type: "invalid", // Invalid @@ -98,7 +97,7 @@ func TestConfig_Validate(t *testing.T) { Model: "whisper-1", }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, @@ -125,7 +124,7 @@ func TestConfig_Validate(t *testing.T) { Model: "whisper-1", }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, @@ -152,7 +151,7 @@ func TestConfig_Validate(t *testing.T) { Model: "whisper-1", }, Injection: InjectionConfig{ - Mode: "invalid", + Backends: []string{"invalid"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, @@ -179,7 +178,7 @@ func TestConfig_Validate(t *testing.T) { Model: "whisper-1", }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, @@ -207,7 +206,7 @@ func TestConfig_Validate(t *testing.T) { Model: "whisper-1", }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, @@ -235,7 +234,7 @@ func TestConfig_Validate(t *testing.T) { Model: "whisper-1", }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, @@ -321,7 +320,8 @@ api_key = "test-key" model = "whisper-1" [injection] -mode = "fallback" +backends = ["ydotool", "wtype", "clipboard"] +ydotool_timeout = "5s" wtype_timeout = "5s" clipboard_timeout = "3s" @@ -363,6 +363,205 @@ type = "log"` t.Errorf("Expected Provider 'openai', got %s", config.Transcription.Provider) } }) + + // Test migration from legacy mode config + t.Run("migrates legacy mode=fallback to backends", func(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") + + err := os.MkdirAll(filepath.Dir(configPath), 0755) + if err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + legacyConfig := `[recording] +sample_rate = 16000 +channels = 1 +format = "s16" +buffer_size = 8192 +channel_buffer_size = 30 +timeout = "5m" + +[transcription] +provider = "openai" +api_key = "test-key" +model = "whisper-1" + +[injection] +mode = "fallback" +wtype_timeout = "5s" +clipboard_timeout = "3s" + +[notifications] +enabled = true +type = "log"` + + err = os.WriteFile(configPath, []byte(legacyConfig), 0644) + if err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + + originalConfigDir := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tempDir) + defer func() { + if originalConfigDir == "" { + os.Unsetenv("XDG_CONFIG_HOME") + } else { + os.Setenv("XDG_CONFIG_HOME", originalConfigDir) + } + }() + + config, err := Load() + if err != nil { + t.Errorf("Load() error = %v", err) + return + } + + // Should have migrated to backends + expectedBackends := []string{"wtype", "clipboard"} + if len(config.Injection.Backends) != len(expectedBackends) { + t.Errorf("Expected %d backends, got %d", len(expectedBackends), len(config.Injection.Backends)) + } + for i, b := range expectedBackends { + if i < len(config.Injection.Backends) && config.Injection.Backends[i] != b { + t.Errorf("Expected backend[%d]=%s, got %s", i, b, config.Injection.Backends[i]) + } + } + + // Should have set default ydotool timeout + if config.Injection.YdotoolTimeout != 5*time.Second { + t.Errorf("Expected YdotoolTimeout=5s, got %v", config.Injection.YdotoolTimeout) + } + + // Verify it passes validation + if err := config.Validate(); err != nil { + t.Errorf("Migrated config is invalid: %v", err) + } + }) + + t.Run("migrates legacy mode=clipboard to backends", func(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") + + err := os.MkdirAll(filepath.Dir(configPath), 0755) + if err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + legacyConfig := `[recording] +sample_rate = 16000 +channels = 1 +format = "s16" +buffer_size = 8192 +channel_buffer_size = 30 +timeout = "5m" + +[transcription] +provider = "openai" +api_key = "test-key" +model = "whisper-1" + +[injection] +mode = "clipboard" +wtype_timeout = "5s" +clipboard_timeout = "3s" + +[notifications] +enabled = true +type = "log"` + + err = os.WriteFile(configPath, []byte(legacyConfig), 0644) + if err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + + originalConfigDir := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tempDir) + defer func() { + if originalConfigDir == "" { + os.Unsetenv("XDG_CONFIG_HOME") + } else { + os.Setenv("XDG_CONFIG_HOME", originalConfigDir) + } + }() + + config, err := Load() + if err != nil { + t.Errorf("Load() error = %v", err) + return + } + + expectedBackends := []string{"clipboard"} + if len(config.Injection.Backends) != len(expectedBackends) { + t.Errorf("Expected %d backends, got %d", len(expectedBackends), len(config.Injection.Backends)) + } + + if err := config.Validate(); err != nil { + t.Errorf("Migrated config is invalid: %v", err) + } + }) + + t.Run("migrates legacy mode=type to backends", func(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "hyprvoice", "config.toml") + + err := os.MkdirAll(filepath.Dir(configPath), 0755) + if err != nil { + t.Fatalf("Failed to create config directory: %v", err) + } + + legacyConfig := `[recording] +sample_rate = 16000 +channels = 1 +format = "s16" +buffer_size = 8192 +channel_buffer_size = 30 +timeout = "5m" + +[transcription] +provider = "openai" +api_key = "test-key" +model = "whisper-1" + +[injection] +mode = "type" +wtype_timeout = "5s" +clipboard_timeout = "3s" + +[notifications] +enabled = true +type = "log"` + + err = os.WriteFile(configPath, []byte(legacyConfig), 0644) + if err != nil { + t.Fatalf("Failed to create config file: %v", err) + } + + originalConfigDir := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tempDir) + defer func() { + if originalConfigDir == "" { + os.Unsetenv("XDG_CONFIG_HOME") + } else { + os.Setenv("XDG_CONFIG_HOME", originalConfigDir) + } + }() + + config, err := Load() + if err != nil { + t.Errorf("Load() error = %v", err) + return + } + + expectedBackends := []string{"wtype"} + if len(config.Injection.Backends) != len(expectedBackends) { + t.Errorf("Expected %d backends, got %d", len(expectedBackends), len(config.Injection.Backends)) + } + + if err := config.Validate(); err != nil { + t.Errorf("Migrated config is invalid: %v", err) + } + }) } func TestConfig_SaveDefaultConfig(t *testing.T) { @@ -460,11 +659,11 @@ func TestConfig_ConversionMethods(t *testing.T) { t.Run("ToInjectionConfig", func(t *testing.T) { injectionConfig := config.ToInjectionConfig() - if injectionConfig.Mode != config.Injection.Mode { - t.Errorf("Mode mismatch: got %s, want %s", injectionConfig.Mode, config.Injection.Mode) + if len(injectionConfig.Backends) != len(config.Injection.Backends) { + t.Errorf("Backends length mismatch: got %d, want %d", len(injectionConfig.Backends), len(config.Injection.Backends)) } - if injectionConfig.RestoreClipboard != config.Injection.RestoreClipboard { - t.Errorf("RestoreClipboard mismatch: got %t, want %t", injectionConfig.RestoreClipboard, config.Injection.RestoreClipboard) + if injectionConfig.YdotoolTimeout != config.Injection.YdotoolTimeout { + t.Errorf("YdotoolTimeout mismatch: got %v, want %v", injectionConfig.YdotoolTimeout, config.Injection.YdotoolTimeout) } if injectionConfig.WtypeTimeout != config.Injection.WtypeTimeout { t.Errorf("WtypeTimeout mismatch: got %v, want %v", injectionConfig.WtypeTimeout, config.Injection.WtypeTimeout) @@ -630,7 +829,7 @@ func TestConfig_Validate_OpenAI_WithoutAPIKey(t *testing.T) { Model: "whisper-1", }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, @@ -670,7 +869,7 @@ func TestConfig_Validate_OpenAI_WithEnvVarAPIKey(t *testing.T) { Model: "whisper-1", }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, @@ -712,7 +911,7 @@ func TestConfig_Validate_RecordingTimeout(t *testing.T) { Model: "whisper-1", }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, @@ -743,7 +942,7 @@ func TestConfig_Validate_InjectionTimeouts(t *testing.T) { Model: "whisper-1", }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 0, // Invalid timeout ClipboardTimeout: 0, // Invalid timeout }, @@ -774,7 +973,7 @@ func TestConfig_Validate_RecordingBufferSizes(t *testing.T) { Model: "whisper-1", }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, @@ -806,7 +1005,7 @@ func TestConfig_Validate_GroqTranscription(t *testing.T) { Model: "whisper-large-v3", }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, @@ -838,7 +1037,7 @@ func TestConfig_Validate_GroqTranslation(t *testing.T) { Model: "whisper-large-v3", // Translation only supports non-turbo }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, @@ -870,7 +1069,7 @@ func TestConfig_Validate_GroqInvalidModel(t *testing.T) { Model: "invalid-model", }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, @@ -901,7 +1100,7 @@ func TestConfig_Validate_GroqWithoutAPIKey(t *testing.T) { Model: "whisper-large-v3", }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, @@ -941,7 +1140,7 @@ func TestConfig_Validate_GroqWithEnvVarAPIKey(t *testing.T) { Model: "whisper-large-v3", }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, @@ -1012,7 +1211,7 @@ func TestConfig_Validate_GroqTranslation_RejectsTurbo(t *testing.T) { Model: "whisper-large-v3-turbo", // Turbo not supported for translation }, Injection: InjectionConfig{ - Mode: "fallback", + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: time.Second, ClipboardTimeout: time.Second, }, diff --git a/internal/injection/backend.go b/internal/injection/backend.go new file mode 100644 index 0000000..2e17b9b --- /dev/null +++ b/internal/injection/backend.go @@ -0,0 +1,13 @@ +package injection + +import ( + "context" + "time" +) + +// Backend represents a text injection method +type Backend interface { + Name() string + Available() error + Inject(ctx context.Context, text string, timeout time.Duration) error +} diff --git a/internal/injection/clipboard.go b/internal/injection/clipboard.go index 1395fdc..4f5d6a0 100644 --- a/internal/injection/clipboard.go +++ b/internal/injection/clipboard.go @@ -9,43 +9,21 @@ import ( "time" ) -func getClipboard(ctx context.Context, timeout time.Duration) (string, error) { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() +type clipboardBackend struct{} - cmd := exec.CommandContext(ctx, "wl-paste", "--no-newline") - output, err := cmd.Output() - if err != nil { - return "", nil - } - - return string(output), nil +func NewClipboardBackend() Backend { + return &clipboardBackend{} } -func setClipboard(ctx context.Context, text string, timeout time.Duration) error { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - cmd := exec.CommandContext(ctx, "wl-copy") - cmd.Stdin = strings.NewReader(text) - - if err := cmd.Run(); err != nil { - return fmt.Errorf("wl-copy failed: %w", err) - } - - return nil +func (c *clipboardBackend) Name() string { + return "clipboard" } -func checkClipboardAvailable() error { +func (c *clipboardBackend) Available() error { if _, err := exec.LookPath("wl-copy"); err != nil { return fmt.Errorf("wl-copy not found: %w (install wl-clipboard)", err) } - if _, err := exec.LookPath("wl-paste"); err != nil { - return fmt.Errorf("wl-paste not found: %w (install wl-clipboard)", err) - } - - // Check for Wayland environment if os.Getenv("WAYLAND_DISPLAY") == "" { return fmt.Errorf("WAYLAND_DISPLAY not set - clipboard operations require Wayland session") } @@ -56,3 +34,21 @@ func checkClipboardAvailable() error { return nil } + +func (c *clipboardBackend) Inject(ctx context.Context, text string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + if err := c.Available(); err != nil { + return err + } + + cmd := exec.CommandContext(ctx, "wl-copy") + cmd.Stdin = strings.NewReader(text) + + if err := cmd.Run(); err != nil { + return fmt.Errorf("wl-copy failed: %w", err) + } + + return nil +} diff --git a/internal/injection/injection.go b/internal/injection/injection.go index 7fdefca..b0540ea 100644 --- a/internal/injection/injection.go +++ b/internal/injection/injection.go @@ -3,6 +3,7 @@ package injection import ( "context" "fmt" + "log" "time" ) @@ -11,19 +12,42 @@ type Injector interface { } type Config struct { - Mode string // "clipboard", "type", "fallback" - RestoreClipboard bool // Restore original clipboard after injection + Backends []string // Ordered list: "ydotool", "wtype", "clipboard" + YdotoolTimeout time.Duration // Timeout for ydotool commands WtypeTimeout time.Duration // Timeout for wtype commands ClipboardTimeout time.Duration // Timeout for clipboard operations } type injector struct { - config Config + config Config + backends []Backend } func NewInjector(config Config) Injector { + // Build backend chain from config + backends := make([]Backend, 0, len(config.Backends)) + for _, name := range config.Backends { + switch name { + case "ydotool": + backends = append(backends, NewYdotoolBackend()) + case "wtype": + backends = append(backends, NewWtypeBackend()) + case "clipboard": + backends = append(backends, NewClipboardBackend()) + default: + log.Printf("Injection: unknown backend %q, skipping", name) + } + } + + // Default to clipboard if no valid backends + if len(backends) == 0 { + log.Printf("Injection: no valid backends configured, defaulting to clipboard") + backends = append(backends, NewClipboardBackend()) + } + return &injector{ - config: config, + config: config, + backends: backends, } } @@ -32,58 +56,31 @@ func (i *injector) Inject(ctx context.Context, text string) error { return fmt.Errorf("cannot inject empty text") } - // Copy to clipboard for clipboard mode and fallback mode - var originalClipboard string - var err error - - if i.config.Mode == "clipboard" || i.config.Mode == "fallback" { - if err := checkClipboardAvailable(); err != nil { - return fmt.Errorf("clipboard tools not available: %w", err) - } - - if i.config.RestoreClipboard { - originalClipboard, _ = getClipboard(ctx, i.config.ClipboardTimeout) - } - - if err := setClipboard(ctx, text, i.config.ClipboardTimeout); err != nil { - return fmt.Errorf("failed to copy text to clipboard: %w", err) - } - } - - // Handle different injection modes - switch i.config.Mode { - case "clipboard": - // Already handled above - return nil - - case "type": - err = typeText(ctx, text, i.config.WtypeTimeout) - if err != nil { - return fmt.Errorf("failed to type text: %w", err) - } - - case "fallback": - // Try typing first, fallback to clipboard - err = typeText(ctx, text, i.config.WtypeTimeout) - if err != nil { - // Typing failed, but clipboard is already set from above - // Just log the typing error but don't fail the injection + // Try each backend in order + var lastErr error + for _, backend := range i.backends { + timeout := i.getTimeout(backend.Name()) + err := backend.Inject(ctx, text, timeout) + if err == nil { + log.Printf("Injection: success via %s", backend.Name()) return nil } - - default: - return fmt.Errorf("unsupported injection mode: %s", i.config.Mode) + log.Printf("Injection: %s failed: %v, trying next backend", backend.Name(), err) + lastErr = err } - // Restore original clipboard if configured and we have it - if i.config.RestoreClipboard && originalClipboard != "" { - go func() { - time.Sleep(100 * time.Millisecond) - restoreCtx, cancel := context.WithTimeout(ctx, i.config.ClipboardTimeout) - defer cancel() - setClipboard(restoreCtx, originalClipboard, i.config.ClipboardTimeout) - }() + return fmt.Errorf("all injection backends failed, last error: %w", lastErr) +} + +func (i *injector) getTimeout(backendName string) time.Duration { + switch backendName { + case "ydotool": + return i.config.YdotoolTimeout + case "wtype": + return i.config.WtypeTimeout + case "clipboard": + return i.config.ClipboardTimeout + default: + return 5 * time.Second } - - return nil } diff --git a/internal/injection/injection_test.go b/internal/injection/injection_test.go index babfadc..7303b0f 100644 --- a/internal/injection/injection_test.go +++ b/internal/injection/injection_test.go @@ -9,8 +9,8 @@ import ( func TestNewInjector(t *testing.T) { config := Config{ - Mode: "fallback", - RestoreClipboard: true, + Backends: []string{"wtype", "clipboard"}, + YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, } @@ -30,6 +30,41 @@ func TestNewInjector(t *testing.T) { } } +func TestNewInjector_DefaultsToClipboard(t *testing.T) { + config := Config{ + Backends: []string{}, + ClipboardTimeout: 3 * time.Second, + } + + injector := NewInjector(config) + if injector == nil { + t.Errorf("NewInjector() returned nil") + return + } + + // Should default to clipboard backend - just test it works + ctx := context.Background() + err := injector.Inject(ctx, "test") + // Will fail if no clipboard tools, but that's ok + if err != nil { + t.Logf("Injection failed (expected without tools): %v", err) + } +} + +func TestNewInjector_IgnoresUnknownBackends(t *testing.T) { + config := Config{ + Backends: []string{"unknown", "wtype", "invalid"}, + WtypeTimeout: 5 * time.Second, + ClipboardTimeout: 3 * time.Second, + } + + injector := NewInjector(config) + // Just verify it was created - we can't inspect internals + if injector == nil { + t.Errorf("NewInjector() returned nil") + } +} + func TestInjector_Inject(t *testing.T) { // Skip integration tests in CI environments if os.Getenv("CI") == "true" { @@ -43,32 +78,28 @@ func TestInjector_Inject(t *testing.T) { wantErr bool }{ { - name: "inject with clipboard mode", + name: "inject with clipboard backend", config: Config{ - Mode: "clipboard", - RestoreClipboard: false, - WtypeTimeout: 5 * time.Second, + Backends: []string{"clipboard"}, ClipboardTimeout: 3 * time.Second, }, text: "test text", wantErr: false, }, { - name: "inject with type mode", + name: "inject with wtype backend", config: Config{ - Mode: "type", - RestoreClipboard: false, - WtypeTimeout: 5 * time.Second, - ClipboardTimeout: 3 * time.Second, + Backends: []string{"wtype"}, + WtypeTimeout: 5 * time.Second, }, text: "test text", wantErr: false, }, { - name: "inject with fallback mode", + name: "inject with fallback chain", config: Config{ - Mode: "fallback", - RestoreClipboard: false, + Backends: []string{"ydotool", "wtype", "clipboard"}, + YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, @@ -78,25 +109,12 @@ func TestInjector_Inject(t *testing.T) { { name: "inject empty text", config: Config{ - Mode: "clipboard", - RestoreClipboard: false, - WtypeTimeout: 5 * time.Second, + Backends: []string{"clipboard"}, ClipboardTimeout: 3 * time.Second, }, text: "", wantErr: true, }, - { - name: "inject with invalid mode", - config: Config{ - Mode: "invalid", - RestoreClipboard: false, - WtypeTimeout: 5 * time.Second, - ClipboardTimeout: 3 * time.Second, - }, - text: "test text", - wantErr: true, - }, } for _, tt := range tests { @@ -114,18 +132,14 @@ func TestInjector_Inject(t *testing.T) { func TestConfig(t *testing.T) { config := Config{ - Mode: "fallback", - RestoreClipboard: true, + Backends: []string{"ydotool", "wtype", "clipboard"}, + YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, } - if config.Mode != "fallback" { - t.Errorf("Mode mismatch: got %s, want %s", config.Mode, "fallback") - } - - if !config.RestoreClipboard { - t.Errorf("RestoreClipboard should be true") + if len(config.Backends) != 3 { + t.Errorf("Backends length mismatch: got %d, want %d", len(config.Backends), 3) } if config.WtypeTimeout != 5*time.Second { @@ -137,123 +151,61 @@ func TestConfig(t *testing.T) { } } -// TestTypeText tests the typeText function -func TestTypeText(t *testing.T) { - // Skip integration tests in CI environments - if os.Getenv("CI") == "true" { - t.Skip("Skipping integration test in CI environment") +// TestWtypeBackend tests the wtype backend +func TestWtypeBackend(t *testing.T) { + backend := NewWtypeBackend() + + if backend.Name() != "wtype" { + t.Errorf("Name() = %s, want wtype", backend.Name()) } - tests := []struct { - name string - text string - wantErr bool - }{ - { - name: "type normal text", - text: "hello world", - wantErr: false, - }, - { - name: "type empty text", - text: "", - wantErr: false, - }, - { - name: "type text with special characters", - text: "hello\nworld\t!", - wantErr: false, - }, + err := backend.Available() + if err != nil { + t.Logf("wtype not available (expected): %v", err) + return } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - err := typeText(ctx, tt.text, 1*time.Second) - if (err != nil) != tt.wantErr { - t.Errorf("typeText() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } + t.Logf("wtype is available") } -// TestCheckWtypeAvailable tests the wtype availability check -func TestCheckWtypeAvailable(t *testing.T) { - err := checkWtypeAvailable() +// TestYdotoolBackend tests the ydotool backend +func TestYdotoolBackend(t *testing.T) { + backend := NewYdotoolBackend() + + if backend.Name() != "ydotool" { + t.Errorf("Name() = %s, want ydotool", backend.Name()) + } + + err := backend.Available() if err != nil { - t.Logf("checkWtypeAvailable() failed (expected if wtype not installed): %v", err) - // Don't fail the test if wtype is not available + t.Logf("ydotool not available (expected): %v", err) return } - t.Logf("checkWtypeAvailable() succeeded - wtype is available") + t.Logf("ydotool is available") } -// TestGetClipboard tests the clipboard get functionality -func TestGetClipboard(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() +// TestClipboardBackend tests the clipboard backend +func TestClipboardBackend(t *testing.T) { + backend := NewClipboardBackend() - // Test getting clipboard content - content, err := getClipboard(ctx, 1*time.Second) + if backend.Name() != "clipboard" { + t.Errorf("Name() = %s, want clipboard", backend.Name()) + } + + err := backend.Available() if err != nil { - t.Logf("getClipboard() failed (expected if wl-paste not available): %v", err) - // Don't fail the test if clipboard tools are not available + t.Logf("clipboard not available (expected): %v", err) return } - t.Logf("getClipboard() succeeded, content length: %d", len(content)) -} - -// TestSetClipboard tests the clipboard set functionality -func TestSetClipboard(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - testText := "test clipboard content" - - err := setClipboard(ctx, testText, 1*time.Second) - if err != nil { - t.Logf("setClipboard() failed (expected if wl-copy not available): %v", err) - // Don't fail the test if clipboard tools are not available - return - } - - t.Logf("setClipboard() succeeded") - - // Try to read it back - content, err := getClipboard(ctx, 1*time.Second) - if err != nil { - t.Logf("Failed to read back clipboard content: %v", err) - return - } - - if content != testText { - t.Logf("Clipboard content mismatch: got %q, want %q", content, testText) - // Don't fail - clipboard might have been modified by other processes - } -} - -// TestCheckClipboardAvailable tests the clipboard tools availability check -func TestCheckClipboardAvailable(t *testing.T) { - err := checkClipboardAvailable() - if err != nil { - t.Logf("checkClipboardAvailable() failed (expected if clipboard tools not installed): %v", err) - // Don't fail the test if clipboard tools are not available - return - } - - t.Logf("checkClipboardAvailable() succeeded - clipboard tools are available") + t.Logf("clipboard is available") } // TestInjector_ClipboardMode tests clipboard-only injection func TestInjector_ClipboardMode(t *testing.T) { config := Config{ - Mode: "clipboard", - RestoreClipboard: false, - WtypeTimeout: 5 * time.Second, + Backends: []string{"clipboard"}, ClipboardTimeout: 3 * time.Second, } @@ -264,20 +216,17 @@ func TestInjector_ClipboardMode(t *testing.T) { err := injector.Inject(ctx, "test clipboard text") if err != nil { t.Logf("Clipboard injection failed (expected if clipboard tools not available): %v", err) - // Don't fail the test if clipboard tools are not available return } t.Logf("Clipboard injection succeeded") } -// TestInjector_TypeMode tests typing-only injection -func TestInjector_TypeMode(t *testing.T) { +// TestInjector_WtypeMode tests wtype-only injection +func TestInjector_WtypeMode(t *testing.T) { config := Config{ - Mode: "type", - RestoreClipboard: false, - WtypeTimeout: 5 * time.Second, - ClipboardTimeout: 3 * time.Second, + Backends: []string{"wtype"}, + WtypeTimeout: 5 * time.Second, } injector := NewInjector(config) @@ -286,19 +235,18 @@ func TestInjector_TypeMode(t *testing.T) { err := injector.Inject(ctx, "test typing text") if err != nil { - t.Logf("Typing injection failed (expected if wtype not available): %v", err) - // Don't fail the test if wtype is not available + t.Logf("Wtype injection failed (expected if wtype not available): %v", err) return } - t.Logf("Typing injection succeeded") + t.Logf("Wtype injection succeeded") } -// TestInjector_FallbackMode tests fallback injection behavior -func TestInjector_FallbackMode(t *testing.T) { +// TestInjector_FallbackChain tests fallback chain injection +func TestInjector_FallbackChain(t *testing.T) { config := Config{ - Mode: "fallback", - RestoreClipboard: false, + Backends: []string{"ydotool", "wtype", "clipboard"}, + YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, } @@ -309,8 +257,7 @@ func TestInjector_FallbackMode(t *testing.T) { err := injector.Inject(ctx, "test fallback text") if err != nil { - t.Logf("Fallback injection failed (expected if both wtype and clipboard tools not available): %v", err) - // Don't fail the test if tools are not available + t.Logf("Fallback injection failed (expected if all tools not available): %v", err) return } @@ -320,9 +267,7 @@ func TestInjector_FallbackMode(t *testing.T) { // TestInjector_EmptyText tests injection of empty text func TestInjector_EmptyText(t *testing.T) { config := Config{ - Mode: "clipboard", - RestoreClipboard: false, - WtypeTimeout: 5 * time.Second, + Backends: []string{"clipboard"}, ClipboardTimeout: 3 * time.Second, } @@ -339,27 +284,3 @@ func TestInjector_EmptyText(t *testing.T) { t.Errorf("Inject() error message = %q, want %q", err.Error(), "cannot inject empty text") } } - -// TestInjector_InvalidMode tests injection with invalid mode -func TestInjector_InvalidMode(t *testing.T) { - config := Config{ - Mode: "invalid", - RestoreClipboard: false, - WtypeTimeout: 5 * time.Second, - ClipboardTimeout: 3 * time.Second, - } - - injector := NewInjector(config) - ctx := context.Background() - - err := injector.Inject(ctx, "test text") - if err == nil { - t.Errorf("Inject() should fail with invalid mode") - return - } - - expectedError := "unsupported injection mode: invalid" - if err.Error() != expectedError { - t.Errorf("Inject() error message = %q, want %q", err.Error(), expectedError) - } -} diff --git a/internal/injection/typing.go b/internal/injection/wtype.go similarity index 63% rename from internal/injection/typing.go rename to internal/injection/wtype.go index 399409a..cac8d26 100644 --- a/internal/injection/typing.go +++ b/internal/injection/wtype.go @@ -8,29 +8,21 @@ import ( "time" ) -func typeText(ctx context.Context, text string, timeout time.Duration) error { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() +type wtypeBackend struct{} - if err := checkWtypeAvailable(); err != nil { - return err - } - - cmd := exec.CommandContext(ctx, "wtype", text) - - if err := cmd.Run(); err != nil { - return fmt.Errorf("wtype failed: %w", err) - } - - return nil +func NewWtypeBackend() Backend { + return &wtypeBackend{} } -func checkWtypeAvailable() error { +func (w *wtypeBackend) Name() string { + return "wtype" +} + +func (w *wtypeBackend) Available() error { if _, err := exec.LookPath("wtype"); err != nil { return fmt.Errorf("wtype not found: %w (install wtype package)", err) } - // Check for Wayland environment if os.Getenv("WAYLAND_DISPLAY") == "" { return fmt.Errorf("WAYLAND_DISPLAY not set - wtype requires Wayland session") } @@ -41,3 +33,19 @@ func checkWtypeAvailable() error { return nil } + +func (w *wtypeBackend) Inject(ctx context.Context, text string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + if err := w.Available(); err != nil { + return err + } + + cmd := exec.CommandContext(ctx, "wtype", "--", text) + if err := cmd.Run(); err != nil { + return fmt.Errorf("wtype failed: %w", err) + } + + return nil +} diff --git a/internal/injection/ydotool.go b/internal/injection/ydotool.go new file mode 100644 index 0000000..0398457 --- /dev/null +++ b/internal/injection/ydotool.go @@ -0,0 +1,87 @@ +package injection + +import ( + "context" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "time" +) + +type ydotoolBackend struct{} + +func NewYdotoolBackend() Backend { + return &ydotoolBackend{} +} + +func (y *ydotoolBackend) Name() string { + return "ydotool" +} + +func (y *ydotoolBackend) Available() error { + if _, err := exec.LookPath("ydotool"); err != nil { + return fmt.Errorf("ydotool not found: %w (install ydotool package)", err) + } + + // Check if ydotoold is running by checking socket + socketPath := y.getSocketPath() + if socketPath == "" { + return fmt.Errorf("ydotoold socket not found - ensure ydotoold is running") + } + + // Try to connect to verify daemon is responsive + conn, err := net.DialTimeout("unix", socketPath, 500*time.Millisecond) + if err != nil { + return fmt.Errorf("ydotoold not responding at %s: %w", socketPath, err) + } + conn.Close() + + return nil +} + +func (y *ydotoolBackend) getSocketPath() string { + // Check YDOTOOL_SOCKET env var first + if sock := os.Getenv("YDOTOOL_SOCKET"); sock != "" { + if _, err := os.Stat(sock); err == nil { + return sock + } + } + + // Check common locations + paths := []string{ + "/run/user/" + fmt.Sprint(os.Getuid()) + "/.ydotool_socket", + "/tmp/.ydotool_socket", + } + + // Also check XDG_RUNTIME_DIR + if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" { + paths = append([]string{filepath.Join(xdg, ".ydotool_socket")}, paths...) + } + + for _, p := range paths { + if _, err := os.Stat(p); err == nil { + return p + } + } + + return "" +} + +func (y *ydotoolBackend) Inject(ctx context.Context, text string, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + if err := y.Available(); err != nil { + return err + } + + // ydotool type -- "text" + cmd := exec.CommandContext(ctx, "ydotool", "type", "--", text) + if err := cmd.Run(); err != nil { + return fmt.Errorf("ydotool failed: %w", err) + } + + return nil +} diff --git a/internal/pipeline/pipeline_test.go b/internal/pipeline/pipeline_test.go index 33732d0..51da06a 100644 --- a/internal/pipeline/pipeline_test.go +++ b/internal/pipeline/pipeline_test.go @@ -25,8 +25,7 @@ func TestNew(t *testing.T) { Model: "whisper-1", }, Injection: config.InjectionConfig{ - Mode: "fallback", - RestoreClipboard: true, + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, @@ -64,8 +63,7 @@ func TestPipeline_Status(t *testing.T) { Model: "whisper-1", }, Injection: config.InjectionConfig{ - Mode: "fallback", - RestoreClipboard: true, + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, @@ -111,8 +109,7 @@ func TestPipeline_GetActionCh(t *testing.T) { Model: "whisper-1", }, Injection: config.InjectionConfig{ - Mode: "fallback", - RestoreClipboard: true, + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, @@ -156,8 +153,7 @@ func TestPipeline_GetErrorCh(t *testing.T) { Model: "whisper-1", }, Injection: config.InjectionConfig{ - Mode: "fallback", - RestoreClipboard: true, + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, @@ -201,8 +197,7 @@ func TestPipeline_Stop(t *testing.T) { Model: "whisper-1", }, Injection: config.InjectionConfig{ - Mode: "fallback", - RestoreClipboard: true, + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, @@ -239,8 +234,7 @@ func TestPipeline_Run(t *testing.T) { Model: "whisper-1", }, Injection: config.InjectionConfig{ - Mode: "fallback", - RestoreClipboard: true, + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, @@ -350,8 +344,7 @@ func TestPipeline_ConcurrentAccess(t *testing.T) { Model: "whisper-1", }, Injection: config.InjectionConfig{ - Mode: "fallback", - RestoreClipboard: true, + Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 56b7995..b9a4ae7 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -31,8 +31,8 @@ func TestConfig() *config.Config { Model: "whisper-1", }, Injection: config.InjectionConfig{ - Mode: "fallback", - RestoreClipboard: true, + Backends: []string{"ydotool", "wtype", "clipboard"}, + YdotoolTimeout: 5 * time.Second, WtypeTimeout: 5 * time.Second, ClipboardTimeout: 3 * time.Second, }, @@ -60,9 +60,10 @@ func TestConfigWithInvalidValues() *config.Config { Model: "", // Invalid }, Injection: config.InjectionConfig{ - Mode: "invalid", // Invalid - WtypeTimeout: 0, // Invalid - ClipboardTimeout: 0, // Invalid + Backends: []string{}, // Invalid (empty) + YdotoolTimeout: 0, // Invalid + WtypeTimeout: 0, // Invalid + ClipboardTimeout: 0, // Invalid }, Notifications: config.NotificationsConfig{ Type: "invalid", // Invalid diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 3c767d2..c5a39eb 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -18,6 +18,7 @@ depends=( optdepends=( 'hyprland: For Hyprland window manager integration' 'sway: For Sway window manager integration' + 'ydotool: Alternative text injection backend (recommended for Chromium apps)' ) provides=('hyprvoice') conflicts=('hyprvoice')