"""cursor.py — o cursor do Primo: o mouse de verdade, com a cara dele.

    "Quando ele tá no modo, eu consigo ver o cursor dele, o mouse, ver ele
     digitando, abrindo as coisas, ver ele fazer as coisas em tempo real."
     (23/09/2026)

── O QUE É ─────────────────────────────────────────────────────────────────
Um processo pequeno, só do macOS, que o Node sobe na primeira ação no
computador. Ele faz duas coisas ao mesmo tempo, e é a soma das duas que dá a
sensação de "ver o Primo trabalhando":

1. MEXE NO MOUSE E NO TECLADO DE VERDADE, pelo Quartz (`CGEventPost`). Não é
   um cursor de enfeite correndo por cima: o clique acontece no app que está
   embaixo. Isso também aposenta o `cliclick`, que precisava de Homebrew e,
   sem ele, fazia TODO clique falhar — foi por isso que "mostra o teu mouse"
   virou uma página HTML com coordenadas.

2. DESENHA O CURSOR DELE por cima do de verdade: uma seta escura com contorno
   claro e brilho na cor da marca, um anel em volta, uma onda a cada clique e
   uma etiqueta curta do que está fazendo ("digitando", "abrindo Safari").
   Numa janela transparente, acima de tudo, que NÃO recebe clique (o clique
   atravessa para o app) e que não aparece em captura de tela (o print que o
   agente tira mostra a tela, não o cursor dele).

O desenho é o do Jarvis (~/Jarvis/src/jarvis/ui/web/hud.html e
motor/pointer.py): o mesmo trajeto em curva com aceleração suave — reta
parece robô — e o mesmo sumiço 1,6 s depois da última ação, devolvendo a tela
ao cursor da pessoa.

── O PROTOCOLO ─────────────────────────────────────────────────────────────
Uma linha de JSON por comando no stdin, uma de resposta no stdout:

    {"id": 1, "cmd": "clicar", "x": 400, "y": 300, "botao": "esquerdo", "duplo": false}
    {"id": 1, "ok": true}

Comandos: mover, clicar, arrastar, rolar, digitar, tecla, rotulo, ocupado,
esconder, permissao, tamanho. Coordenadas em PONTOS da tela principal, com a
origem no canto de cima à esquerda — as mesmas do System Events, que é de onde
vêm as posições dos botões.
"""
from __future__ import annotations

import json
import math
import random
import sys
import threading
import time

import objc
import Quartz
from AppKit import (NSApplication, NSBezierPath, NSColor, NSFont, NSMakeRect,
                    NSObject, NSScreen, NSShadow, NSTimer, NSView, NSWindow,
                    NSBackingStoreBuffered, NSCompositingOperationClear,
                    NSRectFillUsingOperation, NSFontAttributeName,
                    NSForegroundColorAttributeName)
from Foundation import NSString
from PyObjCTools import AppHelper

# ── A cara ──────────────────────────────────────────────────────────────────
CORES = {
    "normal": (0.37, 0.91, 1.0),     # ciano da marca
    "agindo": (1.0, 0.71, 0.32),     # âmbar: está mexendo em alguma coisa
}
PASSOS_HZ = 120            # quantas vezes por segundo o mouse anda no trajeto
SOME_DEPOIS = 1.6          # segundos parado até o cursor dele sumir
NIVEL = 1000               # NSScreenSaverWindowLevel: acima de qualquer app


def _cor(rgb, alfa=1.0):
    return NSColor.colorWithSRGBRed_green_blue_alpha_(rgb[0], rgb[1], rgb[2], alfa)


# ── O mouse e o teclado de verdade ──────────────────────────────────────────

def _post(evento) -> None:
    Quartz.CGEventPost(Quartz.kCGHIDEventTap, evento)


def posicao():
    p = Quartz.CGEventGetLocation(Quartz.CGEventCreate(None))
    return float(p.x), float(p.y)


def pode_agir() -> bool:
    """O sistema deixa este processo mexer no mouse e no teclado?

    `CGPreflightPostEventAccess` e não `AXIsProcessTrusted`: este último fica
    em cache pela vida do processo, e continuava dizendo "não" depois de a
    pessoa conceder a permissão (medido no Jarvis, motor/permissions.py).
    """
    try:
        return bool(Quartz.CGPreflightPostEventAccess())
    except Exception:
        return True       # macOS antigo sem a função: tenta e vê


def pedir_permissao() -> None:
    try:
        Quartz.CGRequestPostEventAccess()
    except Exception:
        pass


# Códigos de tecla do layout americano: o atalho é pelo CÓDIGO, não pela
# letra, então vale em qualquer layout de teclado.
TECLAS = {
    "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, "espaco": 49, "espaço": 49,
    "delete": 51, "backspace": 51, "apagar": 51, "esc": 53, "escape": 53,
    "forwarddelete": 117, "del": 117, "home": 115, "end": 119, "pageup": 116,
    "pagedown": 121, "left": 123, "esquerda": 123, "right": 124, "direita": 124,
    "down": 125, "baixo": 125, "up": 126, "cima": 126,
    "f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97, "f7": 98,
    "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111,
}
MODIFICADORES = {
    "cmd": Quartz.kCGEventFlagMaskCommand, "command": Quartz.kCGEventFlagMaskCommand,
    "super": Quartz.kCGEventFlagMaskCommand, "win": Quartz.kCGEventFlagMaskCommand,
    "meta": Quartz.kCGEventFlagMaskCommand,
    "ctrl": Quartz.kCGEventFlagMaskControl, "control": Quartz.kCGEventFlagMaskControl,
    "alt": Quartz.kCGEventFlagMaskAlternate, "option": Quartz.kCGEventFlagMaskAlternate,
    "opt": Quartz.kCGEventFlagMaskAlternate,
    "shift": Quartz.kCGEventFlagMaskShift,
}


def combo(nome: str):
    """"cmd+shift+t" → (código, flags). None se a tecla não for conhecida."""
    partes = [p.strip().lower() for p in str(nome).replace(" ", "").split("+") if p.strip()]
    if not partes:
        return None
    flags = 0
    for p in partes[:-1]:
        if p not in MODIFICADORES:
            return None
        flags |= MODIFICADORES[p]
    tecla = partes[-1]
    if tecla not in TECLAS:
        return None
    return TECLAS[tecla], flags


def apertar(codigo: int, flags: int = 0) -> None:
    for desce in (True, False):
        ev = Quartz.CGEventCreateKeyboardEvent(None, codigo, desce)
        if flags:
            Quartz.CGEventSetFlags(ev, flags)
        _post(ev)
        time.sleep(0.012)


def digitar(texto: str) -> None:
    """Letra por letra, pelo caractere — acento e emoji saem certos em
    qualquer layout de teclado. Quebra de linha é Return de verdade."""
    for ch in str(texto):
        if ch == "\n":
            apertar(36)
        elif ch == "\t":
            apertar(48)
        else:
            for desce in (True, False):
                ev = Quartz.CGEventCreateKeyboardEvent(None, 0, desce)
                Quartz.CGEventKeyboardSetUnicodeString(ev, len(ch), ch)
                _post(ev)
            time.sleep(0.008)


# ── Ler a tela (OCR) ──────────────────────────────────────────────────────
# "Clicar no texto X" em QUALQUER app, e não só nos que expõem botões pela
# acessibilidade: fotografa a tela e reconhece o texto com o Vision do macOS
# (o mesmo do Jarvis, motor/screen.py). Cada trecho volta com o CENTRO em
# pontos da tela — a mesma unidade dos cliques.

def tela_liberada() -> bool:
    try:
        return bool(Quartz.CGPreflightScreenCaptureAccess())
    except Exception:
        return True


def ler_tela(largura: float, altura: float, arquivo: str = "") -> dict:
    try:
        import Vision
    except Exception:
        return {"ok": False, "error": "falta o pyobjc-framework-Vision para ler a tela"}
    if arquivo:
        # Só para o teste: lê uma imagem do disco em vez da tela.
        from Foundation import NSURL
        fonte = Quartz.CGImageSourceCreateWithURL(NSURL.fileURLWithPath_(arquivo), None)
        img = Quartz.CGImageSourceCreateImageAtIndex(fonte, 0, None) if fonte else None
    else:
        if not tela_liberada():
            try:
                Quartz.CGRequestScreenCaptureAccess()
            except Exception:
                pass
            return {"ok": False, "permissaoTela": False,
                    "error": "o macOS não deixou ver a tela. Libere o Terminal (ou o app de onde você "
                             "abriu o PrimoCode) em Ajustes do Sistema > Privacidade e Segurança > "
                             "Gravação de Tela, e peça de novo."}
        img = Quartz.CGWindowListCreateImage(Quartz.CGRectInfinite, Quartz.kCGWindowListOptionOnScreenOnly,
                                             Quartz.kCGNullWindowID, Quartz.kCGWindowImageDefault)
    if img is None:
        return {"ok": False, "error": "não consegui fotografar a tela"}

    pedido = Vision.VNRecognizeTextRequest.alloc().init()
    pedido.setRecognitionLevel_(Vision.VNRequestTextRecognitionLevelAccurate)
    pedido.setUsesLanguageCorrection_(True)
    try:
        pedido.setRecognitionLanguages_(["pt-BR", "en-US"])
    except Exception:
        pass
    leitor = Vision.VNImageRequestHandler.alloc().initWithCGImage_options_(img, None)
    ok, erro = leitor.performRequests_error_([pedido], None)
    if not ok:
        return {"ok": False, "error": f"o Vision não leu a tela: {erro}"}
    textos = []
    for obs in (pedido.results() or []):
        candidatos = obs.topCandidates_(1)
        if not candidatos:
            continue
        texto = str(candidatos[0].string()).strip()
        if not texto:
            continue
        bb = obs.boundingBox()      # normalizado, origem embaixo à esquerda
        cx = (bb.origin.x + bb.size.width / 2) * largura
        cy = (1 - (bb.origin.y + bb.size.height / 2)) * altura
        textos.append({"texto": texto, "x": round(cx), "y": round(cy),
                       "confianca": round(float(candidatos[0].confidence()), 2)})
    textos.sort(key=lambda t: (t["y"], t["x"]))
    return {"ok": True, "textos": textos}


# ── A janela do cursor ──────────────────────────────────────────────────────

class VistaCursor(NSView):
    """Pinta o cursor do Primo. Coordenadas viradas: y cresce para baixo,
    como as do Quartz — o mesmo número serve para os dois."""

    def initWithFrame_(self, quadro):
        self = objc.super(VistaCursor, self).initWithFrame_(quadro)
        if self is None:
            return None
        self.x, self.y = posicao()
        self.alfa = 0.0          # 0..1, para aparecer e sumir sem piscar
        self.visivel = False
        self.estado = "normal"
        self.rotulo = ""
        self.ocupado = False
        self.ondas = []          # [(x, y, começo)] de cada clique
        self.fase = 0.0
        self.ultima = 0.0        # hora da última ação
        return self

    def isFlipped(self):
        return True

    def isOpaque(self):
        return False

    def drawRect_(self, rect):
        NSColor.clearColor().set()
        NSRectFillUsingOperation(self.bounds(), NSCompositingOperationClear)
        if self.alfa <= 0.01 and not self.ondas:
            return
        a = self.alfa
        cor = CORES.get(self.estado, CORES["normal"])
        x, y = self.x, self.y
        agora = time.time()

        # A ONDA de cada clique: um anel que abre e some em meio segundo.
        vivas = []
        for (ox, oy, t0) in self.ondas:
            t = (agora - t0) / 0.5
            if t >= 1:
                continue
            vivas.append((ox, oy, t0))
            r = 6 + 30 * t
            _cor(cor, (1 - t) * 0.9).set()
            anel = NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(ox - r, oy - r, 2 * r, 2 * r))
            anel.setLineWidth_(2.5 * (1 - t) + 0.5)
            anel.stroke()
        self.ondas = vivas

        if a <= 0.01:
            return

        # HALO: um anel cheio e um tracejado girando, em volta da ponta.
        _cor(cor, 0.35 * a).set()
        halo = NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(x - 20, y - 20, 40, 40))
        halo.setLineWidth_(1.5)
        halo.stroke()
        _cor(cor, 0.55 * a).set()
        giro = NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(x - 14, y - 14, 28, 28))
        giro.setLineWidth_(1.2)
        giro.setLineDash_count_phase_([4.0, 5.0], 2, self.fase * 18)
        giro.stroke()

        # A SETA: escura e cheia (para cobrir a seta do sistema embaixo), com
        # contorno claro e brilho na cor do estado. A ponta é o ponto do mouse.
        pontos = [(0, 0), (0, 25), (6.2, 19.4), (10.6, 29.2), (14.6, 27.4), (10.3, 17.9), (18.5, 17.9)]
        seta = NSBezierPath.bezierPath()
        seta.moveToPoint_((x + pontos[0][0], y + pontos[0][1]))
        for (px, py) in pontos[1:]:
            seta.lineToPoint_((x + px, y + py))
        seta.closePath()
        sombra = NSShadow.alloc().init()
        sombra.setShadowColor_(_cor(cor, 0.9 * a))
        sombra.setShadowBlurRadius_(10)
        sombra.setShadowOffset_((0, 0))
        from AppKit import NSGraphicsContext
        NSGraphicsContext.saveGraphicsState()
        sombra.set()
        NSColor.colorWithSRGBRed_green_blue_alpha_(0.02, 0.043, 0.075, 0.96 * a).set()
        seta.fill()
        NSGraphicsContext.restoreGraphicsState()
        NSColor.colorWithSRGBRed_green_blue_alpha_(0.92, 0.98, 1.0, a).set()
        seta.setLineWidth_(1.6)
        seta.setLineJoinStyle_(1)
        seta.stroke()
        # o núcleo de energia dentro da seta
        _cor(cor, a).set()
        NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(x + 3.2, y + 10.5, 5, 5)).fill()

        # OCUPADO: um arco girando embaixo à direita da seta.
        if self.ocupado:
            _cor(cor, 0.9 * a).set()
            arco = NSBezierPath.bezierPath()
            arco.appendBezierPathWithArcWithCenter_radius_startAngle_endAngle_(
                (x + 24, y + 30), 6, self.fase * 360 % 360, (self.fase * 360 + 250) % 360)
            arco.setLineWidth_(2)
            arco.stroke()

        # A ETIQUETA do que ele está fazendo.
        if self.rotulo:
            atributos = {
                NSFontAttributeName: NSFont.systemFontOfSize_weight_(12, 0.3),
                NSForegroundColorAttributeName: NSColor.colorWithSRGBRed_green_blue_alpha_(0.95, 0.98, 1.0, a),
            }
            texto = NSString.stringWithString_(self.rotulo)
            tam = texto.sizeWithAttributes_(atributos)
            bx, by = x + 22, y + 34
            caixa = NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
                NSMakeRect(bx, by, tam.width + 18, tam.height + 8), 9, 9)
            NSColor.colorWithSRGBRed_green_blue_alpha_(0.03, 0.06, 0.1, 0.86 * a).set()
            caixa.fill()
            _cor(cor, 0.6 * a).set()
            caixa.setLineWidth_(1)
            caixa.stroke()
            texto.drawAtPoint_withAttributes_((bx + 9, by + 4), atributos)


class Relogio(NSObject):
    """Anima o cursor a 60 quadros por segundo, na thread da interface."""

    def initComVista_(self, vista):
        self = objc.super(Relogio, self).init()
        if self is None:
            return None
        self.vista = vista
        return self

    def tique_(self, _timer):
        v = self.vista
        v.fase += 1 / 60
        parado = time.time() - v.ultima
        alvo = 1.0 if (v.visivel and (v.ocupado or parado < SOME_DEPOIS)) else 0.0
        v.alfa += (alvo - v.alfa) * 0.25
        if alvo == 0.0 and v.alfa < 0.02:
            v.alfa = 0.0
            if not v.ocupado:
                v.rotulo = ""
        v.setNeedsDisplay_(True)


class Cursor:
    def __init__(self):
        self.app = NSApplication.sharedApplication()
        self.app.setActivationPolicy_(1)          # acessório: sem ícone no Dock
        tela = NSScreen.screens()[0].frame()      # a tela principal (origem 0,0)
        self.largura, self.altura = tela.size.width, tela.size.height
        self.janela = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
            tela, 0, NSBackingStoreBuffered, False)
        j = self.janela
        j.setOpaque_(False)
        j.setBackgroundColor_(NSColor.clearColor())
        j.setHasShadow_(False)
        j.setLevel_(NIVEL)
        j.setIgnoresMouseEvents_(True)            # o clique atravessa para o app
        # Fora das capturas de tela (o print do agente mostra a tela, não o
        # cursor dele). PRIMO_CURSOR_CAPTURAVEL=1 libera, para o teste fotografar.
        import os
        j.setSharingType_(1 if os.environ.get("PRIMO_CURSOR_CAPTURAVEL") == "1" else 0)
        j.setCollectionBehavior_(1 | 16 | 64 | 256)
        self.vista = VistaCursor.alloc().initWithFrame_(NSMakeRect(0, 0, self.largura, self.altura))
        j.setContentView_(self.vista)
        j.orderFrontRegardless()
        self.relogio = Relogio.alloc().initComVista_(self.vista)
        NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
            1 / 60, self.relogio, "tique:", None, True)

    # ── o que a interface mostra (sempre na thread dela) ────────────────────
    def _ui(self, **campos):
        def aplicar():
            for k, v in campos.items():
                setattr(self.vista, k, v)
            self.vista.ultima = time.time()
        AppHelper.callAfter(aplicar)

    def _onda(self, x, y):
        AppHelper.callAfter(lambda: self.vista.ondas.append((x, y, time.time())))

    # ── o trajeto ───────────────────────────────────────────────────────────
    def mover(self, x: float, y: float, arrastando: bool = False) -> None:
        """Vai até (x, y) numa curva leve, acelerando e freando.

        Reta com velocidade constante é o que denuncia robô. O ponto de
        controle da curva sai para o lado, proporcional à distância, com teto.
        """
        x = max(0.0, min(self.largura - 1, float(x)))
        y = max(0.0, min(self.altura - 1, float(y)))
        x0, y0 = posicao()
        dist = math.hypot(x - x0, y - y0)
        self._ui(visivel=True, estado="agindo")
        if dist < 1:
            self._ui(x=x, y=y)
            return
        dur = max(0.14, min(1.2, 0.22 + dist / 1700))
        passos = max(2, int(dur * PASSOS_HZ))
        desvio = min(dist * 0.12, 90) * random.choice((-1, 1))
        mx, my = (x0 + x) / 2, (y0 + y) / 2
        nx, ny = -(y - y0) / dist, (x - x0) / dist
        cx, cy = mx + nx * desvio, my + ny * desvio
        tipo = Quartz.kCGEventLeftMouseDragged if arrastando else Quartz.kCGEventMouseMoved
        for i in range(1, passos + 1):
            t = i / passos
            e = 0.5 - 0.5 * math.cos(math.pi * t)
            px = (1 - e) ** 2 * x0 + 2 * (1 - e) * e * cx + e ** 2 * x
            py = (1 - e) ** 2 * y0 + 2 * (1 - e) * e * cy + e ** 2 * y
            _post(Quartz.CGEventCreateMouseEvent(None, tipo, (px, py), Quartz.kCGMouseButtonLeft))
            if i % 2 == 0 or i == passos:
                self._ui(x=px, y=py)
            time.sleep(dur / passos)

    def clicar(self, x, y, botao="esquerdo", duplo=False) -> None:
        self.mover(x, y)
        time.sleep(0.04)          # o app vê o mouse chegar antes do clique
        direito = str(botao).lower() in ("direito", "right")
        desce = Quartz.kCGEventRightMouseDown if direito else Quartz.kCGEventLeftMouseDown
        sobe = Quartz.kCGEventRightMouseUp if direito else Quartz.kCGEventLeftMouseUp
        qual = Quartz.kCGMouseButtonRight if direito else Quartz.kCGMouseButtonLeft
        for n in ((1, 2) if duplo else (1,)):
            for tipo in (desce, sobe):
                ev = Quartz.CGEventCreateMouseEvent(None, tipo, (x, y), qual)
                Quartz.CGEventSetIntegerValueField(ev, Quartz.kCGMouseEventClickState, n)
                _post(ev)
            self._onda(x, y)
            time.sleep(0.09)

    def arrastar(self, x1, y1, x2, y2) -> None:
        self.mover(x1, y1)
        _post(Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDown, (x1, y1), Quartz.kCGMouseButtonLeft))
        time.sleep(0.12)
        self.mover(x2, y2, arrastando=True)
        _post(Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseUp, (x2, y2), Quartz.kCGMouseButtonLeft))
        self._onda(x2, y2)

    def rolar(self, dy) -> None:
        linhas = int(round(float(dy) / 50)) or (1 if float(dy) > 0 else -1)
        x, y = posicao()
        self._ui(visivel=True, x=x, y=y)
        passo = -1 if linhas > 0 else 1        # dy > 0 rola para BAIXO
        for _ in range(abs(linhas)):
            _post(Quartz.CGEventCreateScrollWheelEvent(None, Quartz.kCGScrollEventUnitLine, 1, passo * 3))
            time.sleep(0.03)


# ── O laço de comandos ──────────────────────────────────────────────────────

def responder(obj) -> None:
    sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
    sys.stdout.flush()


def executar(cur: Cursor, msg: dict) -> dict:
    cmd = msg.get("cmd")
    if cmd == "permissao":
        ok = pode_agir()
        if not ok and msg.get("pedir"):
            pedir_permissao()
        return {"ok": ok}
    if cmd == "tamanho":
        return {"ok": True, "largura": int(cur.largura), "altura": int(cur.altura)}
    if cmd == "ler_tela":
        return ler_tela(cur.largura, cur.altura, str(msg.get("arquivo") or ""))
    if cmd == "rotulo":
        cur._ui(rotulo=str(msg.get("texto") or "")[:48], visivel=True, x=posicao()[0], y=posicao()[1])
        return {"ok": True}
    if cmd == "ocupado":
        cur._ui(ocupado=bool(msg.get("sim")), visivel=True)
        return {"ok": True}
    if cmd == "esconder":
        cur._ui(visivel=False, ocupado=False, rotulo="")
        return {"ok": True}
    if cmd == "foto":
        # Só para o teste: renderiza a vista do cursor numa imagem, em volta
        # dele. Não é captura de tela (a janela fica fora delas de propósito).
        import os
        if os.environ.get("PRIMO_CURSOR_CAPTURAVEL") != "1":
            return {"ok": False, "error": "foto só no modo de teste"}
        feito = threading.Event()
        saida = {}
        def render():
            v = cur.vista
            area = NSMakeRect(max(0, v.x - 60), max(0, v.y - 60), 240, 160)
            rep = v.bitmapImageRepForCachingDisplayInRect_(area)
            v.cacheDisplayInRect_toBitmapImageRep_(area, rep)
            rep.representationUsingType_properties_(4, None).writeToFile_atomically_(msg["caminho"], True)
            saida.update(alfa=round(v.alfa, 2), x=v.x, y=v.y)
            feito.set()
        AppHelper.callAfter(render)
        feito.wait(3)
        return {"ok": bool(saida), **saida}

    # Daqui para baixo, mexe no computador: sem a permissão, o sistema engole
    # os eventos calado. Melhor dizer do que fingir que clicou.
    if not pode_agir():
        pedir_permissao()
        return {"ok": False, "permissao": False,
                "error": "o macOS não deixou mexer no mouse e no teclado. Libere o Terminal (ou o app "
                         "de onde você abriu o PrimoCode) em Ajustes do Sistema > Privacidade e "
                         "Segurança > Acessibilidade, e peça de novo."}
    if "rotulo" in msg:
        cur._ui(rotulo=str(msg.get("rotulo") or "")[:48])
    if cmd == "mover":
        cur.mover(msg["x"], msg["y"])
    elif cmd == "clicar":
        cur.clicar(msg["x"], msg["y"], msg.get("botao", "esquerdo"), bool(msg.get("duplo")))
    elif cmd == "arrastar":
        cur.arrastar(msg["x1"], msg["y1"], msg["x2"], msg["y2"])
    elif cmd == "rolar":
        cur.rolar(msg.get("dy", 300))
    elif cmd == "digitar":
        cur._ui(visivel=True, x=posicao()[0], y=posicao()[1], ocupado=True)
        digitar(msg.get("texto", ""))
        cur._ui(ocupado=False)
    elif cmd == "tecla":
        c = combo(msg.get("combo", ""))
        if not c:
            return {"ok": False, "error": f'tecla desconhecida: "{msg.get("combo")}". Use nomes como '
                                          '"enter", "esc", "tab", "cmd+t", "cmd+shift+4", "left".'}
        cur._ui(visivel=True, x=posicao()[0], y=posicao()[1])
        apertar(*c)
    else:
        return {"ok": False, "error": f"comando desconhecido: {cmd}"}
    return {"ok": True}


def ler_comandos(cur: Cursor) -> None:
    for linha in sys.stdin:
        linha = linha.strip()
        if not linha:
            continue
        try:
            msg = json.loads(linha)
        except ValueError:
            continue
        try:
            r = executar(cur, msg)
        except Exception as e:           # um comando ruim não derruba o cursor
            r = {"ok": False, "error": str(e)}
        r["id"] = msg.get("id")
        responder(r)
    # stdin fechou: o PrimoCode saiu. O cursor sai junto.
    AppHelper.callAfter(AppHelper.stopEventLoop)


def main() -> int:
    cur = Cursor()
    threading.Thread(target=ler_comandos, args=(cur,), daemon=True).start()
    responder({"pronto": True, "permissao": pode_agir()})
    AppHelper.runEventLoop(installInterrupt=True)
    return 0


if __name__ == "__main__":
    sys.exit(main())
