//go:build linux package tools import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "time" "git.gopher.ink/mirrors2/AutoZero/diag" "git.gopher.ink/mirrors2/AutoZero/execx" ) // screenshotTimeout 兜住截图工具不返回的情况(授权失败、X server 卡死)。 const screenshotTimeout = 20 * time.Second // linuxBackend:Linux 桌面控制,自动检测 X11/Wayland 会话。 // X11: 输入用 xdotool,截图用 scrot → import → gnome-screenshot。 // Wayland: 输入用 ydotool,截图用 grim → gnome-screenshot → import。 type linuxBackend struct { inputFn func(args ...string) (string, error) // xdotool 或 ydotool isWay bool } func newGuiBackend() guiBackend { b := &linuxBackend{} if isWayland() { b.isWay = true b.inputFn = ydotool } else { b.inputFn = xdotool } return b } // isWayland 检测当前会话是否为 Wayland。 func isWayland() bool { if v := os.Getenv("XDG_SESSION_TYPE"); strings.EqualFold(v, "wayland") { return true } if os.Getenv("WAYLAND_DISPLAY") != "" { return true } return false } func hasBin(name string) bool { _, err := exec.LookPath(name) return err == nil } func (b *linuxBackend) Available() error { if b.isWay { if _, err := exec.LookPath("ydotool"); err != nil { return fmt.Errorf("ydotool is required for computer use on Wayland (e.g. apt install ydotool); also ensure /dev/uinput is accessible (user may need input group)") } if !hasBin("grim") && !hasBin("gnome-screenshot") && !hasBin("import") { return fmt.Errorf("a screenshot tool is required on Wayland (install grim, gnome-screenshot, or imagemagick)") } return nil } // X11 if _, err := exec.LookPath("xdotool"); err != nil { return fmt.Errorf("xdotool is required for computer use on X11 (e.g. apt install xdotool)") } for _, t := range []string{"scrot", "import", "gnome-screenshot"} { if hasBin(t) { return nil } } return fmt.Errorf("a screenshot tool is required (install scrot, imagemagick, or gnome-screenshot)") } // PreflightPermissions Linux 暂无统一的“首次引导授权”机制,依赖 Available() 的依赖检查。 func (b *linuxBackend) PreflightPermissions() []string { return nil } func (b *linuxBackend) Screenshot(ctx context.Context, region string) (string, error) { // 截图工具在 Wayland 授权失败/X server 卡住时会不返回;ctx 由调用方给, // 这里再加一层兜底上限,保证 computer 工具不会把回合钉死。 cctx, cancel := context.WithTimeout(ctx, screenshotTimeout) defer cancel() path := filepath.Join(os.TempDir(), fmt.Sprintf("autogo-shot-%d.png", time.Now().UnixNano())) var cmd *exec.Cmd if b.isWay { switch { case hasBin("grim") && region != "": // grim 支持区域: grim -g x,y,w,h cmd = execx.Command(cctx, "grim", "-g", region, path) case hasBin("grim"): cmd = execx.Command(cctx, "grim", path) case hasBin("gnome-screenshot"): cmd = execx.Command(cctx, "gnome-screenshot", "-f", path) case hasBin("import"): cmd = execx.Command(cctx, "import", "-window", "root", path) default: return "", fmt.Errorf("no screenshot tool available on Wayland (install grim)") } } else { // X11 switch { case region != "" && hasBin("scrot"): cmd = execx.Command(cctx, "scrot", "-o", "-a", region, path) case hasBin("scrot"): if region != "" { // import/gnome-screenshot 不支持区域,静默截全屏并打印提示 diag.Printf("[computer] warning: region screenshot not supported with current tool; capturing full screen\n") } cmd = execx.Command(cctx, "scrot", "-o", path) case hasBin("import"): if region != "" { diag.Printf("[computer] warning: region screenshot not supported with import; capturing full screen\n") } cmd = execx.Command(cctx, "import", "-window", "root", path) case hasBin("gnome-screenshot"): if region != "" { diag.Printf("[computer] warning: region screenshot not supported with gnome-screenshot; capturing full screen\n") } cmd = execx.Command(cctx, "gnome-screenshot", "-f", path) default: return "", fmt.Errorf("no screenshot tool available (install scrot or imagemagick)") } } if out, err := cmd.CombinedOutput(); err != nil { _ = os.Remove(path) return "", fmt.Errorf("screenshot failed: %w: %s", err, oneLine(out)) } if _, err := os.Stat(path); err != nil { return "", fmt.Errorf("screenshot was not created (is DISPLAY/WAYLAND_DISPLAY set?)") } return path, nil } func (b *linuxBackend) Click(_ context.Context, x, y int, kind string) error { if b.isWay { return ydotoolClick(x, y, kind) } args := []string{"mousemove", itoa(x), itoa(y)} switch kind { case "right": args = append(args, "click", "3") case "double": args = append(args, "click", "--repeat", "2", "--delay", "80", "1") default: args = append(args, "click", "1") } _, err := b.inputFn(args...) return err } func (b *linuxBackend) TypeText(_ context.Context, text string) error { if b.isWay { // ydotool type: 逐字符输入 _, err := ydotool("type", "--delay", "15", "--", text) return err } _, err := xdotool("type", "--delay", "15", "--", text) return err } func (b *linuxBackend) KeyChord(_ context.Context, chordStr string) error { mods, main, err := parseChord(chordStr) if err != nil { return err } if b.isWay { combo, err := chordToYdotool(mods, main, chordStr) if err != nil { return err } _, err = ydotool("key", combo) return err } combo, err := chordToXdotool(mods, main, chordStr) if err != nil { return err } _, err = xdotool("key", "--clearmodifiers", combo) return err } func (b *linuxBackend) Scroll(_ context.Context, direction string, amount int) error { if b.isWay { return ydotoolScroll(direction, amount) } button := "5" // 滚轮下 if direction == "up" { button = "4" } _, err := xdotool("click", "--repeat", itoa(amount), "--delay", "90", button) return err } // ---- xdotool (X11) ---- func xdotool(args ...string) (string, error) { cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() out, err := execx.Run(cctx, execx.Options{}, "xdotool", args...) if err != nil { return string(out), fmt.Errorf("xdotool %s: %w: %s", joinArgs(args), err, oneLine(out)) } return string(out), nil } // ---- ydotool (Wayland) ---- func ydotool(args ...string) (string, error) { cctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() out, err := execx.Run(cctx, execx.Options{}, "ydotool", args...) if err != nil { return string(out), fmt.Errorf("ydotool %s: %w: %s", joinArgs(args), err, oneLine(out)) } return string(out), nil } func ydotoolClick(x, y int, kind string) error { // ydotool:mousemove absolute x y; click:1=左 3=右 args := []string{"mousemove", "--absolute", itoa(x), itoa(y)} switch kind { case "right": args = append(args, "click", "3") case "double": args = append(args, "click", "1", "--next-delay", "80", "click", "1") default: args = append(args, "click", "1") } _, err := ydotool(args...) return err } func ydotoolScroll(direction string, amount int) error { // ydotool: wheel: -120=上 120=下 delta := "120" if direction == "up" { delta = "-120" } for i := 0; i < amount; i++ { if _, err := ydotool("wheel", "--delta", delta); err != nil { return err } time.Sleep(90 * time.Millisecond) } return nil } // chordToYdotool 翻译为 ydotool 按键语法。 // ydotool 使用 Linux 内核 keycode 名称(与 xdotool 类似但不完全相同)。 func chordToYdotool(mods []string, main, orig string) (string, error) { modMap := map[string]string{ "ctrl": "ctrl", "control": "ctrl", "alt": "alt", "option": "alt", "shift": "shift", "cmd": "meta", "command": "meta", "win": "meta", "super": "meta", "meta": "meta", } mainMap := map[string]string{ "enter": "enter", "return": "enter", "escape": "esc", "esc": "esc", "tab": "tab", "delete": "delete", "backspace": "backspace", "forwarddelete": "delete", "up": "up", "down": "down", "left": "left", "right": "right", "space": "space", "home": "home", "end": "end", "pageup": "pageup", "pagedown": "pagedown", } parts := make([]string, 0, len(mods)+1) for _, m := range mods { parts = append(parts, modMap[m]) } if sym, ok := mainMap[main]; ok { parts = append(parts, sym) } else if n := fnKeyNum(main); n > 0 { parts = append(parts, fmt.Sprintf("f%d", n)) } else { parts = append(parts, main) } return strings.Join(parts, "+"), nil } func joinArgs(args []string) string { s := "" for i, a := range args { if i > 0 { s += " " } s += a } return s }