@@ -0,0 +1,141 @@
|
||||
package injection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// clipboardPasteBackend temporarily puts text on the regular clipboard, then
|
||||
// uses wtype to issue Ctrl+V. This works in applications that do not support
|
||||
// the primary selection, such as browsers and Electron apps. The previous
|
||||
// text clipboard is restored after the paste has been delivered.
|
||||
type clipboardPasteBackend struct {
|
||||
clipboard *clipboardBackend
|
||||
wtype *wtypeBackend
|
||||
ctrlShiftVClasses []string
|
||||
}
|
||||
|
||||
func NewClipboardPasteBackend(ctrlShiftVClasses []string) Backend {
|
||||
return &clipboardPasteBackend{
|
||||
clipboard: NewClipboardBackend().(*clipboardBackend),
|
||||
wtype: NewWtypeBackend().(*wtypeBackend),
|
||||
ctrlShiftVClasses: ctrlShiftVClasses,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *clipboardPasteBackend) Name() string {
|
||||
return "clipboard-paste"
|
||||
}
|
||||
|
||||
func (c *clipboardPasteBackend) Available() error {
|
||||
if err := c.clipboard.Available(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.wtype.Available(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *clipboardPasteBackend) 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
|
||||
}
|
||||
// wl-paste only needs to keep the clipboard owner alive while it reads the
|
||||
// selection, so this command returns with the exact existing text content.
|
||||
// --no-newline prevents wl-paste from adding a terminal-friendly newline.
|
||||
previousClipboard, restore, err := c.readClipboard(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
copyText := exec.CommandContext(ctx, "wl-copy")
|
||||
copyText.Stdin = strings.NewReader(text)
|
||||
if err := copyText.Run(); err != nil {
|
||||
return fmt.Errorf("copy transcription to clipboard: %w", err)
|
||||
}
|
||||
// wl-copy returns before every client has observed the new selection. Give
|
||||
// the compositor a short chance to publish it; this is still perceived as
|
||||
// an immediate paste and avoids inserting the previous clipboard contents.
|
||||
select {
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// wtype resolves the keysym itself, avoiding ydotool's physical-keycode
|
||||
// layout problem on Dvorak and Colemak setups. Terminal emulators typically
|
||||
// reserve Ctrl+V, so configured window classes use Ctrl+Shift+V instead.
|
||||
args := []string{"-M", "ctrl"}
|
||||
if c.shouldUseCtrlShiftV(ctx) {
|
||||
args = append(args, "-M", "shift", "-k", "v", "-m", "shift")
|
||||
} else {
|
||||
args = append(args, "-k", "v")
|
||||
}
|
||||
args = append(args, "-m", "ctrl")
|
||||
cmd := exec.CommandContext(ctx, "wtype", args...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("paste clipboard with wtype: %w", err)
|
||||
}
|
||||
|
||||
if !restore {
|
||||
return nil
|
||||
}
|
||||
// Receiving a Wayland paste is asynchronous. Leave the transcription
|
||||
// available briefly, then put the user's prior clipboard back.
|
||||
select {
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
restoreClipboard := exec.CommandContext(ctx, "wl-copy")
|
||||
restoreClipboard.Stdin = strings.NewReader(previousClipboard)
|
||||
if err := restoreClipboard.Run(); err != nil {
|
||||
return fmt.Errorf("restore previous clipboard: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *clipboardPasteBackend) shouldUseCtrlShiftV(ctx context.Context) bool {
|
||||
if len(c.ctrlShiftVClasses) == 0 {
|
||||
return false
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "hyprctl", "activewindow", "-j")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var activeWindow struct {
|
||||
Class string `json:"class"`
|
||||
}
|
||||
if err := json.Unmarshal(output, &activeWindow); err != nil {
|
||||
return false
|
||||
}
|
||||
class := strings.ToLower(activeWindow.Class)
|
||||
for _, match := range c.ctrlShiftVClasses {
|
||||
if match = strings.TrimSpace(strings.ToLower(match)); match != "" && strings.Contains(class, match) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *clipboardPasteBackend) readClipboard(ctx context.Context) (text string, restore bool, err error) {
|
||||
cmd := exec.CommandContext(ctx, "wl-paste", "--no-newline")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// No clipboard owner is normal. There is simply nothing to restore.
|
||||
if _, ok := err.(*exec.ExitError); ok {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("read existing clipboard: %w", err)
|
||||
}
|
||||
return string(output), true, nil
|
||||
}
|
||||
@@ -12,10 +12,11 @@ type Injector interface {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
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
|
||||
Backends []string // Ordered list: "clipboard-paste", "ydotool", "wtype", "clipboard"
|
||||
YdotoolTimeout time.Duration // Timeout for ydotool commands
|
||||
WtypeTimeout time.Duration // Timeout for wtype commands
|
||||
ClipboardTimeout time.Duration // Timeout for clipboard operations
|
||||
CtrlShiftVClasses []string // Hyprland window-class substrings that paste with Ctrl+Shift+V
|
||||
}
|
||||
|
||||
type injector struct {
|
||||
@@ -34,6 +35,8 @@ func NewInjector(config Config) Injector {
|
||||
backends = append(backends, NewWtypeBackend())
|
||||
case "clipboard":
|
||||
backends = append(backends, NewClipboardBackend())
|
||||
case "clipboard-paste":
|
||||
backends = append(backends, NewClipboardPasteBackend(config.CtrlShiftVClasses))
|
||||
default:
|
||||
log.Printf("Injection: unknown backend %q, skipping", name)
|
||||
}
|
||||
|
||||
@@ -66,9 +66,10 @@ func TestNewInjector_IgnoresUnknownBackends(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestInjector_Inject(t *testing.T) {
|
||||
// Skip integration tests in CI environments
|
||||
if os.Getenv("CI") == "true" {
|
||||
t.Skip("Skipping integration test in CI environment")
|
||||
// This test invokes real Wayland input tools and must run in a graphical
|
||||
// session, not merely outside CI.
|
||||
if os.Getenv("CI") == "true" || os.Getenv("WAYLAND_DISPLAY") == "" {
|
||||
t.Skip("Skipping integration test outside a Wayland session")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -202,6 +203,13 @@ func TestClipboardBackend(t *testing.T) {
|
||||
t.Logf("clipboard is available")
|
||||
}
|
||||
|
||||
func TestClipboardPasteBackend(t *testing.T) {
|
||||
backend := NewClipboardPasteBackend([]string{"ghostty"})
|
||||
if backend.Name() != "clipboard-paste" {
|
||||
t.Errorf("Name() = %s, want clipboard-paste", backend.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// TestInjector_ClipboardMode tests clipboard-only injection
|
||||
func TestInjector_ClipboardMode(t *testing.T) {
|
||||
config := Config{
|
||||
|
||||
@@ -85,9 +85,13 @@ func (y *ydotoolBackend) Inject(ctx context.Context, text string, timeout time.D
|
||||
if err := y.Available(); err != nil {
|
||||
return err
|
||||
}
|
||||
socketPath := y.getSocketPath()
|
||||
|
||||
// ydotool type -- "text"
|
||||
cmd := exec.CommandContext(ctx, "ydotool", "type", "--", text)
|
||||
if socketPath != "" {
|
||||
cmd.Env = append(os.Environ(), "YDOTOOL_SOCKET="+socketPath)
|
||||
}
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("ydotool failed: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user