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。
292 lines
8.5 KiB
Go
292 lines
8.5 KiB
Go
//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
|
||
}
|