refactor: Python ctypes daemon for macOS Zoom (no external tools)
This commit is contained in:
185
scripts/macos-zoom-daemon.py
Executable file
185
scripts/macos-zoom-daemon.py
Executable file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""macOS Zoom cursor tracking daemon for Emacs.
|
||||
|
||||
Reads frame-relative cursor coordinates from stdin (sent by Emacs via
|
||||
process-send-string), obtains the Emacs window position via macOS
|
||||
Accessibility API, and posts a CGEvent.mouseMoved so macOS Zoom
|
||||
"Follow mouse cursor" tracks the text cursor.
|
||||
|
||||
Usage: python3 macos-zoom-daemon.py [--debug]
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# --- Constants ---
|
||||
TITLE_BAR_H = int(os.environ.get("EMACS_ZOOM_TITLE_BAR_H", "22"))
|
||||
AX_CACHE_INTERVAL = 30 # refresh AX window frame every N events
|
||||
DEBUG = "--debug" in sys.argv
|
||||
|
||||
# --- ctypes structures ---
|
||||
class CGPoint(ctypes.Structure):
|
||||
_fields_ = [("x", ctypes.c_double), ("y", ctypes.c_double)]
|
||||
|
||||
class CGSize(ctypes.Structure):
|
||||
_fields_ = [("width", ctypes.c_double), ("height", ctypes.c_double)]
|
||||
|
||||
class CGRect(ctypes.Structure):
|
||||
_fields_ = [("origin", CGPoint), ("size", CGSize)]
|
||||
|
||||
# --- Load frameworks ---
|
||||
CG = ctypes.CDLL("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics")
|
||||
CF = ctypes.CDLL("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")
|
||||
AS = ctypes.CDLL("/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices")
|
||||
|
||||
# CGEvent functions
|
||||
CG.CGEventCreateMouseEvent.restype = ctypes.c_void_p
|
||||
CG.CGEventCreateMouseEvent.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_uint32, CGPoint, ctypes.c_uint32
|
||||
]
|
||||
CG.CGEventPost.restype = None
|
||||
CG.CGEventPost.argtypes = [ctypes.c_uint32, ctypes.c_void_p]
|
||||
|
||||
CF.CFRelease.restype = None
|
||||
CF.CFRelease.argtypes = [ctypes.c_void_p]
|
||||
|
||||
# AX functions
|
||||
AS.AXUIElementCreateApplication.restype = ctypes.c_void_p
|
||||
AS.AXUIElementCreateApplication.argtypes = [ctypes.c_int]
|
||||
|
||||
AS.AXUIElementCopyAttributeValue.restype = ctypes.c_int32
|
||||
AS.AXUIElementCopyAttributeValue.argtypes = [
|
||||
ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)
|
||||
]
|
||||
|
||||
AS.AXValueGetValue.restype = ctypes.c_bool
|
||||
AS.AXValueGetValue.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_void_p]
|
||||
|
||||
# CFString helper
|
||||
CF.CFStringCreateWithCString.restype = ctypes.c_void_p
|
||||
CF.CFStringCreateWithCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_uint32]
|
||||
kCFStringEncodingUTF8 = 0x08000100
|
||||
|
||||
def cfstr(s):
|
||||
return CF.CFStringCreateWithCString(None, s.encode("utf-8"), kCFStringEncodingUTF8)
|
||||
|
||||
# Pre-create CFStrings
|
||||
kAXFocusedWindow = cfstr("AXFocusedWindow")
|
||||
kAXPosition = cfstr("AXPosition")
|
||||
kAXSize = cfstr("AXSize")
|
||||
|
||||
# AXValue types
|
||||
kAXValueCGPointType = 1
|
||||
kAXValueCGSizeType = 2
|
||||
|
||||
# --- Emacs PID ---
|
||||
_emacs_pid = None
|
||||
|
||||
def get_emacs_pid():
|
||||
global _emacs_pid
|
||||
if _emacs_pid is not None:
|
||||
# Check if still alive
|
||||
try:
|
||||
os.kill(_emacs_pid, 0)
|
||||
return _emacs_pid
|
||||
except OSError:
|
||||
_emacs_pid = None
|
||||
try:
|
||||
out = subprocess.check_output(["pgrep", "-x", "Emacs"], text=True).strip()
|
||||
pids = out.split("\n")
|
||||
_emacs_pid = int(pids[0])
|
||||
return _emacs_pid
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# --- AX window frame ---
|
||||
_cached_origin = None # (x, y)
|
||||
_cache_counter = 0
|
||||
|
||||
def get_window_origin(force=False):
|
||||
global _cached_origin, _cache_counter
|
||||
_cache_counter += 1
|
||||
if not force and _cached_origin is not None and _cache_counter % AX_CACHE_INTERVAL != 0:
|
||||
return _cached_origin
|
||||
|
||||
pid = get_emacs_pid()
|
||||
if pid is None:
|
||||
return _cached_origin
|
||||
|
||||
try:
|
||||
app = AS.AXUIElementCreateApplication(pid)
|
||||
if not app:
|
||||
return _cached_origin
|
||||
|
||||
win = ctypes.c_void_p()
|
||||
err = AS.AXUIElementCopyAttributeValue(app, kAXFocusedWindow, ctypes.byref(win))
|
||||
CF.CFRelease(app)
|
||||
if err != 0 or not win.value:
|
||||
return _cached_origin
|
||||
|
||||
# Get position
|
||||
pos_val = ctypes.c_void_p()
|
||||
err = AS.AXUIElementCopyAttributeValue(win.value, kAXPosition, ctypes.byref(pos_val))
|
||||
if err != 0 or not pos_val.value:
|
||||
CF.CFRelease(win)
|
||||
return _cached_origin
|
||||
|
||||
point = CGPoint()
|
||||
ok = AS.AXValueGetValue(pos_val.value, kAXValueCGPointType, ctypes.byref(point))
|
||||
CF.CFRelease(pos_val)
|
||||
CF.CFRelease(win)
|
||||
|
||||
if ok:
|
||||
_cached_origin = (point.x, point.y)
|
||||
if DEBUG:
|
||||
print(f"[AX] window origin: {_cached_origin}", file=sys.stderr)
|
||||
return _cached_origin
|
||||
except Exception as e:
|
||||
if DEBUG:
|
||||
print(f"[AX] error: {e}", file=sys.stderr)
|
||||
return _cached_origin
|
||||
|
||||
# --- Post mouse event ---
|
||||
def post_mouse_moved(sx, sy):
|
||||
pt = CGPoint(sx, sy)
|
||||
evt = CG.CGEventCreateMouseEvent(None, 5, pt, 0) # 5 = kCGEventMouseMoved
|
||||
if evt:
|
||||
CG.CGEventPost(0, evt) # 0 = kCGHIDEventTap
|
||||
CF.CFRelease(evt)
|
||||
|
||||
# --- Main loop ---
|
||||
def main():
|
||||
if DEBUG:
|
||||
print(f"[daemon] started, TITLE_BAR_H={TITLE_BAR_H}", file=sys.stderr)
|
||||
|
||||
# Force initial AX query
|
||||
get_window_origin(force=True)
|
||||
|
||||
for line in sys.stdin:
|
||||
parts = line.strip().split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
try:
|
||||
ex, ey = float(parts[0]), float(parts[1])
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
origin = get_window_origin()
|
||||
if origin is None:
|
||||
if DEBUG:
|
||||
print("[daemon] no window origin, skipping", file=sys.stderr)
|
||||
continue
|
||||
|
||||
sx = origin[0] + ex
|
||||
sy = origin[1] + TITLE_BAR_H + ey
|
||||
|
||||
if DEBUG:
|
||||
print(f"[daemon] frame({ex:.0f},{ey:.0f}) → screen({sx:.0f},{sy:.0f})", file=sys.stderr)
|
||||
|
||||
post_mouse_moved(sx, sy)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user