new injection strategies
This commit is contained in:
@@ -68,7 +68,8 @@ export PATH="$HOME/.local/bin:$PATH"
|
|||||||
|
|
||||||
- `pipewire`, `pipewire-pulse`, `pipewire-audio` - Audio capture
|
- `pipewire`, `pipewire-pulse`, `pipewire-audio` - Audio capture
|
||||||
- `wl-clipboard` - Clipboard integration
|
- `wl-clipboard` - Clipboard integration
|
||||||
- `wtype` - Text typing
|
- `wtype` - Text typing (Wayland)
|
||||||
|
- `ydotool` - Text typing (universal, recommended for Chromium apps)
|
||||||
- `libnotify` - Desktop notifications
|
- `libnotify` - Desktop notifications
|
||||||
- `systemd` - User service management
|
- `systemd` - User service management
|
||||||
|
|
||||||
@@ -76,10 +77,15 @@ For manual installation on other distros:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ubuntu/Debian
|
# 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
|
# 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
|
## Quick Start
|
||||||
@@ -297,9 +303,9 @@ The daemon automatically creates `~/.config/hyprvoice/config.toml` with helpful
|
|||||||
|
|
||||||
# Text Injection Configuration
|
# Text Injection Configuration
|
||||||
[injection]
|
[injection]
|
||||||
mode = "fallback" # Injection method ("clipboard", "type", "fallback")
|
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain
|
||||||
restore_clipboard = true # Restore original clipboard after injection
|
ydotool_timeout = "5s" # Timeout for ydotool commands
|
||||||
wtype_timeout = "5s" # Timeout for direct typing via wtype
|
wtype_timeout = "5s" # Timeout for wtype commands
|
||||||
clipboard_timeout = "3s" # Timeout for clipboard operations
|
clipboard_timeout = "3s" # Timeout for clipboard operations
|
||||||
|
|
||||||
# Desktop Notification Configuration
|
# Desktop Notification Configuration
|
||||||
@@ -343,26 +349,62 @@ timeout = "5m" # Maximum recording duration (prevents runaway record
|
|||||||
|
|
||||||
#### Text Injection
|
#### Text Injection
|
||||||
|
|
||||||
Configurable text injection with multiple modes:
|
Configurable text injection with multiple backends:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[injection]
|
[injection]
|
||||||
mode = "fallback" # "clipboard", "type", or "fallback"
|
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain
|
||||||
restore_clipboard = true
|
ydotool_timeout = "5s"
|
||||||
wtype_timeout = "5s"
|
wtype_timeout = "5s"
|
||||||
clipboard_timeout = "3s"
|
clipboard_timeout = "3s"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Injection Modes:**
|
**Injection Backends:**
|
||||||
|
|
||||||
- **`fallback`** (default): Try direct typing first, fallback to clipboard
|
- **`ydotool`**: Uses ydotool (requires `ydotoold` daemon). Most compatible with Chromium/Electron apps.
|
||||||
- **`type`**: Direct typing using wtype only
|
- **`wtype`**: Uses wtype for Wayland. May have issues with some Chromium-based apps (known upstream bug).
|
||||||
- **`clipboard`**: Copy to clipboard only
|
- **`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:**
|
**Behavior:**
|
||||||
|
|
||||||
- `restore_clipboard = true`: Save and restore original clipboard content
|
- Backends are tried in order until one succeeds
|
||||||
- Smart fallback ensures text injection always succeeds when possible
|
- Include `clipboard` in the chain if you want text copied to clipboard as fallback
|
||||||
|
|
||||||
#### Notifications
|
#### Notifications
|
||||||
|
|
||||||
|
|||||||
+72
-28
@@ -179,7 +179,8 @@ func runInteractiveConfig() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Model selection based on provider
|
// Model selection based on provider
|
||||||
if cfg.Transcription.Provider == "openai" {
|
switch cfg.Transcription.Provider {
|
||||||
|
case "openai":
|
||||||
fmt.Println("\nOpenAI Model:")
|
fmt.Println("\nOpenAI Model:")
|
||||||
fmt.Printf("Model (current: %s): ", cfg.Transcription.Model)
|
fmt.Printf("Model (current: %s): ", cfg.Transcription.Model)
|
||||||
if scanner.Scan() {
|
if scanner.Scan() {
|
||||||
@@ -190,7 +191,7 @@ func runInteractiveConfig() error {
|
|||||||
cfg.Transcription.Model = "whisper-1"
|
cfg.Transcription.Model = "whisper-1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if cfg.Transcription.Provider == "groq-transcription" {
|
case "groq-transcription":
|
||||||
fmt.Println("\nGroq Transcription Model:")
|
fmt.Println("\nGroq Transcription Model:")
|
||||||
fmt.Println(" 1. whisper-large-v3 - Standard model")
|
fmt.Println(" 1. whisper-large-v3 - Standard model")
|
||||||
fmt.Println(" 2. whisper-large-v3-turbo - Faster 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("\nGroq Translation Model:")
|
||||||
fmt.Println(" Note: Translation only supports whisper-large-v3 (turbo not available)")
|
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)
|
fmt.Printf("Model (current: %s, press Enter for whisper-large-v3): ", cfg.Transcription.Model)
|
||||||
@@ -257,21 +258,40 @@ func runInteractiveConfig() error {
|
|||||||
// Configure injection
|
// Configure injection
|
||||||
fmt.Println("⌨️ Text Injection Configuration")
|
fmt.Println("⌨️ Text Injection Configuration")
|
||||||
fmt.Println("--------------------------------")
|
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() {
|
if scanner.Scan() {
|
||||||
input := strings.TrimSpace(scanner.Text())
|
input := strings.TrimSpace(scanner.Text())
|
||||||
if input != "" && (input == "clipboard" || input == "type" || input == "fallback") {
|
if input != "" {
|
||||||
cfg.Injection.Mode = 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)
|
// Check if ydotool is selected and warn about daemon requirement
|
||||||
if scanner.Scan() {
|
for _, b := range cfg.Injection.Backends {
|
||||||
switch strings.TrimSpace(strings.ToLower(scanner.Text())) {
|
if b == "ydotool" {
|
||||||
case "y", "yes":
|
fmt.Println()
|
||||||
cfg.Injection.RestoreClipboard = true
|
fmt.Println("⚠️ ydotool requires the ydotoold daemon to be running!")
|
||||||
case "n", "no":
|
fmt.Println(" Start it with: systemctl --user enable --now ydotool")
|
||||||
cfg.Injection.RestoreClipboard = false
|
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
|
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
|
// Show next steps
|
||||||
fmt.Println("🚀 Next Steps:")
|
fmt.Println("🚀 Next Steps:")
|
||||||
if !serviceRunning {
|
step := 1
|
||||||
fmt.Println("1. Start the service: systemctl --user start hyprvoice.service")
|
if hasYdotool {
|
||||||
fmt.Println("2. Test voice input: hyprvoice toggle")
|
fmt.Printf("%d. Ensure ydotoold is running: systemctl --user enable --now ydotool\n", step)
|
||||||
} else {
|
step++
|
||||||
fmt.Println("1. Restart the service to apply changes: systemctl --user restart hyprvoice.service")
|
|
||||||
fmt.Println("2. Test voice input: hyprvoice toggle")
|
|
||||||
}
|
}
|
||||||
|
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()
|
fmt.Println()
|
||||||
|
|
||||||
configPath, _ := config.GetConfigPath()
|
configPath, _ := config.GetConfigPath()
|
||||||
@@ -346,6 +380,14 @@ func runInteractiveConfig() error {
|
|||||||
return nil
|
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 {
|
func maskAPIKey(key string) string {
|
||||||
if key == "" {
|
if key == "" {
|
||||||
return "<not set>"
|
return "<not set>"
|
||||||
@@ -391,9 +433,9 @@ func saveConfig(cfg *config.Config) error {
|
|||||||
|
|
||||||
# Text Injection Configuration
|
# Text Injection Configuration
|
||||||
[injection]
|
[injection]
|
||||||
mode = "%s" # Injection method ("clipboard", "type", "fallback")
|
backends = [%s] # Ordered fallback chain (tries each until one succeeds)
|
||||||
restore_clipboard = %v # Restore original clipboard after injection
|
ydotool_timeout = "%s" # Timeout for ydotool commands
|
||||||
wtype_timeout = "%s" # Timeout for direct typing via wtype
|
wtype_timeout = "%s" # Timeout for wtype commands
|
||||||
clipboard_timeout = "%s" # Timeout for clipboard operations
|
clipboard_timeout = "%s" # Timeout for clipboard operations
|
||||||
|
|
||||||
# Desktop Notification Configuration
|
# Desktop Notification Configuration
|
||||||
@@ -401,10 +443,12 @@ func saveConfig(cfg *config.Config) error {
|
|||||||
enabled = %v # Enable desktop notifications
|
enabled = %v # Enable desktop notifications
|
||||||
type = "%s" # Notification type ("desktop", "log", "none")
|
type = "%s" # Notification type ("desktop", "log", "none")
|
||||||
|
|
||||||
# Mode explanations:
|
# Backend explanations:
|
||||||
# - "clipboard": Copy text to clipboard only
|
# - "ydotool": Uses ydotool (requires ydotoold daemon running). Most compatible with Chromium/Electron apps.
|
||||||
# - "type": Direct typing via wtype only
|
# - "wtype": Uses wtype for Wayland. May have issues with some Chromium-based apps.
|
||||||
# - "fallback": Try typing first, fallback to clipboard if it fails
|
# - "clipboard": Copies text to clipboard only (most reliable, but requires manual paste).
|
||||||
|
#
|
||||||
|
# The backends are tried in order. First successful one wins.
|
||||||
#
|
#
|
||||||
# Provider explanations:
|
# Provider explanations:
|
||||||
# - "openai": OpenAI Whisper API (cloud-based, requires OPENAI_API_KEY)
|
# - "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.APIKey,
|
||||||
cfg.Transcription.Language,
|
cfg.Transcription.Language,
|
||||||
cfg.Transcription.Model,
|
cfg.Transcription.Model,
|
||||||
cfg.Injection.Mode,
|
formatBackends(cfg.Injection.Backends),
|
||||||
cfg.Injection.RestoreClipboard,
|
cfg.Injection.YdotoolTimeout,
|
||||||
cfg.Injection.WtypeTimeout,
|
cfg.Injection.WtypeTimeout,
|
||||||
cfg.Injection.ClipboardTimeout,
|
cfg.Injection.ClipboardTimeout,
|
||||||
cfg.Notifications.Enabled,
|
cfg.Notifications.Enabled,
|
||||||
|
|||||||
+72
-14
@@ -38,8 +38,8 @@ type TranscriptionConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type InjectionConfig struct {
|
type InjectionConfig struct {
|
||||||
Mode string `toml:"mode"`
|
Backends []string `toml:"backends"`
|
||||||
RestoreClipboard bool `toml:"restore_clipboard"`
|
YdotoolTimeout time.Duration `toml:"ydotool_timeout"`
|
||||||
WtypeTimeout time.Duration `toml:"wtype_timeout"`
|
WtypeTimeout time.Duration `toml:"wtype_timeout"`
|
||||||
ClipboardTimeout time.Duration `toml:"clipboard_timeout"`
|
ClipboardTimeout time.Duration `toml:"clipboard_timeout"`
|
||||||
}
|
}
|
||||||
@@ -84,8 +84,8 @@ func (c *Config) ToTranscriberConfig() transcriber.Config {
|
|||||||
|
|
||||||
func (c *Config) ToInjectionConfig() injection.Config {
|
func (c *Config) ToInjectionConfig() injection.Config {
|
||||||
return injection.Config{
|
return injection.Config{
|
||||||
Mode: c.Injection.Mode,
|
Backends: c.Injection.Backends,
|
||||||
RestoreClipboard: c.Injection.RestoreClipboard,
|
YdotoolTimeout: c.Injection.YdotoolTimeout,
|
||||||
WtypeTimeout: c.Injection.WtypeTimeout,
|
WtypeTimeout: c.Injection.WtypeTimeout,
|
||||||
ClipboardTimeout: c.Injection.ClipboardTimeout,
|
ClipboardTimeout: c.Injection.ClipboardTimeout,
|
||||||
}
|
}
|
||||||
@@ -181,9 +181,17 @@ func (c *Config) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Injection
|
// Injection
|
||||||
validModes := map[string]bool{"clipboard": true, "type": true, "fallback": true}
|
if len(c.Injection.Backends) == 0 {
|
||||||
if !validModes[c.Injection.Mode] {
|
return fmt.Errorf("invalid injection.backends: empty (must have at least one backend)")
|
||||||
return fmt.Errorf("invalid injection.mode: %s (must be clipboard, type, or fallback)", c.Injection.Mode)
|
}
|
||||||
|
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 {
|
if c.Injection.WtypeTimeout <= 0 {
|
||||||
return fmt.Errorf("invalid injection.wtype_timeout: %v", c.Injection.WtypeTimeout)
|
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
|
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) {
|
func Load() (*Config, error) {
|
||||||
configPath, err := GetConfigPath()
|
configPath, err := GetConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -257,10 +274,45 @@ func Load() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("failed to parse config file %s: %w", configPath, err)
|
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")
|
log.Printf("Config: configuration loaded successfully")
|
||||||
return &config, nil
|
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 {
|
func SaveDefaultConfig() error {
|
||||||
configPath, err := GetConfigPath()
|
configPath, err := GetConfigPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -296,9 +348,9 @@ func SaveDefaultConfig() error {
|
|||||||
|
|
||||||
# Text Injection Configuration
|
# Text Injection Configuration
|
||||||
[injection]
|
[injection]
|
||||||
mode = "fallback" # Injection method ("clipboard", "type", "fallback")
|
backends = ["ydotool", "wtype", "clipboard"] # Ordered fallback chain (tries each until one succeeds)
|
||||||
restore_clipboard = true # Restore original clipboard after injection
|
ydotool_timeout = "5s" # Timeout for ydotool commands
|
||||||
wtype_timeout = "5s" # Timeout for direct typing via wtype
|
wtype_timeout = "5s" # Timeout for wtype commands
|
||||||
clipboard_timeout = "3s" # Timeout for clipboard operations
|
clipboard_timeout = "3s" # Timeout for clipboard operations
|
||||||
|
|
||||||
# Desktop Notification Configuration
|
# Desktop Notification Configuration
|
||||||
@@ -306,10 +358,16 @@ func SaveDefaultConfig() error {
|
|||||||
enabled = true # Enable desktop notifications
|
enabled = true # Enable desktop notifications
|
||||||
type = "desktop" # Notification type ("desktop", "log", "none")
|
type = "desktop" # Notification type ("desktop", "log", "none")
|
||||||
|
|
||||||
# Mode explanations:
|
# Backend explanations:
|
||||||
# - "clipboard": Copy text to clipboard only
|
# - "ydotool": Uses ydotool (requires ydotoold daemon running). Most compatible with Chromium/Electron apps.
|
||||||
# - "type": Direct typing via wtype only
|
# - "wtype": Uses wtype for Wayland. May have issues with some Chromium-based apps.
|
||||||
# - "fallback": Try typing first, fallback to clipboard if it fails
|
# - "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:
|
# Provider explanations:
|
||||||
# - "openai": OpenAI Whisper API (cloud-based, requires OPENAI_API_KEY)
|
# - "openai": OpenAI Whisper API (cloud-based, requires OPENAI_API_KEY)
|
||||||
|
|||||||
+224
-25
@@ -26,8 +26,7 @@ func createTestConfig() *Config {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
RestoreClipboard: true,
|
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -55,7 +54,7 @@ func createTestConfigWithInvalidValues() *Config {
|
|||||||
Model: "", // Invalid
|
Model: "", // Invalid
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "invalid", // Invalid
|
Backends: []string{"invalid"}, YdotoolTimeout: 5 * time.Second, // Invalid
|
||||||
WtypeTimeout: 0, // Invalid
|
WtypeTimeout: 0, // Invalid
|
||||||
ClipboardTimeout: 0, // Invalid
|
ClipboardTimeout: 0, // Invalid
|
||||||
},
|
},
|
||||||
@@ -98,7 +97,7 @@ func TestConfig_Validate(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: time.Second,
|
ClipboardTimeout: time.Second,
|
||||||
},
|
},
|
||||||
@@ -125,7 +124,7 @@ func TestConfig_Validate(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: time.Second,
|
ClipboardTimeout: time.Second,
|
||||||
},
|
},
|
||||||
@@ -152,7 +151,7 @@ func TestConfig_Validate(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "invalid",
|
Backends: []string{"invalid"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: time.Second,
|
ClipboardTimeout: time.Second,
|
||||||
},
|
},
|
||||||
@@ -179,7 +178,7 @@ func TestConfig_Validate(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: time.Second,
|
ClipboardTimeout: time.Second,
|
||||||
},
|
},
|
||||||
@@ -207,7 +206,7 @@ func TestConfig_Validate(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: time.Second,
|
ClipboardTimeout: time.Second,
|
||||||
},
|
},
|
||||||
@@ -235,7 +234,7 @@ func TestConfig_Validate(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: time.Second,
|
ClipboardTimeout: time.Second,
|
||||||
},
|
},
|
||||||
@@ -321,7 +320,8 @@ api_key = "test-key"
|
|||||||
model = "whisper-1"
|
model = "whisper-1"
|
||||||
|
|
||||||
[injection]
|
[injection]
|
||||||
mode = "fallback"
|
backends = ["ydotool", "wtype", "clipboard"]
|
||||||
|
ydotool_timeout = "5s"
|
||||||
wtype_timeout = "5s"
|
wtype_timeout = "5s"
|
||||||
clipboard_timeout = "3s"
|
clipboard_timeout = "3s"
|
||||||
|
|
||||||
@@ -363,6 +363,205 @@ type = "log"`
|
|||||||
t.Errorf("Expected Provider 'openai', got %s", config.Transcription.Provider)
|
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) {
|
func TestConfig_SaveDefaultConfig(t *testing.T) {
|
||||||
@@ -460,11 +659,11 @@ func TestConfig_ConversionMethods(t *testing.T) {
|
|||||||
t.Run("ToInjectionConfig", func(t *testing.T) {
|
t.Run("ToInjectionConfig", func(t *testing.T) {
|
||||||
injectionConfig := config.ToInjectionConfig()
|
injectionConfig := config.ToInjectionConfig()
|
||||||
|
|
||||||
if injectionConfig.Mode != config.Injection.Mode {
|
if len(injectionConfig.Backends) != len(config.Injection.Backends) {
|
||||||
t.Errorf("Mode mismatch: got %s, want %s", injectionConfig.Mode, config.Injection.Mode)
|
t.Errorf("Backends length mismatch: got %d, want %d", len(injectionConfig.Backends), len(config.Injection.Backends))
|
||||||
}
|
}
|
||||||
if injectionConfig.RestoreClipboard != config.Injection.RestoreClipboard {
|
if injectionConfig.YdotoolTimeout != config.Injection.YdotoolTimeout {
|
||||||
t.Errorf("RestoreClipboard mismatch: got %t, want %t", injectionConfig.RestoreClipboard, config.Injection.RestoreClipboard)
|
t.Errorf("YdotoolTimeout mismatch: got %v, want %v", injectionConfig.YdotoolTimeout, config.Injection.YdotoolTimeout)
|
||||||
}
|
}
|
||||||
if injectionConfig.WtypeTimeout != config.Injection.WtypeTimeout {
|
if injectionConfig.WtypeTimeout != config.Injection.WtypeTimeout {
|
||||||
t.Errorf("WtypeTimeout mismatch: got %v, want %v", 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",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: time.Second,
|
ClipboardTimeout: time.Second,
|
||||||
},
|
},
|
||||||
@@ -670,7 +869,7 @@ func TestConfig_Validate_OpenAI_WithEnvVarAPIKey(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: time.Second,
|
ClipboardTimeout: time.Second,
|
||||||
},
|
},
|
||||||
@@ -712,7 +911,7 @@ func TestConfig_Validate_RecordingTimeout(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: time.Second,
|
ClipboardTimeout: time.Second,
|
||||||
},
|
},
|
||||||
@@ -743,7 +942,7 @@ func TestConfig_Validate_InjectionTimeouts(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: 0, // Invalid timeout
|
WtypeTimeout: 0, // Invalid timeout
|
||||||
ClipboardTimeout: 0, // Invalid timeout
|
ClipboardTimeout: 0, // Invalid timeout
|
||||||
},
|
},
|
||||||
@@ -774,7 +973,7 @@ func TestConfig_Validate_RecordingBufferSizes(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: time.Second,
|
ClipboardTimeout: time.Second,
|
||||||
},
|
},
|
||||||
@@ -806,7 +1005,7 @@ func TestConfig_Validate_GroqTranscription(t *testing.T) {
|
|||||||
Model: "whisper-large-v3",
|
Model: "whisper-large-v3",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: 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
|
Model: "whisper-large-v3", // Translation only supports non-turbo
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: time.Second,
|
ClipboardTimeout: time.Second,
|
||||||
},
|
},
|
||||||
@@ -870,7 +1069,7 @@ func TestConfig_Validate_GroqInvalidModel(t *testing.T) {
|
|||||||
Model: "invalid-model",
|
Model: "invalid-model",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: time.Second,
|
ClipboardTimeout: time.Second,
|
||||||
},
|
},
|
||||||
@@ -901,7 +1100,7 @@ func TestConfig_Validate_GroqWithoutAPIKey(t *testing.T) {
|
|||||||
Model: "whisper-large-v3",
|
Model: "whisper-large-v3",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: time.Second,
|
ClipboardTimeout: time.Second,
|
||||||
},
|
},
|
||||||
@@ -941,7 +1140,7 @@ func TestConfig_Validate_GroqWithEnvVarAPIKey(t *testing.T) {
|
|||||||
Model: "whisper-large-v3",
|
Model: "whisper-large-v3",
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: 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
|
Model: "whisper-large-v3-turbo", // Turbo not supported for translation
|
||||||
},
|
},
|
||||||
Injection: InjectionConfig{
|
Injection: InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: time.Second,
|
WtypeTimeout: time.Second,
|
||||||
ClipboardTimeout: time.Second,
|
ClipboardTimeout: time.Second,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -9,43 +9,21 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getClipboard(ctx context.Context, timeout time.Duration) (string, error) {
|
type clipboardBackend struct{}
|
||||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "wl-paste", "--no-newline")
|
func NewClipboardBackend() Backend {
|
||||||
output, err := cmd.Output()
|
return &clipboardBackend{}
|
||||||
if err != nil {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(output), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setClipboard(ctx context.Context, text string, timeout time.Duration) error {
|
func (c *clipboardBackend) Name() string {
|
||||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
return "clipboard"
|
||||||
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 checkClipboardAvailable() error {
|
func (c *clipboardBackend) Available() error {
|
||||||
if _, err := exec.LookPath("wl-copy"); err != nil {
|
if _, err := exec.LookPath("wl-copy"); err != nil {
|
||||||
return fmt.Errorf("wl-copy not found: %w (install wl-clipboard)", err)
|
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") == "" {
|
if os.Getenv("WAYLAND_DISPLAY") == "" {
|
||||||
return fmt.Errorf("WAYLAND_DISPLAY not set - clipboard operations require Wayland session")
|
return fmt.Errorf("WAYLAND_DISPLAY not set - clipboard operations require Wayland session")
|
||||||
}
|
}
|
||||||
@@ -56,3 +34,21 @@ func checkClipboardAvailable() error {
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package injection
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,19 +12,42 @@ type Injector interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Mode string // "clipboard", "type", "fallback"
|
Backends []string // Ordered list: "ydotool", "wtype", "clipboard"
|
||||||
RestoreClipboard bool // Restore original clipboard after injection
|
YdotoolTimeout time.Duration // Timeout for ydotool commands
|
||||||
WtypeTimeout time.Duration // Timeout for wtype commands
|
WtypeTimeout time.Duration // Timeout for wtype commands
|
||||||
ClipboardTimeout time.Duration // Timeout for clipboard operations
|
ClipboardTimeout time.Duration // Timeout for clipboard operations
|
||||||
}
|
}
|
||||||
|
|
||||||
type injector struct {
|
type injector struct {
|
||||||
config Config
|
config Config
|
||||||
|
backends []Backend
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewInjector(config Config) Injector {
|
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{
|
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")
|
return fmt.Errorf("cannot inject empty text")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy to clipboard for clipboard mode and fallback mode
|
// Try each backend in order
|
||||||
var originalClipboard string
|
var lastErr error
|
||||||
var err error
|
for _, backend := range i.backends {
|
||||||
|
timeout := i.getTimeout(backend.Name())
|
||||||
if i.config.Mode == "clipboard" || i.config.Mode == "fallback" {
|
err := backend.Inject(ctx, text, timeout)
|
||||||
if err := checkClipboardAvailable(); err != nil {
|
if err == nil {
|
||||||
return fmt.Errorf("clipboard tools not available: %w", err)
|
log.Printf("Injection: success via %s", backend.Name())
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
log.Printf("Injection: %s failed: %v, trying next backend", backend.Name(), err)
|
||||||
default:
|
lastErr = err
|
||||||
return fmt.Errorf("unsupported injection mode: %s", i.config.Mode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore original clipboard if configured and we have it
|
return fmt.Errorf("all injection backends failed, last error: %w", lastErr)
|
||||||
if i.config.RestoreClipboard && originalClipboard != "" {
|
}
|
||||||
go func() {
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
func (i *injector) getTimeout(backendName string) time.Duration {
|
||||||
restoreCtx, cancel := context.WithTimeout(ctx, i.config.ClipboardTimeout)
|
switch backendName {
|
||||||
defer cancel()
|
case "ydotool":
|
||||||
setClipboard(restoreCtx, originalClipboard, i.config.ClipboardTimeout)
|
return i.config.YdotoolTimeout
|
||||||
}()
|
case "wtype":
|
||||||
|
return i.config.WtypeTimeout
|
||||||
|
case "clipboard":
|
||||||
|
return i.config.ClipboardTimeout
|
||||||
|
default:
|
||||||
|
return 5 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
|
|
||||||
func TestNewInjector(t *testing.T) {
|
func TestNewInjector(t *testing.T) {
|
||||||
config := Config{
|
config := Config{
|
||||||
Mode: "fallback",
|
Backends: []string{"wtype", "clipboard"},
|
||||||
RestoreClipboard: true,
|
YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * 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) {
|
func TestInjector_Inject(t *testing.T) {
|
||||||
// Skip integration tests in CI environments
|
// Skip integration tests in CI environments
|
||||||
if os.Getenv("CI") == "true" {
|
if os.Getenv("CI") == "true" {
|
||||||
@@ -43,32 +78,28 @@ func TestInjector_Inject(t *testing.T) {
|
|||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "inject with clipboard mode",
|
name: "inject with clipboard backend",
|
||||||
config: Config{
|
config: Config{
|
||||||
Mode: "clipboard",
|
Backends: []string{"clipboard"},
|
||||||
RestoreClipboard: false,
|
|
||||||
WtypeTimeout: 5 * time.Second,
|
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
},
|
},
|
||||||
text: "test text",
|
text: "test text",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "inject with type mode",
|
name: "inject with wtype backend",
|
||||||
config: Config{
|
config: Config{
|
||||||
Mode: "type",
|
Backends: []string{"wtype"},
|
||||||
RestoreClipboard: false,
|
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * time.Second,
|
|
||||||
},
|
},
|
||||||
text: "test text",
|
text: "test text",
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "inject with fallback mode",
|
name: "inject with fallback chain",
|
||||||
config: Config{
|
config: Config{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"},
|
||||||
RestoreClipboard: false,
|
YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -78,25 +109,12 @@ func TestInjector_Inject(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "inject empty text",
|
name: "inject empty text",
|
||||||
config: Config{
|
config: Config{
|
||||||
Mode: "clipboard",
|
Backends: []string{"clipboard"},
|
||||||
RestoreClipboard: false,
|
|
||||||
WtypeTimeout: 5 * time.Second,
|
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
},
|
},
|
||||||
text: "",
|
text: "",
|
||||||
wantErr: true,
|
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 {
|
for _, tt := range tests {
|
||||||
@@ -114,18 +132,14 @@ func TestInjector_Inject(t *testing.T) {
|
|||||||
|
|
||||||
func TestConfig(t *testing.T) {
|
func TestConfig(t *testing.T) {
|
||||||
config := Config{
|
config := Config{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"},
|
||||||
RestoreClipboard: true,
|
YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Mode != "fallback" {
|
if len(config.Backends) != 3 {
|
||||||
t.Errorf("Mode mismatch: got %s, want %s", config.Mode, "fallback")
|
t.Errorf("Backends length mismatch: got %d, want %d", len(config.Backends), 3)
|
||||||
}
|
|
||||||
|
|
||||||
if !config.RestoreClipboard {
|
|
||||||
t.Errorf("RestoreClipboard should be true")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.WtypeTimeout != 5*time.Second {
|
if config.WtypeTimeout != 5*time.Second {
|
||||||
@@ -137,123 +151,61 @@ func TestConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTypeText tests the typeText function
|
// TestWtypeBackend tests the wtype backend
|
||||||
func TestTypeText(t *testing.T) {
|
func TestWtypeBackend(t *testing.T) {
|
||||||
// Skip integration tests in CI environments
|
backend := NewWtypeBackend()
|
||||||
if os.Getenv("CI") == "true" {
|
|
||||||
t.Skip("Skipping integration test in CI environment")
|
if backend.Name() != "wtype" {
|
||||||
|
t.Errorf("Name() = %s, want wtype", backend.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := []struct {
|
err := backend.Available()
|
||||||
name string
|
if err != nil {
|
||||||
text string
|
t.Logf("wtype not available (expected): %v", err)
|
||||||
wantErr bool
|
return
|
||||||
}{
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
t.Logf("wtype is available")
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCheckWtypeAvailable tests the wtype availability check
|
// TestYdotoolBackend tests the ydotool backend
|
||||||
func TestCheckWtypeAvailable(t *testing.T) {
|
func TestYdotoolBackend(t *testing.T) {
|
||||||
err := checkWtypeAvailable()
|
backend := NewYdotoolBackend()
|
||||||
|
|
||||||
|
if backend.Name() != "ydotool" {
|
||||||
|
t.Errorf("Name() = %s, want ydotool", backend.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
err := backend.Available()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Logf("checkWtypeAvailable() failed (expected if wtype not installed): %v", err)
|
t.Logf("ydotool not available (expected): %v", err)
|
||||||
// Don't fail the test if wtype is not available
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("checkWtypeAvailable() succeeded - wtype is available")
|
t.Logf("ydotool is available")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetClipboard tests the clipboard get functionality
|
// TestClipboardBackend tests the clipboard backend
|
||||||
func TestGetClipboard(t *testing.T) {
|
func TestClipboardBackend(t *testing.T) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
backend := NewClipboardBackend()
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Test getting clipboard content
|
if backend.Name() != "clipboard" {
|
||||||
content, err := getClipboard(ctx, 1*time.Second)
|
t.Errorf("Name() = %s, want clipboard", backend.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
err := backend.Available()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Logf("getClipboard() failed (expected if wl-paste not available): %v", err)
|
t.Logf("clipboard not available (expected): %v", err)
|
||||||
// Don't fail the test if clipboard tools are not available
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("getClipboard() succeeded, content length: %d", len(content))
|
t.Logf("clipboard is available")
|
||||||
}
|
|
||||||
|
|
||||||
// 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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInjector_ClipboardMode tests clipboard-only injection
|
// TestInjector_ClipboardMode tests clipboard-only injection
|
||||||
func TestInjector_ClipboardMode(t *testing.T) {
|
func TestInjector_ClipboardMode(t *testing.T) {
|
||||||
config := Config{
|
config := Config{
|
||||||
Mode: "clipboard",
|
Backends: []string{"clipboard"},
|
||||||
RestoreClipboard: false,
|
|
||||||
WtypeTimeout: 5 * time.Second,
|
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,20 +216,17 @@ func TestInjector_ClipboardMode(t *testing.T) {
|
|||||||
err := injector.Inject(ctx, "test clipboard text")
|
err := injector.Inject(ctx, "test clipboard text")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Logf("Clipboard injection failed (expected if clipboard tools not available): %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("Clipboard injection succeeded")
|
t.Logf("Clipboard injection succeeded")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInjector_TypeMode tests typing-only injection
|
// TestInjector_WtypeMode tests wtype-only injection
|
||||||
func TestInjector_TypeMode(t *testing.T) {
|
func TestInjector_WtypeMode(t *testing.T) {
|
||||||
config := Config{
|
config := Config{
|
||||||
Mode: "type",
|
Backends: []string{"wtype"},
|
||||||
RestoreClipboard: false,
|
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * time.Second,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
injector := NewInjector(config)
|
injector := NewInjector(config)
|
||||||
@@ -286,19 +235,18 @@ func TestInjector_TypeMode(t *testing.T) {
|
|||||||
|
|
||||||
err := injector.Inject(ctx, "test typing text")
|
err := injector.Inject(ctx, "test typing text")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Logf("Typing injection failed (expected if wtype not available): %v", err)
|
t.Logf("Wtype injection failed (expected if wtype not available): %v", err)
|
||||||
// Don't fail the test if wtype is not available
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("Typing injection succeeded")
|
t.Logf("Wtype injection succeeded")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInjector_FallbackMode tests fallback injection behavior
|
// TestInjector_FallbackChain tests fallback chain injection
|
||||||
func TestInjector_FallbackMode(t *testing.T) {
|
func TestInjector_FallbackChain(t *testing.T) {
|
||||||
config := Config{
|
config := Config{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"},
|
||||||
RestoreClipboard: false,
|
YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
}
|
}
|
||||||
@@ -309,8 +257,7 @@ func TestInjector_FallbackMode(t *testing.T) {
|
|||||||
|
|
||||||
err := injector.Inject(ctx, "test fallback text")
|
err := injector.Inject(ctx, "test fallback text")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Logf("Fallback injection failed (expected if both wtype and clipboard tools not available): %v", err)
|
t.Logf("Fallback injection failed (expected if all tools not available): %v", err)
|
||||||
// Don't fail the test if tools are not available
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,9 +267,7 @@ func TestInjector_FallbackMode(t *testing.T) {
|
|||||||
// TestInjector_EmptyText tests injection of empty text
|
// TestInjector_EmptyText tests injection of empty text
|
||||||
func TestInjector_EmptyText(t *testing.T) {
|
func TestInjector_EmptyText(t *testing.T) {
|
||||||
config := Config{
|
config := Config{
|
||||||
Mode: "clipboard",
|
Backends: []string{"clipboard"},
|
||||||
RestoreClipboard: false,
|
|
||||||
WtypeTimeout: 5 * time.Second,
|
|
||||||
ClipboardTimeout: 3 * time.Second,
|
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")
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,29 +8,21 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func typeText(ctx context.Context, text string, timeout time.Duration) error {
|
type wtypeBackend struct{}
|
||||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := checkWtypeAvailable(); err != nil {
|
func NewWtypeBackend() Backend {
|
||||||
return err
|
return &wtypeBackend{}
|
||||||
}
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, "wtype", text)
|
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
return fmt.Errorf("wtype failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkWtypeAvailable() error {
|
func (w *wtypeBackend) Name() string {
|
||||||
|
return "wtype"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *wtypeBackend) Available() error {
|
||||||
if _, err := exec.LookPath("wtype"); err != nil {
|
if _, err := exec.LookPath("wtype"); err != nil {
|
||||||
return fmt.Errorf("wtype not found: %w (install wtype package)", err)
|
return fmt.Errorf("wtype not found: %w (install wtype package)", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for Wayland environment
|
|
||||||
if os.Getenv("WAYLAND_DISPLAY") == "" {
|
if os.Getenv("WAYLAND_DISPLAY") == "" {
|
||||||
return fmt.Errorf("WAYLAND_DISPLAY not set - wtype requires Wayland session")
|
return fmt.Errorf("WAYLAND_DISPLAY not set - wtype requires Wayland session")
|
||||||
}
|
}
|
||||||
@@ -41,3 +33,19 @@ func checkWtypeAvailable() error {
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -25,8 +25,7 @@ func TestNew(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: config.InjectionConfig{
|
Injection: config.InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
RestoreClipboard: true,
|
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -64,8 +63,7 @@ func TestPipeline_Status(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: config.InjectionConfig{
|
Injection: config.InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
RestoreClipboard: true,
|
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -111,8 +109,7 @@ func TestPipeline_GetActionCh(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: config.InjectionConfig{
|
Injection: config.InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
RestoreClipboard: true,
|
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -156,8 +153,7 @@ func TestPipeline_GetErrorCh(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: config.InjectionConfig{
|
Injection: config.InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
RestoreClipboard: true,
|
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -201,8 +197,7 @@ func TestPipeline_Stop(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: config.InjectionConfig{
|
Injection: config.InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
RestoreClipboard: true,
|
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -239,8 +234,7 @@ func TestPipeline_Run(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: config.InjectionConfig{
|
Injection: config.InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
RestoreClipboard: true,
|
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -350,8 +344,7 @@ func TestPipeline_ConcurrentAccess(t *testing.T) {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: config.InjectionConfig{
|
Injection: config.InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"}, YdotoolTimeout: 5 * time.Second,
|
||||||
RestoreClipboard: true,
|
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ func TestConfig() *config.Config {
|
|||||||
Model: "whisper-1",
|
Model: "whisper-1",
|
||||||
},
|
},
|
||||||
Injection: config.InjectionConfig{
|
Injection: config.InjectionConfig{
|
||||||
Mode: "fallback",
|
Backends: []string{"ydotool", "wtype", "clipboard"},
|
||||||
RestoreClipboard: true,
|
YdotoolTimeout: 5 * time.Second,
|
||||||
WtypeTimeout: 5 * time.Second,
|
WtypeTimeout: 5 * time.Second,
|
||||||
ClipboardTimeout: 3 * time.Second,
|
ClipboardTimeout: 3 * time.Second,
|
||||||
},
|
},
|
||||||
@@ -60,7 +60,8 @@ func TestConfigWithInvalidValues() *config.Config {
|
|||||||
Model: "", // Invalid
|
Model: "", // Invalid
|
||||||
},
|
},
|
||||||
Injection: config.InjectionConfig{
|
Injection: config.InjectionConfig{
|
||||||
Mode: "invalid", // Invalid
|
Backends: []string{}, // Invalid (empty)
|
||||||
|
YdotoolTimeout: 0, // Invalid
|
||||||
WtypeTimeout: 0, // Invalid
|
WtypeTimeout: 0, // Invalid
|
||||||
ClipboardTimeout: 0, // Invalid
|
ClipboardTimeout: 0, // Invalid
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ depends=(
|
|||||||
optdepends=(
|
optdepends=(
|
||||||
'hyprland: For Hyprland window manager integration'
|
'hyprland: For Hyprland window manager integration'
|
||||||
'sway: For Sway window manager integration'
|
'sway: For Sway window manager integration'
|
||||||
|
'ydotool: Alternative text injection backend (recommended for Chromium apps)'
|
||||||
)
|
)
|
||||||
provides=('hyprvoice')
|
provides=('hyprvoice')
|
||||||
conflicts=('hyprvoice')
|
conflicts=('hyprvoice')
|
||||||
|
|||||||
Reference in New Issue
Block a user