//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 }