primocode 9.5.0 → 9.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,593 @@
1
+ """cursor.py — o cursor do Primo: o mouse de verdade, com a cara dele.
2
+
3
+ "Quando ele tá no modo, eu consigo ver o cursor dele, o mouse, ver ele
4
+ digitando, abrindo as coisas, ver ele fazer as coisas em tempo real."
5
+ (23/09/2026)
6
+
7
+ ── O QUE É ─────────────────────────────────────────────────────────────────
8
+ Um processo pequeno, só do macOS, que o Node sobe na primeira ação no
9
+ computador. Ele faz duas coisas ao mesmo tempo, e é a soma das duas que dá a
10
+ sensação de "ver o Primo trabalhando":
11
+
12
+ 1. MEXE NO MOUSE E NO TECLADO DE VERDADE, pelo Quartz (`CGEventPost`). Não é
13
+ um cursor de enfeite correndo por cima: o clique acontece no app que está
14
+ embaixo. Isso também aposenta o `cliclick`, que precisava de Homebrew e,
15
+ sem ele, fazia TODO clique falhar — foi por isso que "mostra o teu mouse"
16
+ virou uma página HTML com coordenadas.
17
+
18
+ 2. DESENHA O CURSOR DELE por cima do de verdade: uma seta escura com contorno
19
+ claro e brilho na cor da marca, um anel em volta, uma onda a cada clique e
20
+ uma etiqueta curta do que está fazendo ("digitando", "abrindo Safari").
21
+ Numa janela transparente, acima de tudo, que NÃO recebe clique (o clique
22
+ atravessa para o app) e que não aparece em captura de tela (o print que o
23
+ agente tira mostra a tela, não o cursor dele).
24
+
25
+ O desenho é o do Jarvis (~/Jarvis/src/jarvis/ui/web/hud.html e
26
+ motor/pointer.py): o mesmo trajeto em curva com aceleração suave — reta
27
+ parece robô — e o mesmo sumiço 1,6 s depois da última ação, devolvendo a tela
28
+ ao cursor da pessoa.
29
+
30
+ ── O PROTOCOLO ─────────────────────────────────────────────────────────────
31
+ Uma linha de JSON por comando no stdin, uma de resposta no stdout:
32
+
33
+ {"id": 1, "cmd": "clicar", "x": 400, "y": 300, "botao": "esquerdo", "duplo": false}
34
+ {"id": 1, "ok": true}
35
+
36
+ Comandos: mover, clicar, arrastar, rolar, digitar, tecla, rotulo, ocupado,
37
+ esconder, permissao, tamanho. Coordenadas em PONTOS da tela principal, com a
38
+ origem no canto de cima à esquerda — as mesmas do System Events, que é de onde
39
+ vêm as posições dos botões.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import json
44
+ import math
45
+ import random
46
+ import sys
47
+ import threading
48
+ import time
49
+
50
+ import objc
51
+ import Quartz
52
+ from AppKit import (NSApplication, NSBezierPath, NSColor, NSFont, NSMakeRect,
53
+ NSObject, NSScreen, NSShadow, NSTimer, NSView, NSWindow,
54
+ NSBackingStoreBuffered, NSCompositingOperationClear,
55
+ NSRectFillUsingOperation, NSFontAttributeName,
56
+ NSForegroundColorAttributeName)
57
+ from Foundation import NSString
58
+ from PyObjCTools import AppHelper
59
+
60
+ # ── A cara ──────────────────────────────────────────────────────────────────
61
+ CORES = {
62
+ "normal": (0.37, 0.91, 1.0), # ciano da marca
63
+ "agindo": (1.0, 0.71, 0.32), # âmbar: está mexendo em alguma coisa
64
+ }
65
+ PASSOS_HZ = 120 # quantas vezes por segundo o mouse anda no trajeto
66
+ SOME_DEPOIS = 1.6 # segundos parado até o cursor dele sumir
67
+ NIVEL = 1000 # NSScreenSaverWindowLevel: acima de qualquer app
68
+
69
+
70
+ def _cor(rgb, alfa=1.0):
71
+ return NSColor.colorWithSRGBRed_green_blue_alpha_(rgb[0], rgb[1], rgb[2], alfa)
72
+
73
+
74
+ # ── O mouse e o teclado de verdade ──────────────────────────────────────────
75
+
76
+ def _post(evento) -> None:
77
+ Quartz.CGEventPost(Quartz.kCGHIDEventTap, evento)
78
+
79
+
80
+ def posicao():
81
+ p = Quartz.CGEventGetLocation(Quartz.CGEventCreate(None))
82
+ return float(p.x), float(p.y)
83
+
84
+
85
+ def pode_agir() -> bool:
86
+ """O sistema deixa este processo mexer no mouse e no teclado?
87
+
88
+ `CGPreflightPostEventAccess` e não `AXIsProcessTrusted`: este último fica
89
+ em cache pela vida do processo, e continuava dizendo "não" depois de a
90
+ pessoa conceder a permissão (medido no Jarvis, motor/permissions.py).
91
+ """
92
+ try:
93
+ return bool(Quartz.CGPreflightPostEventAccess())
94
+ except Exception:
95
+ return True # macOS antigo sem a função: tenta e vê
96
+
97
+
98
+ def pedir_permissao() -> None:
99
+ try:
100
+ Quartz.CGRequestPostEventAccess()
101
+ except Exception:
102
+ pass
103
+
104
+
105
+ # Códigos de tecla do layout americano: o atalho é pelo CÓDIGO, não pela
106
+ # letra, então vale em qualquer layout de teclado.
107
+ TECLAS = {
108
+ "a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7, "c": 8, "v": 9,
109
+ "b": 11, "q": 12, "w": 13, "e": 14, "r": 15, "y": 16, "t": 17, "1": 18, "2": 19,
110
+ "3": 20, "4": 21, "6": 22, "5": 23, "=": 24, "9": 25, "7": 26, "-": 27, "8": 28,
111
+ "0": 29, "]": 30, "o": 31, "u": 32, "[": 33, "i": 34, "p": 35, "l": 37, "j": 38,
112
+ "'": 39, "k": 40, ";": 41, "\\": 42, ",": 43, "/": 44, "n": 45, "m": 46, ".": 47,
113
+ "`": 50,
114
+ "enter": 36, "return": 36, "tab": 48, "space": 49, "espaco": 49, "espaço": 49,
115
+ "delete": 51, "backspace": 51, "apagar": 51, "esc": 53, "escape": 53,
116
+ "forwarddelete": 117, "del": 117, "home": 115, "end": 119, "pageup": 116,
117
+ "pagedown": 121, "left": 123, "esquerda": 123, "right": 124, "direita": 124,
118
+ "down": 125, "baixo": 125, "up": 126, "cima": 126,
119
+ "f1": 122, "f2": 120, "f3": 99, "f4": 118, "f5": 96, "f6": 97, "f7": 98,
120
+ "f8": 100, "f9": 101, "f10": 109, "f11": 103, "f12": 111,
121
+ }
122
+ MODIFICADORES = {
123
+ "cmd": Quartz.kCGEventFlagMaskCommand, "command": Quartz.kCGEventFlagMaskCommand,
124
+ "super": Quartz.kCGEventFlagMaskCommand, "win": Quartz.kCGEventFlagMaskCommand,
125
+ "meta": Quartz.kCGEventFlagMaskCommand,
126
+ "ctrl": Quartz.kCGEventFlagMaskControl, "control": Quartz.kCGEventFlagMaskControl,
127
+ "alt": Quartz.kCGEventFlagMaskAlternate, "option": Quartz.kCGEventFlagMaskAlternate,
128
+ "opt": Quartz.kCGEventFlagMaskAlternate,
129
+ "shift": Quartz.kCGEventFlagMaskShift,
130
+ }
131
+
132
+
133
+ def combo(nome: str):
134
+ """"cmd+shift+t" → (código, flags). None se a tecla não for conhecida."""
135
+ partes = [p.strip().lower() for p in str(nome).replace(" ", "").split("+") if p.strip()]
136
+ if not partes:
137
+ return None
138
+ flags = 0
139
+ for p in partes[:-1]:
140
+ if p not in MODIFICADORES:
141
+ return None
142
+ flags |= MODIFICADORES[p]
143
+ tecla = partes[-1]
144
+ if tecla not in TECLAS:
145
+ return None
146
+ return TECLAS[tecla], flags
147
+
148
+
149
+ def apertar(codigo: int, flags: int = 0) -> None:
150
+ for desce in (True, False):
151
+ ev = Quartz.CGEventCreateKeyboardEvent(None, codigo, desce)
152
+ if flags:
153
+ Quartz.CGEventSetFlags(ev, flags)
154
+ _post(ev)
155
+ time.sleep(0.012)
156
+
157
+
158
+ def digitar(texto: str) -> None:
159
+ """Letra por letra, pelo caractere — acento e emoji saem certos em
160
+ qualquer layout de teclado. Quebra de linha é Return de verdade."""
161
+ for ch in str(texto):
162
+ if ch == "\n":
163
+ apertar(36)
164
+ elif ch == "\t":
165
+ apertar(48)
166
+ else:
167
+ for desce in (True, False):
168
+ ev = Quartz.CGEventCreateKeyboardEvent(None, 0, desce)
169
+ Quartz.CGEventKeyboardSetUnicodeString(ev, len(ch), ch)
170
+ _post(ev)
171
+ time.sleep(0.008)
172
+
173
+
174
+ # ── Ler a tela (OCR) ──────────────────────────────────────────────────────
175
+ # "Clicar no texto X" em QUALQUER app, e não só nos que expõem botões pela
176
+ # acessibilidade: fotografa a tela e reconhece o texto com o Vision do macOS
177
+ # (o mesmo do Jarvis, motor/screen.py). Cada trecho volta com o CENTRO em
178
+ # pontos da tela — a mesma unidade dos cliques.
179
+
180
+ def tela_liberada() -> bool:
181
+ try:
182
+ return bool(Quartz.CGPreflightScreenCaptureAccess())
183
+ except Exception:
184
+ return True
185
+
186
+
187
+ def ler_tela(largura: float, altura: float, arquivo: str = "") -> dict:
188
+ try:
189
+ import Vision
190
+ except Exception:
191
+ return {"ok": False, "error": "falta o pyobjc-framework-Vision para ler a tela"}
192
+ if arquivo:
193
+ # Só para o teste: lê uma imagem do disco em vez da tela.
194
+ from Foundation import NSURL
195
+ fonte = Quartz.CGImageSourceCreateWithURL(NSURL.fileURLWithPath_(arquivo), None)
196
+ img = Quartz.CGImageSourceCreateImageAtIndex(fonte, 0, None) if fonte else None
197
+ else:
198
+ if not tela_liberada():
199
+ try:
200
+ Quartz.CGRequestScreenCaptureAccess()
201
+ except Exception:
202
+ pass
203
+ return {"ok": False, "permissaoTela": False,
204
+ "error": "o macOS não deixou ver a tela. Libere o Terminal (ou o app de onde você "
205
+ "abriu o PrimoCode) em Ajustes do Sistema > Privacidade e Segurança > "
206
+ "Gravação de Tela, e peça de novo."}
207
+ img = Quartz.CGWindowListCreateImage(Quartz.CGRectInfinite, Quartz.kCGWindowListOptionOnScreenOnly,
208
+ Quartz.kCGNullWindowID, Quartz.kCGWindowImageDefault)
209
+ if img is None:
210
+ return {"ok": False, "error": "não consegui fotografar a tela"}
211
+
212
+ pedido = Vision.VNRecognizeTextRequest.alloc().init()
213
+ pedido.setRecognitionLevel_(Vision.VNRequestTextRecognitionLevelAccurate)
214
+ pedido.setUsesLanguageCorrection_(True)
215
+ try:
216
+ pedido.setRecognitionLanguages_(["pt-BR", "en-US"])
217
+ except Exception:
218
+ pass
219
+ leitor = Vision.VNImageRequestHandler.alloc().initWithCGImage_options_(img, None)
220
+ ok, erro = leitor.performRequests_error_([pedido], None)
221
+ if not ok:
222
+ return {"ok": False, "error": f"o Vision não leu a tela: {erro}"}
223
+ textos = []
224
+ for obs in (pedido.results() or []):
225
+ candidatos = obs.topCandidates_(1)
226
+ if not candidatos:
227
+ continue
228
+ texto = str(candidatos[0].string()).strip()
229
+ if not texto:
230
+ continue
231
+ bb = obs.boundingBox() # normalizado, origem embaixo à esquerda
232
+ cx = (bb.origin.x + bb.size.width / 2) * largura
233
+ cy = (1 - (bb.origin.y + bb.size.height / 2)) * altura
234
+ textos.append({"texto": texto, "x": round(cx), "y": round(cy),
235
+ "confianca": round(float(candidatos[0].confidence()), 2)})
236
+ textos.sort(key=lambda t: (t["y"], t["x"]))
237
+ return {"ok": True, "textos": textos}
238
+
239
+
240
+ # ── A janela do cursor ──────────────────────────────────────────────────────
241
+
242
+ class VistaCursor(NSView):
243
+ """Pinta o cursor do Primo. Coordenadas viradas: y cresce para baixo,
244
+ como as do Quartz — o mesmo número serve para os dois."""
245
+
246
+ def initWithFrame_(self, quadro):
247
+ self = objc.super(VistaCursor, self).initWithFrame_(quadro)
248
+ if self is None:
249
+ return None
250
+ self.x, self.y = posicao()
251
+ self.alfa = 0.0 # 0..1, para aparecer e sumir sem piscar
252
+ self.visivel = False
253
+ self.estado = "normal"
254
+ self.rotulo = ""
255
+ self.ocupado = False
256
+ self.ondas = [] # [(x, y, começo)] de cada clique
257
+ self.fase = 0.0
258
+ self.ultima = 0.0 # hora da última ação
259
+ return self
260
+
261
+ def isFlipped(self):
262
+ return True
263
+
264
+ def isOpaque(self):
265
+ return False
266
+
267
+ def drawRect_(self, rect):
268
+ NSColor.clearColor().set()
269
+ NSRectFillUsingOperation(self.bounds(), NSCompositingOperationClear)
270
+ if self.alfa <= 0.01 and not self.ondas:
271
+ return
272
+ a = self.alfa
273
+ cor = CORES.get(self.estado, CORES["normal"])
274
+ x, y = self.x, self.y
275
+ agora = time.time()
276
+
277
+ # A ONDA de cada clique: um anel que abre e some em meio segundo.
278
+ vivas = []
279
+ for (ox, oy, t0) in self.ondas:
280
+ t = (agora - t0) / 0.5
281
+ if t >= 1:
282
+ continue
283
+ vivas.append((ox, oy, t0))
284
+ r = 6 + 30 * t
285
+ _cor(cor, (1 - t) * 0.9).set()
286
+ anel = NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(ox - r, oy - r, 2 * r, 2 * r))
287
+ anel.setLineWidth_(2.5 * (1 - t) + 0.5)
288
+ anel.stroke()
289
+ self.ondas = vivas
290
+
291
+ if a <= 0.01:
292
+ return
293
+
294
+ # HALO: um anel cheio e um tracejado girando, em volta da ponta.
295
+ _cor(cor, 0.35 * a).set()
296
+ halo = NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(x - 20, y - 20, 40, 40))
297
+ halo.setLineWidth_(1.5)
298
+ halo.stroke()
299
+ _cor(cor, 0.55 * a).set()
300
+ giro = NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(x - 14, y - 14, 28, 28))
301
+ giro.setLineWidth_(1.2)
302
+ giro.setLineDash_count_phase_([4.0, 5.0], 2, self.fase * 18)
303
+ giro.stroke()
304
+
305
+ # A SETA: escura e cheia (para cobrir a seta do sistema embaixo), com
306
+ # contorno claro e brilho na cor do estado. A ponta é o ponto do mouse.
307
+ 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)]
308
+ seta = NSBezierPath.bezierPath()
309
+ seta.moveToPoint_((x + pontos[0][0], y + pontos[0][1]))
310
+ for (px, py) in pontos[1:]:
311
+ seta.lineToPoint_((x + px, y + py))
312
+ seta.closePath()
313
+ sombra = NSShadow.alloc().init()
314
+ sombra.setShadowColor_(_cor(cor, 0.9 * a))
315
+ sombra.setShadowBlurRadius_(10)
316
+ sombra.setShadowOffset_((0, 0))
317
+ from AppKit import NSGraphicsContext
318
+ NSGraphicsContext.saveGraphicsState()
319
+ sombra.set()
320
+ NSColor.colorWithSRGBRed_green_blue_alpha_(0.02, 0.043, 0.075, 0.96 * a).set()
321
+ seta.fill()
322
+ NSGraphicsContext.restoreGraphicsState()
323
+ NSColor.colorWithSRGBRed_green_blue_alpha_(0.92, 0.98, 1.0, a).set()
324
+ seta.setLineWidth_(1.6)
325
+ seta.setLineJoinStyle_(1)
326
+ seta.stroke()
327
+ # o núcleo de energia dentro da seta
328
+ _cor(cor, a).set()
329
+ NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(x + 3.2, y + 10.5, 5, 5)).fill()
330
+
331
+ # OCUPADO: um arco girando embaixo à direita da seta.
332
+ if self.ocupado:
333
+ _cor(cor, 0.9 * a).set()
334
+ arco = NSBezierPath.bezierPath()
335
+ arco.appendBezierPathWithArcWithCenter_radius_startAngle_endAngle_(
336
+ (x + 24, y + 30), 6, self.fase * 360 % 360, (self.fase * 360 + 250) % 360)
337
+ arco.setLineWidth_(2)
338
+ arco.stroke()
339
+
340
+ # A ETIQUETA do que ele está fazendo.
341
+ if self.rotulo:
342
+ atributos = {
343
+ NSFontAttributeName: NSFont.systemFontOfSize_weight_(12, 0.3),
344
+ NSForegroundColorAttributeName: NSColor.colorWithSRGBRed_green_blue_alpha_(0.95, 0.98, 1.0, a),
345
+ }
346
+ texto = NSString.stringWithString_(self.rotulo)
347
+ tam = texto.sizeWithAttributes_(atributos)
348
+ bx, by = x + 22, y + 34
349
+ caixa = NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
350
+ NSMakeRect(bx, by, tam.width + 18, tam.height + 8), 9, 9)
351
+ NSColor.colorWithSRGBRed_green_blue_alpha_(0.03, 0.06, 0.1, 0.86 * a).set()
352
+ caixa.fill()
353
+ _cor(cor, 0.6 * a).set()
354
+ caixa.setLineWidth_(1)
355
+ caixa.stroke()
356
+ texto.drawAtPoint_withAttributes_((bx + 9, by + 4), atributos)
357
+
358
+
359
+ class Relogio(NSObject):
360
+ """Anima o cursor a 60 quadros por segundo, na thread da interface."""
361
+
362
+ def initComVista_(self, vista):
363
+ self = objc.super(Relogio, self).init()
364
+ if self is None:
365
+ return None
366
+ self.vista = vista
367
+ return self
368
+
369
+ def tique_(self, _timer):
370
+ v = self.vista
371
+ v.fase += 1 / 60
372
+ parado = time.time() - v.ultima
373
+ alvo = 1.0 if (v.visivel and (v.ocupado or parado < SOME_DEPOIS)) else 0.0
374
+ v.alfa += (alvo - v.alfa) * 0.25
375
+ if alvo == 0.0 and v.alfa < 0.02:
376
+ v.alfa = 0.0
377
+ if not v.ocupado:
378
+ v.rotulo = ""
379
+ v.setNeedsDisplay_(True)
380
+
381
+
382
+ class Cursor:
383
+ def __init__(self):
384
+ self.app = NSApplication.sharedApplication()
385
+ self.app.setActivationPolicy_(1) # acessório: sem ícone no Dock
386
+ tela = NSScreen.screens()[0].frame() # a tela principal (origem 0,0)
387
+ self.largura, self.altura = tela.size.width, tela.size.height
388
+ self.janela = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
389
+ tela, 0, NSBackingStoreBuffered, False)
390
+ j = self.janela
391
+ j.setOpaque_(False)
392
+ j.setBackgroundColor_(NSColor.clearColor())
393
+ j.setHasShadow_(False)
394
+ j.setLevel_(NIVEL)
395
+ j.setIgnoresMouseEvents_(True) # o clique atravessa para o app
396
+ # Fora das capturas de tela (o print do agente mostra a tela, não o
397
+ # cursor dele). PRIMO_CURSOR_CAPTURAVEL=1 libera, para o teste fotografar.
398
+ import os
399
+ j.setSharingType_(1 if os.environ.get("PRIMO_CURSOR_CAPTURAVEL") == "1" else 0)
400
+ j.setCollectionBehavior_(1 | 16 | 64 | 256)
401
+ self.vista = VistaCursor.alloc().initWithFrame_(NSMakeRect(0, 0, self.largura, self.altura))
402
+ j.setContentView_(self.vista)
403
+ j.orderFrontRegardless()
404
+ self.relogio = Relogio.alloc().initComVista_(self.vista)
405
+ NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
406
+ 1 / 60, self.relogio, "tique:", None, True)
407
+
408
+ # ── o que a interface mostra (sempre na thread dela) ────────────────────
409
+ def _ui(self, **campos):
410
+ def aplicar():
411
+ for k, v in campos.items():
412
+ setattr(self.vista, k, v)
413
+ self.vista.ultima = time.time()
414
+ AppHelper.callAfter(aplicar)
415
+
416
+ def _onda(self, x, y):
417
+ AppHelper.callAfter(lambda: self.vista.ondas.append((x, y, time.time())))
418
+
419
+ # ── o trajeto ───────────────────────────────────────────────────────────
420
+ def mover(self, x: float, y: float, arrastando: bool = False) -> None:
421
+ """Vai até (x, y) numa curva leve, acelerando e freando.
422
+
423
+ Reta com velocidade constante é o que denuncia robô. O ponto de
424
+ controle da curva sai para o lado, proporcional à distância, com teto.
425
+ """
426
+ x = max(0.0, min(self.largura - 1, float(x)))
427
+ y = max(0.0, min(self.altura - 1, float(y)))
428
+ x0, y0 = posicao()
429
+ dist = math.hypot(x - x0, y - y0)
430
+ self._ui(visivel=True, estado="agindo")
431
+ if dist < 1:
432
+ self._ui(x=x, y=y)
433
+ return
434
+ dur = max(0.14, min(1.2, 0.22 + dist / 1700))
435
+ passos = max(2, int(dur * PASSOS_HZ))
436
+ desvio = min(dist * 0.12, 90) * random.choice((-1, 1))
437
+ mx, my = (x0 + x) / 2, (y0 + y) / 2
438
+ nx, ny = -(y - y0) / dist, (x - x0) / dist
439
+ cx, cy = mx + nx * desvio, my + ny * desvio
440
+ tipo = Quartz.kCGEventLeftMouseDragged if arrastando else Quartz.kCGEventMouseMoved
441
+ for i in range(1, passos + 1):
442
+ t = i / passos
443
+ e = 0.5 - 0.5 * math.cos(math.pi * t)
444
+ px = (1 - e) ** 2 * x0 + 2 * (1 - e) * e * cx + e ** 2 * x
445
+ py = (1 - e) ** 2 * y0 + 2 * (1 - e) * e * cy + e ** 2 * y
446
+ _post(Quartz.CGEventCreateMouseEvent(None, tipo, (px, py), Quartz.kCGMouseButtonLeft))
447
+ if i % 2 == 0 or i == passos:
448
+ self._ui(x=px, y=py)
449
+ time.sleep(dur / passos)
450
+
451
+ def clicar(self, x, y, botao="esquerdo", duplo=False) -> None:
452
+ self.mover(x, y)
453
+ time.sleep(0.04) # o app vê o mouse chegar antes do clique
454
+ direito = str(botao).lower() in ("direito", "right")
455
+ desce = Quartz.kCGEventRightMouseDown if direito else Quartz.kCGEventLeftMouseDown
456
+ sobe = Quartz.kCGEventRightMouseUp if direito else Quartz.kCGEventLeftMouseUp
457
+ qual = Quartz.kCGMouseButtonRight if direito else Quartz.kCGMouseButtonLeft
458
+ for n in ((1, 2) if duplo else (1,)):
459
+ for tipo in (desce, sobe):
460
+ ev = Quartz.CGEventCreateMouseEvent(None, tipo, (x, y), qual)
461
+ Quartz.CGEventSetIntegerValueField(ev, Quartz.kCGMouseEventClickState, n)
462
+ _post(ev)
463
+ self._onda(x, y)
464
+ time.sleep(0.09)
465
+
466
+ def arrastar(self, x1, y1, x2, y2) -> None:
467
+ self.mover(x1, y1)
468
+ _post(Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDown, (x1, y1), Quartz.kCGMouseButtonLeft))
469
+ time.sleep(0.12)
470
+ self.mover(x2, y2, arrastando=True)
471
+ _post(Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseUp, (x2, y2), Quartz.kCGMouseButtonLeft))
472
+ self._onda(x2, y2)
473
+
474
+ def rolar(self, dy) -> None:
475
+ linhas = int(round(float(dy) / 50)) or (1 if float(dy) > 0 else -1)
476
+ x, y = posicao()
477
+ self._ui(visivel=True, x=x, y=y)
478
+ passo = -1 if linhas > 0 else 1 # dy > 0 rola para BAIXO
479
+ for _ in range(abs(linhas)):
480
+ _post(Quartz.CGEventCreateScrollWheelEvent(None, Quartz.kCGScrollEventUnitLine, 1, passo * 3))
481
+ time.sleep(0.03)
482
+
483
+
484
+ # ── O laço de comandos ──────────────────────────────────────────────────────
485
+
486
+ def responder(obj) -> None:
487
+ sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
488
+ sys.stdout.flush()
489
+
490
+
491
+ def executar(cur: Cursor, msg: dict) -> dict:
492
+ cmd = msg.get("cmd")
493
+ if cmd == "permissao":
494
+ ok = pode_agir()
495
+ if not ok and msg.get("pedir"):
496
+ pedir_permissao()
497
+ return {"ok": ok}
498
+ if cmd == "tamanho":
499
+ return {"ok": True, "largura": int(cur.largura), "altura": int(cur.altura)}
500
+ if cmd == "ler_tela":
501
+ return ler_tela(cur.largura, cur.altura, str(msg.get("arquivo") or ""))
502
+ if cmd == "rotulo":
503
+ cur._ui(rotulo=str(msg.get("texto") or "")[:48], visivel=True, x=posicao()[0], y=posicao()[1])
504
+ return {"ok": True}
505
+ if cmd == "ocupado":
506
+ cur._ui(ocupado=bool(msg.get("sim")), visivel=True)
507
+ return {"ok": True}
508
+ if cmd == "esconder":
509
+ cur._ui(visivel=False, ocupado=False, rotulo="")
510
+ return {"ok": True}
511
+ if cmd == "foto":
512
+ # Só para o teste: renderiza a vista do cursor numa imagem, em volta
513
+ # dele. Não é captura de tela (a janela fica fora delas de propósito).
514
+ import os
515
+ if os.environ.get("PRIMO_CURSOR_CAPTURAVEL") != "1":
516
+ return {"ok": False, "error": "foto só no modo de teste"}
517
+ feito = threading.Event()
518
+ saida = {}
519
+ def render():
520
+ v = cur.vista
521
+ area = NSMakeRect(max(0, v.x - 60), max(0, v.y - 60), 240, 160)
522
+ rep = v.bitmapImageRepForCachingDisplayInRect_(area)
523
+ v.cacheDisplayInRect_toBitmapImageRep_(area, rep)
524
+ rep.representationUsingType_properties_(4, None).writeToFile_atomically_(msg["caminho"], True)
525
+ saida.update(alfa=round(v.alfa, 2), x=v.x, y=v.y)
526
+ feito.set()
527
+ AppHelper.callAfter(render)
528
+ feito.wait(3)
529
+ return {"ok": bool(saida), **saida}
530
+
531
+ # Daqui para baixo, mexe no computador: sem a permissão, o sistema engole
532
+ # os eventos calado. Melhor dizer do que fingir que clicou.
533
+ if not pode_agir():
534
+ pedir_permissao()
535
+ return {"ok": False, "permissao": False,
536
+ "error": "o macOS não deixou mexer no mouse e no teclado. Libere o Terminal (ou o app "
537
+ "de onde você abriu o PrimoCode) em Ajustes do Sistema > Privacidade e "
538
+ "Segurança > Acessibilidade, e peça de novo."}
539
+ if "rotulo" in msg:
540
+ cur._ui(rotulo=str(msg.get("rotulo") or "")[:48])
541
+ if cmd == "mover":
542
+ cur.mover(msg["x"], msg["y"])
543
+ elif cmd == "clicar":
544
+ cur.clicar(msg["x"], msg["y"], msg.get("botao", "esquerdo"), bool(msg.get("duplo")))
545
+ elif cmd == "arrastar":
546
+ cur.arrastar(msg["x1"], msg["y1"], msg["x2"], msg["y2"])
547
+ elif cmd == "rolar":
548
+ cur.rolar(msg.get("dy", 300))
549
+ elif cmd == "digitar":
550
+ cur._ui(visivel=True, x=posicao()[0], y=posicao()[1], ocupado=True)
551
+ digitar(msg.get("texto", ""))
552
+ cur._ui(ocupado=False)
553
+ elif cmd == "tecla":
554
+ c = combo(msg.get("combo", ""))
555
+ if not c:
556
+ return {"ok": False, "error": f'tecla desconhecida: "{msg.get("combo")}". Use nomes como '
557
+ '"enter", "esc", "tab", "cmd+t", "cmd+shift+4", "left".'}
558
+ cur._ui(visivel=True, x=posicao()[0], y=posicao()[1])
559
+ apertar(*c)
560
+ else:
561
+ return {"ok": False, "error": f"comando desconhecido: {cmd}"}
562
+ return {"ok": True}
563
+
564
+
565
+ def ler_comandos(cur: Cursor) -> None:
566
+ for linha in sys.stdin:
567
+ linha = linha.strip()
568
+ if not linha:
569
+ continue
570
+ try:
571
+ msg = json.loads(linha)
572
+ except ValueError:
573
+ continue
574
+ try:
575
+ r = executar(cur, msg)
576
+ except Exception as e: # um comando ruim não derruba o cursor
577
+ r = {"ok": False, "error": str(e)}
578
+ r["id"] = msg.get("id")
579
+ responder(r)
580
+ # stdin fechou: o PrimoCode saiu. O cursor sai junto.
581
+ AppHelper.callAfter(AppHelper.stopEventLoop)
582
+
583
+
584
+ def main() -> int:
585
+ cur = Cursor()
586
+ threading.Thread(target=ler_comandos, args=(cur,), daemon=True).start()
587
+ responder({"pronto": True, "permissao": pode_agir()})
588
+ AppHelper.runEventLoop(installInterrupt=True)
589
+ return 0
590
+
591
+
592
+ if __name__ == "__main__":
593
+ sys.exit(main())
@@ -174,7 +174,10 @@ def _criar_nativa(orbe: "Orbe"):
174
174
  janela.setBackgroundColor_(NSColor.clearColor())
175
175
  janela.setHasShadow_(False)
176
176
  janela.setLevel_(25) # NSStatusWindowLevel: por cima de tudo
177
- janela.setMovableByWindowBackground_(True) # arrasta pela orb
177
+ # O clique ATRAVESSA a orb: ela mora no pé da tela, em cima do Dock e
178
+ # do que o cursor do Primo precisa clicar. Uma orb que segura o clique
179
+ # faria o agente clicar nela em vez de no app.
180
+ janela.setIgnoresMouseEvents_(True)
178
181
  janela.setCollectionBehavior_(1 | 16) # todos os espaços; fora do Exposé
179
182
  janela.setReleasedWhenClosed_(False)
180
183
  vista = Vista.alloc().initWithFrame_(NSMakeRect(0, 0, LARGURA, ALTURA))