#!/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: # Match any Emacs variant: Emacs, Emacs-arm64-11, Emacs-x86_64-10_14, etc. out = subprocess.check_output( ["pgrep", "-f", "Emacs.app/Contents/MacOS/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()