Files
AutoZero/tools/computer_darwin.go
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

252 lines
8.6 KiB
Go
Raw Permalink 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 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
}