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。
This commit is contained in:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
@@ -0,0 +1,251 @@
|
||||
//go:build darwin
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.gopher.ink/mirrors2/AutoZero/diag"
|
||||
"git.gopher.ink/mirrors2/AutoZero/execx"
|
||||
)
|
||||
|
||||
// darwinBackend:macOS 桌面控制,走系统自带能力(零依赖):
|
||||
// 截图用 screencapture,鼠标键盘用 System Events 的 osascript;
|
||||
// 若安装了 cliclick 则用它增强(更可靠的 click/right_click/double_click)。
|
||||
// 需要终端的 辅助功能(Accessibility)与 屏幕录制 权限。
|
||||
|
||||
type darwinBackend struct{}
|
||||
|
||||
func newGuiBackend() guiBackend { return darwinBackend{} }
|
||||
|
||||
func (darwinBackend) Available() error { return nil } // osascript/screencapture 为系统自带
|
||||
|
||||
// PreflightPermissions 探测桌面控制所需的 macOS TCC 权限(辅助功能 / 屏幕录制),
|
||||
// 缺失时打开系统设置对应面板引导用户授权,并尝试触发一次受保护调用以唤起原生弹窗。
|
||||
// 返回缺失权限名(空切片=齐全)。仅在首次运行(无标记文件)时自动打开面板,避免每次启动打扰。
|
||||
func (darwinBackend) PreflightPermissions() []string {
|
||||
st := checkDesktopPermissions()
|
||||
var missing []string
|
||||
if !st.Accessibility {
|
||||
missing = append(missing, "Accessibility")
|
||||
}
|
||||
if !st.ScreenCapture {
|
||||
missing = append(missing, "Screen Recording")
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 首次运行自动打开系统设置面板引导;写标记后不再自动弹。
|
||||
home, _ := os.UserHomeDir()
|
||||
marker := filepath.Join(home, ".autogo", ".perms-prompted")
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
openSettingsPane("Privacy_Accessibility")
|
||||
openSettingsPane("Privacy_ScreenCapture")
|
||||
_ = os.MkdirAll(filepath.Dir(marker), 0o755)
|
||||
_ = os.WriteFile(marker, []byte(time.Now().UTC().Format(time.RFC3339)), 0o644)
|
||||
}
|
||||
// 触发原生授权弹窗:未授权时调用一次受保护 API 即可让 macOS 弹窗引导。
|
||||
_, _ = runOSA(context.Background(), `tell application "System Events" to return name of first process`)
|
||||
// 指引走 diag:TUI 下它被换到服务日志,直写 os.Stderr 会打穿 alt-screen。
|
||||
diag.Printf("[setup] macOS 桌面权限缺失:%s。请在「系统设置 → 隐私与安全性 → %s」中开启,并完全重启本程序(终端/App)使授权生效。",
|
||||
strings.Join(missing, "、"), strings.Join(missing, " / "))
|
||||
return missing
|
||||
}
|
||||
|
||||
// checkDesktopPermissions 探测两项 TCC 权限是否已授予。
|
||||
func checkDesktopPermissions() permState {
|
||||
s := permState{}
|
||||
// 辅助功能:未授权时 System Events 事件请求被拒。
|
||||
if _, err := runOSA(context.Background(), `tell application "System Events" to return name of first process`); err == nil {
|
||||
s.Accessibility = true
|
||||
}
|
||||
// 屏幕录制:未授权时 screencapture 不产生文件。
|
||||
if p, err := captureScreen(context.Background(), ""); err == nil {
|
||||
s.ScreenCapture = true
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type permState struct {
|
||||
Accessibility bool
|
||||
ScreenCapture bool
|
||||
}
|
||||
|
||||
// openSettingsPane 打开系统设置的某个隐私子面板。
|
||||
func openSettingsPane(pane string) {
|
||||
_ = exec.Command("open", "x-apple.systempreferences:com.apple.preference.security?"+pane).Start()
|
||||
}
|
||||
|
||||
func (darwinBackend) Screenshot(ctx context.Context, region string) (string, error) {
|
||||
return captureScreen(ctx, region)
|
||||
}
|
||||
|
||||
func (darwinBackend) Click(_ context.Context, x, y int, kind string) error {
|
||||
if hasCliclick() {
|
||||
_, err := runShell(context.Background(), clickScript(x, y, true, kind))
|
||||
return err
|
||||
}
|
||||
if _, err := runOSA(context.Background(), clickScript(x, y, false, kind)); err != nil {
|
||||
return fmt.Errorf("%w (hint: install cliclick for more reliable clicks, or grant Accessibility permission)", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (darwinBackend) TypeText(_ context.Context, text string) error {
|
||||
_, err := runOSA(context.Background(), fmt.Sprintf(`tell application "System Events" to keystroke %s`, osaQuote(text)))
|
||||
return err
|
||||
}
|
||||
|
||||
func (darwinBackend) KeyChord(_ context.Context, chordStr string) error {
|
||||
stmt, err := osaKeyChord(chordStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = runOSA(context.Background(), `tell application "System Events" to `+stmt)
|
||||
return err
|
||||
}
|
||||
|
||||
func (darwinBackend) Scroll(_ context.Context, direction string, amount int) error {
|
||||
code := "125" // down arrow
|
||||
if direction == "up" {
|
||||
code = "126"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(`tell application "System Events"`)
|
||||
for i := 0; i < amount; i++ {
|
||||
fmt.Fprintf(&b, " delay 0.05 key code %s", code)
|
||||
}
|
||||
b.WriteString(` end tell`)
|
||||
_, err := runOSA(context.Background(), b.String())
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- macOS 底层工具函数(browser.go 也复用 osaQuote/runOSA)----
|
||||
|
||||
// osaQuote 把字符串安全嵌入 AppleScript 双引号字符串。
|
||||
func osaQuote(s string) string {
|
||||
return "\"" + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + "\""
|
||||
}
|
||||
|
||||
// osaKeyChord 把 "cmd+shift+t" 这类键组合编译为 AppleScript 语句。
|
||||
func osaKeyChord(chord string) (string, error) {
|
||||
mods := map[string]string{
|
||||
"cmd": "command down", "command": "command down",
|
||||
"ctrl": "control down", "control": "control down",
|
||||
"alt": "option down", "option": "option down",
|
||||
"shift": "shift down", "fn": "fn down",
|
||||
}
|
||||
named := map[string]string{
|
||||
"enter": "36", "return": "36", "tab": "48", "escape": "53", "esc": "53",
|
||||
"delete": "51", "backspace": "51", "forwarddelete": "117",
|
||||
"up": "126", "down": "125", "left": "123", "right": "124",
|
||||
"space": "49", "home": "115", "end": "119", "pageup": "116", "pagedown": "121",
|
||||
"f1": "122", "f2": "120", "f3": "99", "f4": "118", "f5": "96", "f6": "97",
|
||||
"f7": "98", "f8": "100", "f9": "101", "f10": "109", "f11": "103", "f12": "111",
|
||||
}
|
||||
var using []string
|
||||
var main string
|
||||
for _, part := range strings.Split(strings.ToLower(strings.TrimSpace(chord)), "+") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return "", fmt.Errorf("empty key in chord %q", chord)
|
||||
}
|
||||
if m, ok := mods[part]; ok && main == "" {
|
||||
using = append(using, m)
|
||||
continue
|
||||
}
|
||||
if main != "" {
|
||||
return "", fmt.Errorf("multiple non-modifier keys in chord %q", chord)
|
||||
}
|
||||
if code, ok := named[part]; ok {
|
||||
main = "key code " + code
|
||||
continue
|
||||
}
|
||||
if len([]rune(part)) == 1 {
|
||||
main = "keystroke " + osaQuote(part)
|
||||
continue
|
||||
}
|
||||
return "", fmt.Errorf("unknown key %q", part)
|
||||
}
|
||||
if main == "" {
|
||||
return "", fmt.Errorf("chord %q has no main key", chord)
|
||||
}
|
||||
if len(using) > 0 {
|
||||
return main + " using {" + strings.Join(using, ", ") + "}", nil
|
||||
}
|
||||
return main, nil
|
||||
}
|
||||
|
||||
func runOSA(ctx context.Context, script string) (string, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
out, err := execx.Run(cctx, execx.Options{}, "osascript", "-e", script)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(string(out)), fmt.Errorf("osascript: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func hasCliclick() bool {
|
||||
_, err := exec.LookPath("cliclick")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// captureScreen 截屏到临时文件(region 非空时截取 "x,y,w,h")。
|
||||
// screencapture 在等屏幕录制授权弹窗时会一直不返回,所以必须有超时。
|
||||
func captureScreen(ctx context.Context, region string) (string, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
path := fmt.Sprintf("/tmp/autogo-shot-%d.png", time.Now().UnixNano())
|
||||
args := []string{"-x"}
|
||||
if region != "" {
|
||||
args = append(args, "-R", region)
|
||||
}
|
||||
args = append(args, path)
|
||||
out, err := execx.Run(cctx, execx.Options{}, "screencapture", args...)
|
||||
if err != nil {
|
||||
_ = os.Remove(path)
|
||||
return "", fmt.Errorf("screencapture: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return "", fmt.Errorf("screenshot was not created (check Screen Recording permission)")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func clickScript(x, y int, cliclick bool, kind string) string {
|
||||
if cliclick {
|
||||
switch kind {
|
||||
case "right":
|
||||
return fmt.Sprintf("cliclick rc:%d,%d", x, y)
|
||||
case "double":
|
||||
return fmt.Sprintf("cliclick dc:%d,%d", x, y)
|
||||
default:
|
||||
return fmt.Sprintf("cliclick c:%d,%d", x, y)
|
||||
}
|
||||
}
|
||||
switch kind {
|
||||
case "double":
|
||||
return fmt.Sprintf(`tell application "System Events" to click at {%d, %d} delay 0.05 click at {%d, %d}`, x, y, x, y)
|
||||
default:
|
||||
return fmt.Sprintf(`tell application "System Events" to click at {%d, %d}`, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
func runShell(ctx context.Context, cmdline string) (string, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
out, err := execx.Shell(cctx, execx.Options{}, cmdline)
|
||||
if err != nil {
|
||||
return strings.TrimSpace(string(out)), fmt.Errorf("%s: %w: %s", strings.Fields(cmdline)[0], err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
Reference in new issue
Block a user