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.
- package/README.md +2 -2
- package/bin/primocode.js +168 -40
- package/lib/act.js +128 -9
- package/lib/catalogo.js +20 -1
- package/lib/config.js +29 -0
- package/lib/cursor.js +129 -0
- package/lib/memoria-conversa.js +20 -11
- package/lib/tools.js +117 -20
- package/lib/ui.js +3 -0
- package/lib/voz.js +22 -3
- package/package.json +2 -2
- package/skills/projeto-no-github/SKILL.md +32 -0
- package/skills/publicar-na-internet/SKILL.md +36 -0
- package/voz/primo_voz/cursor.py +593 -0
- package/voz/primo_voz/orbe.py +4 -1
|
@@ -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())
|
package/voz/primo_voz/orbe.py
CHANGED
|
@@ -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
|
-
|
|
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))
|