primocode 9.4.0 → 9.6.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 +189 -104
- package/lib/act.js +26 -8
- package/lib/catalogo.js +20 -1
- package/lib/config.js +29 -0
- package/lib/cursor.js +120 -0
- package/lib/memoria-conversa.js +20 -11
- package/lib/tools.js +89 -18
- package/lib/ui.js +3 -0
- package/lib/voz.js +189 -2
- package/package.json +2 -2
- package/voz/primo_voz/__main__.py +17 -5
- package/voz/primo_voz/cursor.py +525 -0
- package/voz/primo_voz/orbe.py +237 -122
- package/voz/primo_voz/transcrever.py +106 -0
|
@@ -50,7 +50,7 @@ from pathlib import Path
|
|
|
50
50
|
AQUI = Path(__file__).resolve().parent
|
|
51
51
|
sys.path.insert(0, str(AQUI.parent))
|
|
52
52
|
|
|
53
|
-
from primo_voz import desperta, escuta, orbe # noqa: E402
|
|
53
|
+
from primo_voz import desperta, escuta, orbe, transcrever # noqa: E402
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
def falar_para_o_node(obj: dict) -> None:
|
|
@@ -135,6 +135,11 @@ class Voz:
|
|
|
135
135
|
self.desperta = None
|
|
136
136
|
|
|
137
137
|
self.orb = orbe.Orbe(ao_fechar=self.parar)
|
|
138
|
+
# O Whisper local carrega em segundo plano desde já: senão a primeira
|
|
139
|
+
# frase esperaria o modelo inteiro subir.
|
|
140
|
+
self.local = transcrever.disponivel()
|
|
141
|
+
if self.local:
|
|
142
|
+
transcrever.aquecer()
|
|
138
143
|
self.gravador = escuta.Gravador()
|
|
139
144
|
self.corte = escuta.Corte()
|
|
140
145
|
|
|
@@ -251,11 +256,18 @@ class Voz:
|
|
|
251
256
|
|
|
252
257
|
# ── transcrição ──────────────────────────────────────────────────────
|
|
253
258
|
def _transcrever(self, wav: Path) -> str:
|
|
254
|
-
"""
|
|
255
|
-
|
|
256
|
-
É a mesma rota que a legenda de vídeo usa. Duas formas de transcrever
|
|
257
|
-
seriam duas contas para pagar e duas para consertar.
|
|
259
|
+
"""Primeiro aqui, com o Whisper local (ver transcrever.py). O servidor
|
|
260
|
+
só entra se o local não estiver instalado ou falhar — e diz que caiu.
|
|
258
261
|
"""
|
|
262
|
+
if self.local:
|
|
263
|
+
try:
|
|
264
|
+
return transcrever.transcrever(wav)
|
|
265
|
+
except Exception as e:
|
|
266
|
+
avisar(f"a transcrição local falhou ({e}); tentando pelo servidor.")
|
|
267
|
+
return self._transcrever_no_servidor(wav)
|
|
268
|
+
|
|
269
|
+
def _transcrever_no_servidor(self, wav: Path) -> str:
|
|
270
|
+
"""A rota do servidor, a mesma que a legenda de vídeo usa."""
|
|
259
271
|
import base64
|
|
260
272
|
import urllib.request
|
|
261
273
|
|
|
@@ -0,0 +1,525 @@
|
|
|
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
|
+
# ── A janela do cursor ──────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
class VistaCursor(NSView):
|
|
177
|
+
"""Pinta o cursor do Primo. Coordenadas viradas: y cresce para baixo,
|
|
178
|
+
como as do Quartz — o mesmo número serve para os dois."""
|
|
179
|
+
|
|
180
|
+
def initWithFrame_(self, quadro):
|
|
181
|
+
self = objc.super(VistaCursor, self).initWithFrame_(quadro)
|
|
182
|
+
if self is None:
|
|
183
|
+
return None
|
|
184
|
+
self.x, self.y = posicao()
|
|
185
|
+
self.alfa = 0.0 # 0..1, para aparecer e sumir sem piscar
|
|
186
|
+
self.visivel = False
|
|
187
|
+
self.estado = "normal"
|
|
188
|
+
self.rotulo = ""
|
|
189
|
+
self.ocupado = False
|
|
190
|
+
self.ondas = [] # [(x, y, começo)] de cada clique
|
|
191
|
+
self.fase = 0.0
|
|
192
|
+
self.ultima = 0.0 # hora da última ação
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
def isFlipped(self):
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
def isOpaque(self):
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
def drawRect_(self, rect):
|
|
202
|
+
NSColor.clearColor().set()
|
|
203
|
+
NSRectFillUsingOperation(self.bounds(), NSCompositingOperationClear)
|
|
204
|
+
if self.alfa <= 0.01 and not self.ondas:
|
|
205
|
+
return
|
|
206
|
+
a = self.alfa
|
|
207
|
+
cor = CORES.get(self.estado, CORES["normal"])
|
|
208
|
+
x, y = self.x, self.y
|
|
209
|
+
agora = time.time()
|
|
210
|
+
|
|
211
|
+
# A ONDA de cada clique: um anel que abre e some em meio segundo.
|
|
212
|
+
vivas = []
|
|
213
|
+
for (ox, oy, t0) in self.ondas:
|
|
214
|
+
t = (agora - t0) / 0.5
|
|
215
|
+
if t >= 1:
|
|
216
|
+
continue
|
|
217
|
+
vivas.append((ox, oy, t0))
|
|
218
|
+
r = 6 + 30 * t
|
|
219
|
+
_cor(cor, (1 - t) * 0.9).set()
|
|
220
|
+
anel = NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(ox - r, oy - r, 2 * r, 2 * r))
|
|
221
|
+
anel.setLineWidth_(2.5 * (1 - t) + 0.5)
|
|
222
|
+
anel.stroke()
|
|
223
|
+
self.ondas = vivas
|
|
224
|
+
|
|
225
|
+
if a <= 0.01:
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
# HALO: um anel cheio e um tracejado girando, em volta da ponta.
|
|
229
|
+
_cor(cor, 0.35 * a).set()
|
|
230
|
+
halo = NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(x - 20, y - 20, 40, 40))
|
|
231
|
+
halo.setLineWidth_(1.5)
|
|
232
|
+
halo.stroke()
|
|
233
|
+
_cor(cor, 0.55 * a).set()
|
|
234
|
+
giro = NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(x - 14, y - 14, 28, 28))
|
|
235
|
+
giro.setLineWidth_(1.2)
|
|
236
|
+
giro.setLineDash_count_phase_([4.0, 5.0], 2, self.fase * 18)
|
|
237
|
+
giro.stroke()
|
|
238
|
+
|
|
239
|
+
# A SETA: escura e cheia (para cobrir a seta do sistema embaixo), com
|
|
240
|
+
# contorno claro e brilho na cor do estado. A ponta é o ponto do mouse.
|
|
241
|
+
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)]
|
|
242
|
+
seta = NSBezierPath.bezierPath()
|
|
243
|
+
seta.moveToPoint_((x + pontos[0][0], y + pontos[0][1]))
|
|
244
|
+
for (px, py) in pontos[1:]:
|
|
245
|
+
seta.lineToPoint_((x + px, y + py))
|
|
246
|
+
seta.closePath()
|
|
247
|
+
sombra = NSShadow.alloc().init()
|
|
248
|
+
sombra.setShadowColor_(_cor(cor, 0.9 * a))
|
|
249
|
+
sombra.setShadowBlurRadius_(10)
|
|
250
|
+
sombra.setShadowOffset_((0, 0))
|
|
251
|
+
from AppKit import NSGraphicsContext
|
|
252
|
+
NSGraphicsContext.saveGraphicsState()
|
|
253
|
+
sombra.set()
|
|
254
|
+
NSColor.colorWithSRGBRed_green_blue_alpha_(0.02, 0.043, 0.075, 0.96 * a).set()
|
|
255
|
+
seta.fill()
|
|
256
|
+
NSGraphicsContext.restoreGraphicsState()
|
|
257
|
+
NSColor.colorWithSRGBRed_green_blue_alpha_(0.92, 0.98, 1.0, a).set()
|
|
258
|
+
seta.setLineWidth_(1.6)
|
|
259
|
+
seta.setLineJoinStyle_(1)
|
|
260
|
+
seta.stroke()
|
|
261
|
+
# o núcleo de energia dentro da seta
|
|
262
|
+
_cor(cor, a).set()
|
|
263
|
+
NSBezierPath.bezierPathWithOvalInRect_(NSMakeRect(x + 3.2, y + 10.5, 5, 5)).fill()
|
|
264
|
+
|
|
265
|
+
# OCUPADO: um arco girando embaixo à direita da seta.
|
|
266
|
+
if self.ocupado:
|
|
267
|
+
_cor(cor, 0.9 * a).set()
|
|
268
|
+
arco = NSBezierPath.bezierPath()
|
|
269
|
+
arco.appendBezierPathWithArcWithCenter_radius_startAngle_endAngle_(
|
|
270
|
+
(x + 24, y + 30), 6, self.fase * 360 % 360, (self.fase * 360 + 250) % 360)
|
|
271
|
+
arco.setLineWidth_(2)
|
|
272
|
+
arco.stroke()
|
|
273
|
+
|
|
274
|
+
# A ETIQUETA do que ele está fazendo.
|
|
275
|
+
if self.rotulo:
|
|
276
|
+
atributos = {
|
|
277
|
+
NSFontAttributeName: NSFont.systemFontOfSize_weight_(12, 0.3),
|
|
278
|
+
NSForegroundColorAttributeName: NSColor.colorWithSRGBRed_green_blue_alpha_(0.95, 0.98, 1.0, a),
|
|
279
|
+
}
|
|
280
|
+
texto = NSString.stringWithString_(self.rotulo)
|
|
281
|
+
tam = texto.sizeWithAttributes_(atributos)
|
|
282
|
+
bx, by = x + 22, y + 34
|
|
283
|
+
caixa = NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_(
|
|
284
|
+
NSMakeRect(bx, by, tam.width + 18, tam.height + 8), 9, 9)
|
|
285
|
+
NSColor.colorWithSRGBRed_green_blue_alpha_(0.03, 0.06, 0.1, 0.86 * a).set()
|
|
286
|
+
caixa.fill()
|
|
287
|
+
_cor(cor, 0.6 * a).set()
|
|
288
|
+
caixa.setLineWidth_(1)
|
|
289
|
+
caixa.stroke()
|
|
290
|
+
texto.drawAtPoint_withAttributes_((bx + 9, by + 4), atributos)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class Relogio(NSObject):
|
|
294
|
+
"""Anima o cursor a 60 quadros por segundo, na thread da interface."""
|
|
295
|
+
|
|
296
|
+
def initComVista_(self, vista):
|
|
297
|
+
self = objc.super(Relogio, self).init()
|
|
298
|
+
if self is None:
|
|
299
|
+
return None
|
|
300
|
+
self.vista = vista
|
|
301
|
+
return self
|
|
302
|
+
|
|
303
|
+
def tique_(self, _timer):
|
|
304
|
+
v = self.vista
|
|
305
|
+
v.fase += 1 / 60
|
|
306
|
+
parado = time.time() - v.ultima
|
|
307
|
+
alvo = 1.0 if (v.visivel and (v.ocupado or parado < SOME_DEPOIS)) else 0.0
|
|
308
|
+
v.alfa += (alvo - v.alfa) * 0.25
|
|
309
|
+
if alvo == 0.0 and v.alfa < 0.02:
|
|
310
|
+
v.alfa = 0.0
|
|
311
|
+
if not v.ocupado:
|
|
312
|
+
v.rotulo = ""
|
|
313
|
+
v.setNeedsDisplay_(True)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class Cursor:
|
|
317
|
+
def __init__(self):
|
|
318
|
+
self.app = NSApplication.sharedApplication()
|
|
319
|
+
self.app.setActivationPolicy_(1) # acessório: sem ícone no Dock
|
|
320
|
+
tela = NSScreen.screens()[0].frame() # a tela principal (origem 0,0)
|
|
321
|
+
self.largura, self.altura = tela.size.width, tela.size.height
|
|
322
|
+
self.janela = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(
|
|
323
|
+
tela, 0, NSBackingStoreBuffered, False)
|
|
324
|
+
j = self.janela
|
|
325
|
+
j.setOpaque_(False)
|
|
326
|
+
j.setBackgroundColor_(NSColor.clearColor())
|
|
327
|
+
j.setHasShadow_(False)
|
|
328
|
+
j.setLevel_(NIVEL)
|
|
329
|
+
j.setIgnoresMouseEvents_(True) # o clique atravessa para o app
|
|
330
|
+
# Fora das capturas de tela (o print do agente mostra a tela, não o
|
|
331
|
+
# cursor dele). PRIMO_CURSOR_CAPTURAVEL=1 libera, para o teste fotografar.
|
|
332
|
+
import os
|
|
333
|
+
j.setSharingType_(1 if os.environ.get("PRIMO_CURSOR_CAPTURAVEL") == "1" else 0)
|
|
334
|
+
j.setCollectionBehavior_(1 | 16 | 64 | 256)
|
|
335
|
+
self.vista = VistaCursor.alloc().initWithFrame_(NSMakeRect(0, 0, self.largura, self.altura))
|
|
336
|
+
j.setContentView_(self.vista)
|
|
337
|
+
j.orderFrontRegardless()
|
|
338
|
+
self.relogio = Relogio.alloc().initComVista_(self.vista)
|
|
339
|
+
NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(
|
|
340
|
+
1 / 60, self.relogio, "tique:", None, True)
|
|
341
|
+
|
|
342
|
+
# ── o que a interface mostra (sempre na thread dela) ────────────────────
|
|
343
|
+
def _ui(self, **campos):
|
|
344
|
+
def aplicar():
|
|
345
|
+
for k, v in campos.items():
|
|
346
|
+
setattr(self.vista, k, v)
|
|
347
|
+
self.vista.ultima = time.time()
|
|
348
|
+
AppHelper.callAfter(aplicar)
|
|
349
|
+
|
|
350
|
+
def _onda(self, x, y):
|
|
351
|
+
AppHelper.callAfter(lambda: self.vista.ondas.append((x, y, time.time())))
|
|
352
|
+
|
|
353
|
+
# ── o trajeto ───────────────────────────────────────────────────────────
|
|
354
|
+
def mover(self, x: float, y: float, arrastando: bool = False) -> None:
|
|
355
|
+
"""Vai até (x, y) numa curva leve, acelerando e freando.
|
|
356
|
+
|
|
357
|
+
Reta com velocidade constante é o que denuncia robô. O ponto de
|
|
358
|
+
controle da curva sai para o lado, proporcional à distância, com teto.
|
|
359
|
+
"""
|
|
360
|
+
x = max(0.0, min(self.largura - 1, float(x)))
|
|
361
|
+
y = max(0.0, min(self.altura - 1, float(y)))
|
|
362
|
+
x0, y0 = posicao()
|
|
363
|
+
dist = math.hypot(x - x0, y - y0)
|
|
364
|
+
self._ui(visivel=True, estado="agindo")
|
|
365
|
+
if dist < 1:
|
|
366
|
+
self._ui(x=x, y=y)
|
|
367
|
+
return
|
|
368
|
+
dur = max(0.14, min(1.2, 0.22 + dist / 1700))
|
|
369
|
+
passos = max(2, int(dur * PASSOS_HZ))
|
|
370
|
+
desvio = min(dist * 0.12, 90) * random.choice((-1, 1))
|
|
371
|
+
mx, my = (x0 + x) / 2, (y0 + y) / 2
|
|
372
|
+
nx, ny = -(y - y0) / dist, (x - x0) / dist
|
|
373
|
+
cx, cy = mx + nx * desvio, my + ny * desvio
|
|
374
|
+
tipo = Quartz.kCGEventLeftMouseDragged if arrastando else Quartz.kCGEventMouseMoved
|
|
375
|
+
for i in range(1, passos + 1):
|
|
376
|
+
t = i / passos
|
|
377
|
+
e = 0.5 - 0.5 * math.cos(math.pi * t)
|
|
378
|
+
px = (1 - e) ** 2 * x0 + 2 * (1 - e) * e * cx + e ** 2 * x
|
|
379
|
+
py = (1 - e) ** 2 * y0 + 2 * (1 - e) * e * cy + e ** 2 * y
|
|
380
|
+
_post(Quartz.CGEventCreateMouseEvent(None, tipo, (px, py), Quartz.kCGMouseButtonLeft))
|
|
381
|
+
if i % 2 == 0 or i == passos:
|
|
382
|
+
self._ui(x=px, y=py)
|
|
383
|
+
time.sleep(dur / passos)
|
|
384
|
+
|
|
385
|
+
def clicar(self, x, y, botao="esquerdo", duplo=False) -> None:
|
|
386
|
+
self.mover(x, y)
|
|
387
|
+
time.sleep(0.04) # o app vê o mouse chegar antes do clique
|
|
388
|
+
direito = str(botao).lower() in ("direito", "right")
|
|
389
|
+
desce = Quartz.kCGEventRightMouseDown if direito else Quartz.kCGEventLeftMouseDown
|
|
390
|
+
sobe = Quartz.kCGEventRightMouseUp if direito else Quartz.kCGEventLeftMouseUp
|
|
391
|
+
qual = Quartz.kCGMouseButtonRight if direito else Quartz.kCGMouseButtonLeft
|
|
392
|
+
for n in ((1, 2) if duplo else (1,)):
|
|
393
|
+
for tipo in (desce, sobe):
|
|
394
|
+
ev = Quartz.CGEventCreateMouseEvent(None, tipo, (x, y), qual)
|
|
395
|
+
Quartz.CGEventSetIntegerValueField(ev, Quartz.kCGMouseEventClickState, n)
|
|
396
|
+
_post(ev)
|
|
397
|
+
self._onda(x, y)
|
|
398
|
+
time.sleep(0.09)
|
|
399
|
+
|
|
400
|
+
def arrastar(self, x1, y1, x2, y2) -> None:
|
|
401
|
+
self.mover(x1, y1)
|
|
402
|
+
_post(Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDown, (x1, y1), Quartz.kCGMouseButtonLeft))
|
|
403
|
+
time.sleep(0.12)
|
|
404
|
+
self.mover(x2, y2, arrastando=True)
|
|
405
|
+
_post(Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseUp, (x2, y2), Quartz.kCGMouseButtonLeft))
|
|
406
|
+
self._onda(x2, y2)
|
|
407
|
+
|
|
408
|
+
def rolar(self, dy) -> None:
|
|
409
|
+
linhas = int(round(float(dy) / 50)) or (1 if float(dy) > 0 else -1)
|
|
410
|
+
x, y = posicao()
|
|
411
|
+
self._ui(visivel=True, x=x, y=y)
|
|
412
|
+
passo = -1 if linhas > 0 else 1 # dy > 0 rola para BAIXO
|
|
413
|
+
for _ in range(abs(linhas)):
|
|
414
|
+
_post(Quartz.CGEventCreateScrollWheelEvent(None, Quartz.kCGScrollEventUnitLine, 1, passo * 3))
|
|
415
|
+
time.sleep(0.03)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ── O laço de comandos ──────────────────────────────────────────────────────
|
|
419
|
+
|
|
420
|
+
def responder(obj) -> None:
|
|
421
|
+
sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n")
|
|
422
|
+
sys.stdout.flush()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def executar(cur: Cursor, msg: dict) -> dict:
|
|
426
|
+
cmd = msg.get("cmd")
|
|
427
|
+
if cmd == "permissao":
|
|
428
|
+
ok = pode_agir()
|
|
429
|
+
if not ok and msg.get("pedir"):
|
|
430
|
+
pedir_permissao()
|
|
431
|
+
return {"ok": ok}
|
|
432
|
+
if cmd == "tamanho":
|
|
433
|
+
return {"ok": True, "largura": int(cur.largura), "altura": int(cur.altura)}
|
|
434
|
+
if cmd == "rotulo":
|
|
435
|
+
cur._ui(rotulo=str(msg.get("texto") or "")[:48], visivel=True, x=posicao()[0], y=posicao()[1])
|
|
436
|
+
return {"ok": True}
|
|
437
|
+
if cmd == "ocupado":
|
|
438
|
+
cur._ui(ocupado=bool(msg.get("sim")), visivel=True)
|
|
439
|
+
return {"ok": True}
|
|
440
|
+
if cmd == "esconder":
|
|
441
|
+
cur._ui(visivel=False, ocupado=False, rotulo="")
|
|
442
|
+
return {"ok": True}
|
|
443
|
+
if cmd == "foto":
|
|
444
|
+
# Só para o teste: renderiza a vista do cursor numa imagem, em volta
|
|
445
|
+
# dele. Não é captura de tela (a janela fica fora delas de propósito).
|
|
446
|
+
import os
|
|
447
|
+
if os.environ.get("PRIMO_CURSOR_CAPTURAVEL") != "1":
|
|
448
|
+
return {"ok": False, "error": "foto só no modo de teste"}
|
|
449
|
+
feito = threading.Event()
|
|
450
|
+
saida = {}
|
|
451
|
+
def render():
|
|
452
|
+
v = cur.vista
|
|
453
|
+
area = NSMakeRect(max(0, v.x - 60), max(0, v.y - 60), 240, 160)
|
|
454
|
+
rep = v.bitmapImageRepForCachingDisplayInRect_(area)
|
|
455
|
+
v.cacheDisplayInRect_toBitmapImageRep_(area, rep)
|
|
456
|
+
rep.representationUsingType_properties_(4, None).writeToFile_atomically_(msg["caminho"], True)
|
|
457
|
+
saida.update(alfa=round(v.alfa, 2), x=v.x, y=v.y)
|
|
458
|
+
feito.set()
|
|
459
|
+
AppHelper.callAfter(render)
|
|
460
|
+
feito.wait(3)
|
|
461
|
+
return {"ok": bool(saida), **saida}
|
|
462
|
+
|
|
463
|
+
# Daqui para baixo, mexe no computador: sem a permissão, o sistema engole
|
|
464
|
+
# os eventos calado. Melhor dizer do que fingir que clicou.
|
|
465
|
+
if not pode_agir():
|
|
466
|
+
pedir_permissao()
|
|
467
|
+
return {"ok": False, "permissao": False,
|
|
468
|
+
"error": "o macOS não deixou mexer no mouse e no teclado. Libere o Terminal (ou o app "
|
|
469
|
+
"de onde você abriu o PrimoCode) em Ajustes do Sistema > Privacidade e "
|
|
470
|
+
"Segurança > Acessibilidade, e peça de novo."}
|
|
471
|
+
if "rotulo" in msg:
|
|
472
|
+
cur._ui(rotulo=str(msg.get("rotulo") or "")[:48])
|
|
473
|
+
if cmd == "mover":
|
|
474
|
+
cur.mover(msg["x"], msg["y"])
|
|
475
|
+
elif cmd == "clicar":
|
|
476
|
+
cur.clicar(msg["x"], msg["y"], msg.get("botao", "esquerdo"), bool(msg.get("duplo")))
|
|
477
|
+
elif cmd == "arrastar":
|
|
478
|
+
cur.arrastar(msg["x1"], msg["y1"], msg["x2"], msg["y2"])
|
|
479
|
+
elif cmd == "rolar":
|
|
480
|
+
cur.rolar(msg.get("dy", 300))
|
|
481
|
+
elif cmd == "digitar":
|
|
482
|
+
cur._ui(visivel=True, x=posicao()[0], y=posicao()[1], ocupado=True)
|
|
483
|
+
digitar(msg.get("texto", ""))
|
|
484
|
+
cur._ui(ocupado=False)
|
|
485
|
+
elif cmd == "tecla":
|
|
486
|
+
c = combo(msg.get("combo", ""))
|
|
487
|
+
if not c:
|
|
488
|
+
return {"ok": False, "error": f'tecla desconhecida: "{msg.get("combo")}". Use nomes como '
|
|
489
|
+
'"enter", "esc", "tab", "cmd+t", "cmd+shift+4", "left".'}
|
|
490
|
+
cur._ui(visivel=True, x=posicao()[0], y=posicao()[1])
|
|
491
|
+
apertar(*c)
|
|
492
|
+
else:
|
|
493
|
+
return {"ok": False, "error": f"comando desconhecido: {cmd}"}
|
|
494
|
+
return {"ok": True}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def ler_comandos(cur: Cursor) -> None:
|
|
498
|
+
for linha in sys.stdin:
|
|
499
|
+
linha = linha.strip()
|
|
500
|
+
if not linha:
|
|
501
|
+
continue
|
|
502
|
+
try:
|
|
503
|
+
msg = json.loads(linha)
|
|
504
|
+
except ValueError:
|
|
505
|
+
continue
|
|
506
|
+
try:
|
|
507
|
+
r = executar(cur, msg)
|
|
508
|
+
except Exception as e: # um comando ruim não derruba o cursor
|
|
509
|
+
r = {"ok": False, "error": str(e)}
|
|
510
|
+
r["id"] = msg.get("id")
|
|
511
|
+
responder(r)
|
|
512
|
+
# stdin fechou: o PrimoCode saiu. O cursor sai junto.
|
|
513
|
+
AppHelper.callAfter(AppHelper.stopEventLoop)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def main() -> int:
|
|
517
|
+
cur = Cursor()
|
|
518
|
+
threading.Thread(target=ler_comandos, args=(cur,), daemon=True).start()
|
|
519
|
+
responder({"pronto": True, "permissao": pode_agir()})
|
|
520
|
+
AppHelper.runEventLoop(installInterrupt=True)
|
|
521
|
+
return 0
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
if __name__ == "__main__":
|
|
525
|
+
sys.exit(main())
|