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。
252 lines
8.6 KiB
Go
252 lines
8.6 KiB
Go
//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
|
||
}
|