420 lines
9.4 KiB
Go
420 lines
9.4 KiB
Go
package desktop
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// InputEvent represents a client mouse or keyboard action
|
|
type InputEvent struct {
|
|
Type string `json:"type"` // "mouse_move", "mouse_down", "mouse_up", "key_down", "key_up", "scroll"
|
|
X float64 `json:"x,omitempty"` // normalized x coordinate (0.0 to 1.0)
|
|
Y float64 `json:"y,omitempty"` // normalized y coordinate (0.0 to 1.0)
|
|
Button int `json:"button,omitempty"` // mouse button: 1=left, 2=middle, 3=right, 4=scroll_up, 5=scroll_down
|
|
Key string `json:"key,omitempty"` // key symbol or keycode
|
|
}
|
|
|
|
// XdotoolInjector injects inputs by writing to a persistent xdotool process stdin
|
|
type XdotoolInjector struct {
|
|
cmd *exec.Cmd
|
|
stdin io.WriteCloser
|
|
mu sync.Mutex
|
|
display string
|
|
width int
|
|
height int
|
|
isStarted bool
|
|
|
|
moveMu sync.Mutex
|
|
pendingMove *InputEvent
|
|
stopChan chan struct{}
|
|
}
|
|
|
|
func NewXdotoolInjector(display string) *XdotoolInjector {
|
|
if display == "" {
|
|
display = ":0.0"
|
|
}
|
|
return &XdotoolInjector{
|
|
display: display,
|
|
width: 1280, // Default fallbacks
|
|
height: 720,
|
|
}
|
|
}
|
|
|
|
// Start launches xdotool and queries screen resolution
|
|
func (xi *XdotoolInjector) Start() error {
|
|
xi.mu.Lock()
|
|
defer xi.mu.Unlock()
|
|
|
|
if xi.isStarted {
|
|
return nil
|
|
}
|
|
|
|
// 1. Resolve screen resolution
|
|
w, h, err := xi.queryDisplayGeometry()
|
|
if err == nil {
|
|
xi.width = w
|
|
xi.height = h
|
|
log.Printf("Detected remote display geometry: %dx%d on %s", w, h, xi.display)
|
|
} else {
|
|
log.Printf("Warning: Failed to query display geometry: %v. Using default: %dx%d", err, xi.width, xi.height)
|
|
}
|
|
|
|
// 2. Launch persistent xdotool process
|
|
cmd := exec.Command("xdotool", "-")
|
|
cmd.Env = desktopCommandEnv(xi.display)
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to open xdotool stdin pipe: %w", err)
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
_ = stdin.Close()
|
|
return fmt.Errorf("failed to start xdotool process: %w", err)
|
|
}
|
|
|
|
xi.cmd = cmd
|
|
xi.stdin = stdin
|
|
xi.isStarted = true
|
|
|
|
if xi.stopChan == nil {
|
|
xi.stopChan = make(chan struct{})
|
|
go xi.mouseMoveWorker()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Inject sends a command to the persistent xdotool process
|
|
func (xi *XdotoolInjector) Inject(event InputEvent) error {
|
|
xi.mu.Lock()
|
|
if !xi.isStarted || xi.stdin == nil {
|
|
xi.mu.Unlock()
|
|
return fmt.Errorf("injector is not running")
|
|
}
|
|
|
|
var cmdStr string
|
|
|
|
switch event.Type {
|
|
case "mouse_move":
|
|
xi.moveMu.Lock()
|
|
xi.pendingMove = &event
|
|
xi.moveMu.Unlock()
|
|
xi.mu.Unlock()
|
|
return nil
|
|
|
|
case "mouse_down":
|
|
btn := xi.mapButton(event.Button)
|
|
cmdStr = fmt.Sprintf("mousedown %d\n", btn)
|
|
|
|
case "mouse_up":
|
|
btn := xi.mapButton(event.Button)
|
|
cmdStr = fmt.Sprintf("mouseup %d\n", btn)
|
|
|
|
case "key_down":
|
|
key := xi.sanitizeKey(event.Key)
|
|
cmdStr = fmt.Sprintf("keydown %s\n", key)
|
|
|
|
case "key_up":
|
|
key := xi.sanitizeKey(event.Key)
|
|
cmdStr = fmt.Sprintf("keyup %s\n", key)
|
|
|
|
case "scroll":
|
|
// xdotool maps scroll up to button 4 and scroll down to button 5
|
|
if event.Button == 4 || event.Button == 5 {
|
|
cmdStr = fmt.Sprintf("click %d\n", event.Button)
|
|
}
|
|
|
|
default:
|
|
xi.mu.Unlock()
|
|
return fmt.Errorf("unsupported input type: %s", event.Type)
|
|
}
|
|
|
|
if cmdStr != "" {
|
|
_, err := xi.stdin.Write([]byte(cmdStr))
|
|
if err != nil {
|
|
// Try to restart if pipe is broken
|
|
log.Printf("xdotool write error: %v. Attempting to restart injector.", err)
|
|
xi.isStarted = false
|
|
if xi.stdin != nil {
|
|
_ = xi.stdin.Close()
|
|
}
|
|
xi.stdin = nil
|
|
xi.cmd = nil
|
|
xi.mu.Unlock()
|
|
if restartErr := xi.Start(); restartErr == nil {
|
|
xi.mu.Lock()
|
|
_, _ = xi.stdin.Write([]byte(cmdStr))
|
|
xi.mu.Unlock()
|
|
}
|
|
return err
|
|
}
|
|
}
|
|
|
|
xi.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// Close terminates the xdotool process
|
|
func (xi *XdotoolInjector) Close() error {
|
|
xi.mu.Lock()
|
|
defer xi.mu.Unlock()
|
|
|
|
if !xi.isStarted {
|
|
return nil
|
|
}
|
|
|
|
xi.isStarted = false
|
|
if xi.stdin != nil {
|
|
_ = xi.stdin.Close()
|
|
}
|
|
if xi.cmd != nil {
|
|
_ = xi.cmd.Process.Kill()
|
|
}
|
|
|
|
xi.stdin = nil
|
|
xi.cmd = nil
|
|
|
|
if xi.stopChan != nil {
|
|
close(xi.stopChan)
|
|
xi.stopChan = nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (xi *XdotoolInjector) mouseMoveWorker() {
|
|
ticker := time.NewTicker(16 * time.Millisecond) // ~60fps
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-xi.stopChan:
|
|
return
|
|
case <-ticker.C:
|
|
xi.moveMu.Lock()
|
|
event := xi.pendingMove
|
|
xi.pendingMove = nil
|
|
xi.moveMu.Unlock()
|
|
|
|
if event != nil {
|
|
xi.mu.Lock()
|
|
if xi.isStarted && xi.stdin != nil {
|
|
absX := int(event.X * float64(xi.width))
|
|
absY := int(event.Y * float64(xi.height))
|
|
cmdStr := fmt.Sprintf("mousemove %d %d\n", absX, absY)
|
|
if _, err := xi.stdin.Write([]byte(cmdStr)); err != nil {
|
|
log.Printf("xdotool mousemove write error: %v", err)
|
|
}
|
|
}
|
|
xi.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (xi *XdotoolInjector) queryDisplayGeometry() (int, int, error) {
|
|
return queryDisplayGeometry(xi.display)
|
|
}
|
|
|
|
func queryDisplayGeometry(display string) (int, int, error) {
|
|
cmd := exec.Command("xdotool", "getdisplaygeometry")
|
|
cmd.Env = desktopCommandEnv(display)
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
parts := strings.Fields(string(out))
|
|
if len(parts) < 2 {
|
|
return 0, 0, fmt.Errorf("invalid geometry output: %s", string(out))
|
|
}
|
|
|
|
w, err1 := strconv.Atoi(parts[0])
|
|
h, err2 := strconv.Atoi(parts[1])
|
|
if err1 != nil || err2 != nil {
|
|
return 0, 0, fmt.Errorf("failed to parse geometry: %w, %w", err1, err2)
|
|
}
|
|
|
|
return w, h, nil
|
|
}
|
|
|
|
func ResolveDesktopDisplay(requested string) string {
|
|
resolved, ok := resolveDesktopDisplayWithProber(
|
|
requested,
|
|
os.Getenv("DISPLAY"),
|
|
x11SocketDisplays("/tmp/.X11-unix"),
|
|
func(display string) bool {
|
|
_, _, err := queryDisplayGeometry(display)
|
|
return err == nil
|
|
},
|
|
)
|
|
if ok {
|
|
if strings.TrimSpace(requested) != "" && strings.TrimSpace(requested) != resolved {
|
|
log.Printf("Resolved remote desktop display %q to active X11 display %s", requested, resolved)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
requested = strings.TrimSpace(requested)
|
|
if requested != "" {
|
|
return requested
|
|
}
|
|
if envDisplay := strings.TrimSpace(os.Getenv("DISPLAY")); envDisplay != "" {
|
|
return envDisplay
|
|
}
|
|
return ":0.0"
|
|
}
|
|
|
|
func resolveDesktopDisplayWithProber(
|
|
requested string,
|
|
envDisplay string,
|
|
socketDisplays []string,
|
|
probe func(string) bool,
|
|
) (string, bool) {
|
|
requested = strings.TrimSpace(requested)
|
|
envDisplay = strings.TrimSpace(envDisplay)
|
|
|
|
if requested != "" && !isAutoDesktopDisplay(requested) {
|
|
return requested, true
|
|
}
|
|
|
|
candidates := make([]string, 0, len(socketDisplays)+3)
|
|
if envDisplay != "" {
|
|
candidates = append(candidates, envDisplay)
|
|
}
|
|
candidates = append(candidates, socketDisplays...)
|
|
if requested != "" {
|
|
candidates = append(candidates, requested)
|
|
}
|
|
candidates = append(candidates, ":0.0", ":0")
|
|
|
|
for _, candidate := range uniqueDisplayCandidates(candidates) {
|
|
if probe(candidate) {
|
|
return candidate, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func isAutoDesktopDisplay(display string) bool {
|
|
switch strings.TrimSpace(display) {
|
|
case "", ":0", ":0.0":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func x11SocketDisplays(dir string) []string {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
displays := make([]int, 0, len(entries))
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if entry.IsDir() || !strings.HasPrefix(name, "X") {
|
|
continue
|
|
}
|
|
value, err := strconv.Atoi(strings.TrimPrefix(name, "X"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
displays = append(displays, value)
|
|
}
|
|
sort.Sort(sort.Reverse(sort.IntSlice(displays)))
|
|
|
|
result := make([]string, 0, len(displays))
|
|
for _, display := range displays {
|
|
result = append(result, fmt.Sprintf(":%d", display))
|
|
}
|
|
return result
|
|
}
|
|
|
|
func uniqueDisplayCandidates(candidates []string) []string {
|
|
seen := make(map[string]struct{}, len(candidates))
|
|
result := make([]string, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
candidate = strings.TrimSpace(candidate)
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
if _, exists := seen[candidate]; exists {
|
|
continue
|
|
}
|
|
seen[candidate] = struct{}{}
|
|
result = append(result, candidate)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func desktopCommandEnv(display string) []string {
|
|
env := os.Environ()
|
|
if strings.TrimSpace(display) == "" {
|
|
return env
|
|
}
|
|
filtered := make([]string, 0, len(env)+1)
|
|
hasXauthority := false
|
|
for _, item := range env {
|
|
if strings.HasPrefix(item, "DISPLAY=") {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(item, "XAUTHORITY=") {
|
|
hasXauthority = true
|
|
}
|
|
filtered = append(filtered, item)
|
|
}
|
|
filtered = append(filtered, "DISPLAY="+display)
|
|
if !hasXauthority {
|
|
if home := strings.TrimSpace(os.Getenv("HOME")); home != "" {
|
|
xauthority := filepath.Join(home, ".Xauthority")
|
|
if _, err := os.Stat(xauthority); err == nil {
|
|
filtered = append(filtered, "XAUTHORITY="+xauthority)
|
|
}
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func (xi *XdotoolInjector) mapButton(btn int) int {
|
|
// Standard mapping: 1=left, 2=middle, 3=right
|
|
if btn <= 0 || btn > 3 {
|
|
return 1
|
|
}
|
|
return btn
|
|
}
|
|
|
|
func (xi *XdotoolInjector) sanitizeKey(key string) string {
|
|
// Clean or map keys if they need special handling in xdotool
|
|
// For example, Flutter "Backspace" needs to be "BackSpace" or "Return" to "Return".
|
|
key = strings.TrimSpace(key)
|
|
switch strings.ToLower(key) {
|
|
case "enter":
|
|
return "Return"
|
|
case "backspace":
|
|
return "BackSpace"
|
|
case "tab":
|
|
return "Tab"
|
|
case "escape":
|
|
return "Escape"
|
|
case "control":
|
|
return "ctrl"
|
|
case "shift":
|
|
return "shift"
|
|
case "alt":
|
|
return "alt"
|
|
case "meta":
|
|
return "super"
|
|
}
|
|
return key
|
|
}
|