Files
AutoZero/tools/computer_linux.go
T
Sakurasan d5dac5a6fc feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、
builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、
编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。

产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt)
留在 AutoGo,宿主接入方式见 MIGRATION.md。
2026-10-26 00:54:46 +08:00

292 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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
}