Files
lsdefine--genericagent/memory/macljqCtrl.py
T
2026-07-13 13:04:19 +08:00

591 lines
24 KiB
Python

"""
macljqCtrl —— ljqCtrl 的 macOS 等价实现 (Quartz CGEvent + screencapture)。
与 ljqCtrl.py API 镜像对齐, 跨平台代码可写 `import macljqCtrl as ljqCtrl`。
CRITICAL: 严禁 import pyautogui。
依赖: pyobjc-framework-Quartz, pyobjc-framework-Cocoa (其余 numpy/opencv/Pillow 同 Windows 版)。
坐标约定 (与 Windows 版完全一致):
- 对外 API 接收【物理像素坐标】(= screencapture/ui_detect 截图内坐标)
- dpi_scale = 逻辑点 / 物理像素 (Retina=0.5, 普通屏=1.0)
- 逻辑坐标 = 物理坐标 * dpi_scale (CGEvent 内部用逻辑点)
权限前置 (缺失则键鼠/截图静默失败):
- 辅助功能(Accessibility): 系统设置>隐私与安全性>辅助功能, 授权 GA 宿主进程
- 屏幕录制(Screen Recording): 同上>屏幕录制
用 macljqCtrl.check_permissions() 自检。
API 快速参考:
- dpi_scale: float
- Click(x, y=None, check=True): 物理坐标; check=True 比较前后像素变化, 返回变化信息
- SetCursorPos((x,y)): 物理坐标移动鼠标
- Press(cmd, staytime=0): 快捷键, 如 'cmd+v' 'cmd+c' 'enter' 'cmd+shift+4'
- TypeText(s): 直接键入文本(Unicode, 无需剪贴板)
- MouseClick / MouseDClick / MouseDown / MouseUp / RightClick
- GrabWindow(win) -> PIL Image: 指定窗口截图(物理像素)。win=窗口号(int)或标题/应用名子串(str)
- GrabScreen(bbox=None) -> PIL Image: 全屏或区域截图, bbox=(l,u,r,b)物理像素
- ScreenCapAt(x, y, r=100) -> PIL Image: 物理坐标(x,y)±r 区域截图
- FindBlock(fn, wrect=None, threshold=0.8) -> ((cx,cy), is_found): 模板匹配, 物理坐标
- ListWindows(name=None) -> [dict]: 枚举窗口(替代 win32gui), 返回号/标题/应用/物理bbox
- ActivateApp(name): 激活应用到前台(替代 win32gui.SetForegroundWindow)
"""
import os, sys, time, subprocess, tempfile, math
import numpy as np
try:
from PIL import Image, ImageGrab, ImageEnhance, ImageFilter, ImageDraw
import cv2
_HAS_CV2 = True
except Exception:
_HAS_CV2 = False
import Quartz
from AppKit import NSScreen, NSWorkspace, NSPasteboard, NSStringPboardType, NSRunningApplication
verbose_click = True # Click 像素变化打印开关
# ---------- 屏幕几何 & dpi_scale ----------
_main = Quartz.CGMainDisplayID()
_bounds = Quartz.CGDisplayBounds(_main)
cwidth = int(_bounds.size.width) # 逻辑点宽 (CGEvent 坐标系)
cheight = int(_bounds.size.height)
_mode = Quartz.CGDisplayCopyDisplayMode(_main)
swidth = int(Quartz.CGDisplayModeGetPixelWidth(_mode)) # 物理像素宽
sheight = int(Quartz.CGDisplayModeGetPixelHeight(_mode))
dpi_scale = cwidth / swidth if swidth else 1.0 # 逻辑/物理, Retina=0.5
def check_permissions(verbose=True):
"""返回 (accessibility_ok, screen_recording_ok)。缺失时打印授权指引。"""
sc = bool(Quartz.CGPreflightScreenCaptureAccess()) if hasattr(Quartz, 'CGPreflightScreenCaptureAccess') else None
ax = None
try:
import HIServices
ax = bool(HIServices.AXIsProcessTrusted())
except Exception:
try:
from ApplicationServices import AXIsProcessTrusted
ax = bool(AXIsProcessTrusted())
except Exception:
ax = None
if verbose:
print(f'[PERM] Accessibility(键鼠): {ax} ScreenRecording(截图): {sc}')
if ax is False:
print(' → 系统设置>隐私与安全性>辅助功能, 勾选 GA 宿主进程后重启 GA')
if sc is False:
print(' → 系统设置>隐私与安全性>屏幕录制, 勾选 GA 宿主进程后重启 GA')
return ax, sc
# ---------- 鼠标 ----------
def _post(ev):
Quartz.CGEventPost(Quartz.kCGHIDEventTap, ev)
def _cursor_logical():
e = Quartz.CGEventCreate(None)
loc = Quartz.CGEventGetLocation(e)
return loc.x, loc.y
def _phys_to_logical(x, y):
return x * dpi_scale, y * dpi_scale
def SetCursorPos(z):
"""z=(x,y) 物理坐标。移动鼠标(不点击)。"""
lx, ly = _phys_to_logical(int(z[0]), int(z[1]))
ev = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventMouseMoved,
Quartz.CGPointMake(lx, ly), Quartz.kCGMouseButtonLeft)
_post(ev)
time.sleep(0.05)
def _mouse_event(etype, button=Quartz.kCGMouseButtonLeft):
lx, ly = _cursor_logical()
ev = Quartz.CGEventCreateMouseEvent(None, etype, Quartz.CGPointMake(lx, ly), button)
_post(ev)
def MouseDown():
_mouse_event(Quartz.kCGEventLeftMouseDown)
def MouseUp():
_mouse_event(Quartz.kCGEventLeftMouseUp)
def MouseClick(staytime=0.05):
MouseDown(); time.sleep(staytime)
MouseUp(); time.sleep(0.05)
def MouseDClick(staytime=0.05):
# 真双击: 同坐标连发2次, 用 click state=2
lx, ly = _cursor_logical()
p = Quartz.CGPointMake(lx, ly)
for state in (1, 2):
down = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDown, p, Quartz.kCGMouseButtonLeft)
Quartz.CGEventSetIntegerValueField(down, Quartz.kCGMouseEventClickState, state)
_post(down)
up = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseUp, p, Quartz.kCGMouseButtonLeft)
Quartz.CGEventSetIntegerValueField(up, Quartz.kCGMouseEventClickState, state)
_post(up)
time.sleep(0.05)
def RightClick(staytime=0.05):
lx, ly = _cursor_logical()
p = Quartz.CGPointMake(lx, ly)
down = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseDown, p, Quartz.kCGMouseButtonRight)
_post(down); time.sleep(staytime)
up = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseUp, p, Quartz.kCGMouseButtonRight)
_post(up); time.sleep(0.05)
# ---------- 键盘 ----------
# macOS 虚拟键码 (kVK_*)
_KEYMAP = {
'a':0,'s':1,'d':2,'f':3,'h':4,'g':5,'z':6,'x':7,'c':8,'v':9,'b':11,'q':12,
'w':13,'e':14,'r':15,'y':16,'t':17,'1':18,'2':19,'3':20,'4':21,'6':22,'5':23,
'=':24,'9':25,'7':26,'-':27,'8':28,'0':29,']':30,'o':31,'u':32,'[':33,'i':34,
'p':35,'l':37,'j':38,"'":39,'k':40,';':41,'\\':42,',':43,'/':44,'n':45,'m':46,
'.':47,'`':50,
'enter':36,'return':36,'tab':48,'space':49,' ':49,'delete':51,'backspace':51,
'esc':53,'escape':53,'forwarddelete':117,
'left':123,'right':124,'down':125,'up':126,
'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,
}
_MODS = {
'cmd':Quartz.kCGEventFlagMaskCommand, 'command':Quartz.kCGEventFlagMaskCommand,
'ctrl':Quartz.kCGEventFlagMaskControl, 'control':Quartz.kCGEventFlagMaskControl,
'alt':Quartz.kCGEventFlagMaskAlternate, 'option':Quartz.kCGEventFlagMaskAlternate,
'opt':Quartz.kCGEventFlagMaskAlternate,
'shift':Quartz.kCGEventFlagMaskShift,
'fn':Quartz.kCGEventFlagMaskSecondaryFn,
}
def _key_tap(keycode, flags=0):
down = Quartz.CGEventCreateKeyboardEvent(None, keycode, True)
if flags: Quartz.CGEventSetFlags(down, flags)
_post(down)
up = Quartz.CGEventCreateKeyboardEvent(None, keycode, False)
if flags: Quartz.CGEventSetFlags(up, flags)
_post(up)
def Press(cmd, staytime=0):
"""快捷键。如 'cmd+v' 'cmd+shift+4' 'enter' 'cmd+c'。Win版的 ctrl 在mac多对应 cmd, 调用方自行决定。"""
parts = [p.strip().lower() for p in str(cmd).split('+') if p.strip()]
flags = 0; key = None
for p in parts:
if p in _MODS: flags |= _MODS[p]
else: key = p
if key is None:
return
kc = _KEYMAP.get(key)
if kc is None:
# 单字符走 TypeText
TypeText(key)
else:
_key_tap(kc, flags)
if staytime: time.sleep(staytime)
time.sleep(0.03)
def TypeText(s):
"""直接键入 Unicode 文本 (无需剪贴板)。"""
for ch in str(s):
ev = Quartz.CGEventCreateKeyboardEvent(None, 0, True)
Quartz.CGEventKeyboardSetUnicodeString(ev, len(ch), ch)
_post(ev)
ev2 = Quartz.CGEventCreateKeyboardEvent(None, 0, False)
Quartz.CGEventKeyboardSetUnicodeString(ev2, len(ch), ch)
_post(ev2)
time.sleep(0.005)
# 剪贴板 (替代 pyperclip)
def set_clipboard(text):
pb = NSPasteboard.generalPasteboard()
pb.clearContents()
pb.setString_forType_(text, NSStringPboardType)
def get_clipboard():
pb = NSPasteboard.generalPasteboard()
return pb.stringForType_(NSStringPboardType)
def Paste(text):
"""把 text 放剪贴板并 cmd+v 粘贴 (等价 Win版 pyperclip+ctrl+v)。"""
set_clipboard(text); time.sleep(0.05); Press('cmd+v')
# ---------- 截图 (screencapture, 输出物理像素) ----------
def GrabScreen(bbox=None):
"""全屏或区域截图 -> PIL Image。bbox=(l,u,r,b) 物理像素坐标。
传 bbox 后图内坐标相对裁剪原点; 转屏幕绝对坐标用 CropToScreen, 勿手搓 screencapture -R。
"""
fd, fn = tempfile.mkstemp(suffix='.png'); os.close(fd)
try:
if bbox:
l, u, r, b = bbox
# screencapture -R 用逻辑点; 转换 物理->逻辑
lx, ly = l*dpi_scale, u*dpi_scale
lw, lh = (r-l)*dpi_scale, (b-u)*dpi_scale
cmd = ['/usr/sbin/screencapture','-x','-t','png',
f'-R{lx:.0f},{ly:.0f},{lw:.0f},{lh:.0f}', fn]
else:
cmd = ['/usr/sbin/screencapture','-x','-t','png', fn]
subprocess.run(cmd, check=True, capture_output=True)
return Image.open(fn).copy()
finally:
try: os.remove(fn)
except Exception: pass
def ScreenCapAt(x, y, r=100):
"""物理坐标(x,y)为中心±r 截图 -> PIL Image。"""
return GrabScreen((x-r, y-r, x+r, y+r))
def CropToScreen(bbox, x, y=None):
"""裁剪图内坐标 -> 屏幕绝对物理坐标。bbox=GrabScreen 用的 (l,u,r,b) 物理像素。
(x,y)=在 GrabScreen(bbox) 返回图内找到的点。返回可直接喂给 Click 的 (X,Y)。
macOS 版的 ClientToScreen: 纯加裁剪原点偏移, 不做缩放(裁剪图与 bbox 同物理像素)。"""
if y is None and isinstance(x, (tuple, list)):
x, y = x[0], x[1]
return int(bbox[0] + x), int(bbox[1] + y)
def GrabWindow(win):
"""窗口截图 -> PIL Image。win=窗口号(int) 或 标题/应用名子串(str)。物理像素。"""
if isinstance(win, str):
ws = ListWindows(win)
if not ws: raise RuntimeError(f'window not found: {win}')
win = ws[0]['id']
fd, fn = tempfile.mkstemp(suffix='.png'); os.close(fd)
try:
subprocess.run(['/usr/sbin/screencapture','-x','-o','-l',str(win),'-t','png',fn],
check=True, capture_output=True)
return Image.open(fn).copy()
finally:
try: os.remove(fn)
except Exception: pass
# ---------- 窗口枚举 / 激活 (替代 win32gui) ----------
def ListWindows(name=None):
"""枚举屏上窗口 -> [{'id','title','app','bbox'(物理像素 l,u,r,b),'pid'}]。
name: 标题或应用名子串过滤(不区分大小写)。"""
opts = Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements
infos = Quartz.CGWindowListCopyWindowInfo(opts, Quartz.kCGNullWindowID)
out = []
inv = 1.0 / dpi_scale # 逻辑->物理
for w in infos:
b = w.get('kCGWindowBounds') or {}
layer = w.get('kCGWindowLayer', 0)
if layer != 0: # 只要普通应用窗口层
continue
title = w.get('kCGWindowName') or ''
app = w.get('kCGWindowOwnerName') or ''
l = b.get('X', 0)*inv; u = b.get('Y', 0)*inv
r = l + b.get('Width', 0)*inv; bo = u + b.get('Height', 0)*inv
rec = {'id': int(w.get('kCGWindowNumber', 0)), 'title': title, 'app': app,
'pid': int(w.get('kCGWindowOwnerPID', 0)),
'bbox': (int(l), int(u), int(r), int(bo))}
if name:
n = name.lower()
if n not in title.lower() and n not in app.lower():
continue
out.append(rec)
return out
def ActivateApp(target):
"""激活应用到前台 (替代 SetForegroundWindow)。
macOS 的前台是 *应用* 粒度而非窗口句柄粒度, 故无法 1:1 镜像 Win 版
Activate(hwnd)。target 支持两种定位键, 优先用精确的:
- int : 进程 pid (来自 ListWindows 的 'pid' 字段) —— 精确, 不受语言/本地化影响, **推荐**
- str : 应用名 / bundle id。按精确度分级匹配, 避免误中同厂商应用:
① bundle id 精确等值 (如 'com.tencent.meeting') —— 最可靠
② localizedName 精确等值 (如 '腾讯会议')
③ localizedName 子串 (最后兜底, 可能模糊)
注意: 不对 bundle id 做子串匹配, 因同厂商应用共享前缀
(微信 com.tencent.xinWeChat 与腾讯会议 com.tencent.meeting 都含 'tencent')。
返回是否成功。"""
ws = NSWorkspace.sharedWorkspace()
# 1) pid 精确激活 (首选)
if isinstance(target, int):
app = NSRunningApplication.runningApplicationWithProcessIdentifier_(target)
if app is not None:
app.activateWithOptions_(1 << 1) # NSApplicationActivateAllWindows
time.sleep(0.3)
return True
return False
# 2) 字符串: 按精确度分级匹配 (避免同厂商前缀误伤)
key = str(target)
keyl = key.lower()
apps = list(ws.runningApplications())
def _fire(app):
app.activateWithOptions_(1 << 1)
time.sleep(0.3)
return True
# ① bundle id 精确等值
for app in apps:
if (app.bundleIdentifier() or '').lower() == keyl:
return _fire(app)
# ② localizedName 精确等值
for app in apps:
if (app.localizedName() or '').lower() == keyl:
return _fire(app)
# ③ localizedName 子串 (兜底)
for app in apps:
if keyl in (app.localizedName() or '').lower():
return _fire(app)
# 3) 兜底用 open -a
try:
subprocess.run(['open', '-a', str(target)], check=True, capture_output=True)
time.sleep(0.5); return True
except Exception:
return False
# ---------- 模板匹配 FindBlock ----------
def GetWRect(sr):
"""快捷区域名 -> 物理像素 [l,u,r,b]。如 'left2'=左半屏, 'top3'=上1/3。"""
num = int(sr[-1])
l, u, r, b = 0, 0, swidth, sheight
if 'left' in sr: r = swidth // num
if 'right' in sr: l = swidth * (num - 1) // num
if 'top' in sr: b = sheight // num
if 'bottom' in sr: u = sheight * (num - 1) // num
return [l, u, r, b]
def FindBlock(fn, wrect=None, verbose=0, threshold=0.8):
"""在屏幕(或wrect区域)内找模板图 fn。返回 ((cx,cy), is_found), 物理坐标。
fn: 模板图路径(str)或 PIL Image。
wrect: None=全屏 | [l,u,r,b]物理像素 | 'left2'等快捷名 | PIL Image(直接当搜索底图)。"""
if not _HAS_CV2:
raise RuntimeError('FindBlock 需要 opencv-python 和 Pillow')
if wrect is not None and isinstance(wrect, Image.Image):
scr, wrect = wrect, None
else:
if isinstance(wrect, str): wrect = GetWRect(wrect)
scr = GrabScreen(wrect) # 物理像素
blc = Image.open(fn) if isinstance(fn, str) else fn
T = cv2.cvtColor(np.array(blc), cv2.COLOR_RGB2BGR)
B = cv2.cvtColor(np.array(scr), cv2.COLOR_RGB2BGR)
tsh, tsw = T.shape[:2]
res = cv2.matchTemplate(B, T, cv2.TM_CCOEFF_NORMED)
_, max_val, _, max_loc = cv2.minMaxLoc(res)
oj, oi = max_loc
if wrect is None: wrect = [0, 0, scr.size[0], scr.size[1]]
obj = (oj + wrect[0] + tsw // 2, oi + wrect[1] + tsh // 2)
if verbose:
print(f'FindBlock {fn}: score={max_val:.4f} at phys={obj}')
return obj, max_val > threshold
def imshow(mt, sec=0):
cv2.imshow('cc', mt); cv2.waitKey(sec)
# ---------- Click (带像素变化检测) ----------
def Click(x, y=None, check=True, r=60):
"""物理坐标点击。check=True 时比较点击前后周边像素变化, 返回 (变化百分比, 截图)。
若变化≈0 → 可能点歪了 (同 Win 版语义)。"""
if y is None and isinstance(x, (tuple, list)):
x, y = x[0], x[1]
x, y = int(x), int(y)
before = None
if check:
try: before = np.array(ScreenCapAt(x, y, r))
except Exception: before = None
SetCursorPos((x, y)); time.sleep(0.05)
MouseClick()
if check:
time.sleep(0.25)
try:
after = np.array(ScreenCapAt(x, y, r))
except Exception:
return None
if before is not None and before.shape == after.shape:
diff = np.mean(np.any(before != after, axis=-1)) * 100
if verbose_click:
print(f'Click({x},{y}) pixel change: {diff:.1f}%')
if diff < 0.5:
print(f'[WARN] Click({x},{y}) 像素变化≈0%, 可能点歪了, 请诊断坐标! '
'常见错因: 用了裁剪图内坐标却没加裁剪原点(用 CropToScreen), '
'或对已是物理像素的坐标又做了 *dpi_scale 换算。')
return diff, Image.fromarray(after)
return None
# ---------- AX 辅助功能控件枚举 (UIA 层的 macOS 等价) ----------
try:
from ApplicationServices import (
AXUIElementCreateApplication, AXUIElementCopyAttributeValue,
AXValueGetValue, AXUIElementPerformAction, AXIsProcessTrusted,
kAXChildrenAttribute, kAXRoleAttribute, kAXDescriptionAttribute,
kAXPositionAttribute, kAXSizeAttribute, kAXWindowsAttribute,
kAXTitleAttribute, kAXValueCGPointType, kAXValueCGSizeType,
kAXPressAction, kAXEnabledAttribute,
)
_HAS_AX = True
except ImportError:
_HAS_AX = False
def _resolve_pid(target):
"""target(int pid | str bundle_id/应用名) → pid(int)。
str 优先按 bundle id 精确匹配, 再按 localizedName 精确/子串兜底,
与 ActivateApp 的匹配纪律一致, 避免同厂商前缀误伤。"""
if isinstance(target, int):
return int(target)
key = str(target); keyl = key.lower()
ws = NSWorkspace.sharedWorkspace()
apps = list(ws.runningApplications())
for a in apps: # ① bundle id 精确
if (a.bundleIdentifier() or '') == key:
return int(a.processIdentifier())
for a in apps: # ② localizedName 精确
if (a.localizedName() or '').lower() == keyl:
return int(a.processIdentifier())
for a in apps: # ③ localizedName 子串
if keyl in (a.localizedName() or '').lower():
return int(a.processIdentifier())
raise ValueError(f'找不到 target={target!r} 对应的运行中应用')
def _ax_attr(el, key):
"""读取单个 AX 属性,失败返回 None。"""
if not _HAS_AX:
return None
err, val = AXUIElementCopyAttributeValue(el, key, None)
return val if err == 0 else None
def AXElements(target, max_depth=10, include_zero_size=False):
"""枚举应用控件树。
Parameters
----------
target : int | str
pid(int) 或 bundle_id(str, 如 'com.tencent.meeting')。
max_depth : int
递归深度上限。
include_zero_size : bool
是否包含 w<=0 或 h<=0 的零尺寸节点。
Returns
-------
list[dict] : 每项含 role/desc/title/id/value/x/y/w/h(物理像素)/depth/el。
value 为控件当前值(文本/输入框内容等),非文本值为 None。
el 是原始 AXUIElement 引用,目标窗口关闭/重建后失效,AXPress 前宜就近重新枚举。
"""
if not _HAS_AX:
raise RuntimeError(
'AX 不可用。请安装: pip install pyobjc-framework-ApplicationServices')
if not AXIsProcessTrusted():
raise PermissionError('需要授予辅助功能权限(系统设置 > 隐私与安全 > 辅助功能)')
pid = _resolve_pid(target)
app_el = AXUIElementCreateApplication(pid)
wins = _ax_attr(app_el, kAXWindowsAttribute) or []
scale = dpi_scale # 逻辑/物理, Retina=0.5
results = []
def _walk(el, depth):
if depth > max_depth:
return
role = _ax_attr(el, kAXRoleAttribute)
desc = _ax_attr(el, kAXDescriptionAttribute)
title = _ax_attr(el, kAXTitleAttribute)
ident = _ax_attr(el, 'AXIdentifier')
value = _ax_attr(el, 'AXValue')
enabled = _ax_attr(el, kAXEnabledAttribute)
# AXValue 多为 str/num(文本/输入框);若是 AXValueRef(坐标等)忽略
if value is not None and not isinstance(value, (str, int, float, bool)):
value = None
pos_val = _ax_attr(el, kAXPositionAttribute)
sz_val = _ax_attr(el, kAXSizeAttribute)
# 解包坐标(逻辑点)
px = py_ = pw = ph = 0.0
if pos_val is not None:
ok, pt = AXValueGetValue(pos_val, kAXValueCGPointType, None)
if ok:
px, py_ = pt.x, pt.y
if sz_val is not None:
ok, sz = AXValueGetValue(sz_val, kAXValueCGSizeType, None)
if ok:
pw, ph = sz.width, sz.height
# 转物理像素
phys_x = px / scale if scale else px
phys_y = py_ / scale if scale else py_
phys_w = pw / scale if scale else pw
phys_h = ph / scale if scale else ph
if not include_zero_size and (phys_w <= 0 or phys_h <= 0):
pass # 跳过零尺寸,但仍递归子节点
else:
results.append(dict(
depth=depth, role=role, desc=desc, title=title, id=ident,
value=value, enabled=bool(enabled) if enabled is not None else None,
x=round(phys_x), y=round(phys_y),
w=round(phys_w), h=round(phys_h), el=el))
for child in (_ax_attr(el, kAXChildrenAttribute) or []):
_walk(child, depth + 1)
for win in wins:
_walk(win, 0)
return results
def AXPress(element) -> bool:
"""对 AX element 执行 Press 动作(免坐标点击)。"""
if not _HAS_AX:
return False
err = AXUIElementPerformAction(element, kAXPressAction)
return err == 0
def AXClick(node, check=True) -> bool:
"""点击控件: AXPress 优先(免坐标), 失败回退到中心点物理坐标 Click。
node: AXFind/AXElements 返回的 dict(含 el 与 x/y/w/h), 或裸 AXUIElement。
返回是否点击成功(回退路径据像素变化判定, check=False 时无法判定按 True)。
呼应 computer_use SOP: AXPress 优先, 回退 Click(phys_cx, phys_cy)。"""
if not isinstance(node, dict):
return AXPress(node)
if node.get('enabled') is False:
print(f"[WARN] AXClick: 控件 disabled (role={node.get('role')}, "
f"title={node.get('title')!r}), 点击可能无效")
if AXPress(node.get('el')):
return True
# 回退: 中心点物理坐标
cx = node['x'] + node['w'] // 2
cy = node['y'] + node['h'] // 2
res = Click(cx, cy, check=check)
if not check or res is None:
return check is False # 无法判定时: 关检查按成功, 截图失败按失败
diff, _ = res
return diff >= 0.5
def AXFind(target, role=None, desc=None, title=None, identifier=None,
enabled_only=False, max_depth=10):
"""枚举并过滤控件。所有过滤条件为子串匹配(大小写不敏感)。
enabled_only=True 时只返回 enabled 的控件(SOP: 点前查 disabled)。
Returns
-------
list[dict] : 匹配项,同 AXElements 返回格式。
"""
def _hit(field, needle):
return needle is None or (field and needle.lower() in field.lower())
return [n for n in AXElements(target, max_depth=max_depth)
if _hit(n['role'], role) and _hit(n['desc'], desc)
and _hit(n['title'], title) and _hit(n['id'], identifier)
and not (enabled_only and n.get('enabled') is False)]
# ---------- API 镜像别名 (drop-in 替换 ljqCtrl 用) ----------
click = Click
press = Press
activate = ActivateApp # 注意: Win 版 Activate(hwnd) 收窗口句柄, Mac 版收应用名子串
GrabWindowBg = GrabWindow # Mac screencapture 本身支持后台窗口
VK_CODE = _KEYMAP # 名称兼容
if __name__ == '__main__':
print('--- macljqCtrl self-check ---')
check_permissions()
print('cursor(logical):', _cursor_logical())
print('windows(top5):')
for w in ListWindows()[:5]:
print(' ', w['id'], '|', w['app'], '|', w['title'][:30], '|', w['bbox'])