591 lines
24 KiB
Python
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'])
|