Files
emacs-doom/scripts/macos-zoom-daemon.py

189 lines
5.6 KiB
Python
Executable File

#!/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()