Files

586 lines
24 KiB
Python
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.
"""
macljqCtrl —— ljqCtrl 的 macOS 等价实现 (Quartz 键鼠与进程内截图)。
与 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 time, subprocess, 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)
Quartz.CGEventSetFlags(down, flags)
_post(down)
up = Quartz.CGEventCreateKeyboardEvent(None, keycode, False)
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')
# ---------- 截图 (进程内 Quartz, 输出物理像素) ----------
def _capture(rect, options, window=Quartz.kCGNullWindowID):
from io import BytesIO
from AppKit import NSBitmapImageRep, NSPNGFileType
flags = Quartz.kCGWindowImageBestResolution | Quartz.kCGWindowImageBoundsIgnoreFraming
cg = Quartz.CGWindowListCreateImage(rect, options, window, flags)
if cg is None:
raise RuntimeError('截图失败:请检查当前 Python 的录屏权限及目标窗口')
rep = NSBitmapImageRep.alloc().initWithCGImage_(cg)
data = rep.representationUsingType_properties_(NSPNGFileType, {})
with Image.open(BytesIO(bytes(data))) as img:
return img.copy()
def GrabScreen(bbox=None):
"""全屏或区域截图 -> PIL Image;bbox=(l,u,r,b)物理像素,图内点用CropToScreen转换。"""
rect = Quartz.CGRectInfinite
if bbox is not None:
l, u, r, b = bbox
if r <= l or b <= u:
raise ValueError('bbox must have positive width and height')
rect = Quartz.CGRectMake(l*dpi_scale, u*dpi_scale, (r-l)*dpi_scale, (b-u)*dpi_scale)
return _capture(rect, Quartz.kCGWindowListOptionOnScreenOnly)
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']
return _capture(Quartz.CGRectNull, Quartz.kCGWindowListOptionIncludingWindow, int(win))
# ---------- 窗口枚举 / 激活 (替代 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'])