primocode 7.10.1 → 7.12.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/bin/primocode.js +30 -1
- package/lib/act.js +27 -0
- package/lib/catalogo.js +15 -9
- package/lib/desktop.js +578 -47
- package/lib/modo.js +5 -1
- package/lib/streaming.js +410 -0
- package/lib/tools.js +16 -0
- package/lib/ui.js +6 -3
- package/package.json +3 -3
package/lib/desktop.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* desktop.js — Controle nativo do mouse
|
|
2
|
+
* desktop.js — Controle nativo do computador: mouse, teclado, janelas e tela.
|
|
3
3
|
* Zero dependências npm. Usa ferramentas nativas do SO:
|
|
4
|
-
* Linux:
|
|
5
|
-
* macOS:
|
|
6
|
-
* Windows: powershell +
|
|
4
|
+
* Linux: xdotool (mouse/teclado/janelas) + scrot/maim/… (tela)
|
|
5
|
+
* macOS: cliclick (brew install cliclick) e osascript/System Events
|
|
6
|
+
* Windows: powershell (user32 + UI Automation) — nada a instalar
|
|
7
|
+
*
|
|
8
|
+
* A regra do módulo: o agente NÃO enxerga pixels (os modelos são só-texto),
|
|
9
|
+
* então ele enxerga por NOMES — as janelas abertas (listWindows), os botões e
|
|
10
|
+
* ícones de cada janela (uiElements) e o clique pelo nome (clickElement).
|
|
11
|
+
* Coordenada continua existindo, mas é o último recurso, não o primeiro.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
'use strict';
|
|
@@ -24,19 +29,52 @@ const TOOLS = {
|
|
|
24
29
|
nircmd: PLATFORM === 'win32' && (() => { try { execSync('where nircmd', {stdio:'ignore'}); return true; } catch { return false; } })(),
|
|
25
30
|
};
|
|
26
31
|
|
|
27
|
-
function run(cmd) {
|
|
28
|
-
try {
|
|
29
|
-
|
|
32
|
+
function run(cmd, opts = {}) {
|
|
33
|
+
try {
|
|
34
|
+
execSync(cmd, {
|
|
35
|
+
stdio: 'ignore',
|
|
36
|
+
timeout: opts.timeout || 15000,
|
|
37
|
+
env: opts.env ? { ...process.env, ...opts.env } : undefined,
|
|
38
|
+
});
|
|
39
|
+
return { ok: true };
|
|
40
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
30
41
|
}
|
|
31
42
|
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
// Igual ao run(), mas devolve o stdout — para os comandos que PERGUNTAM algo
|
|
44
|
+
// (listar janelas, ler elementos) em vez de só agir.
|
|
45
|
+
function runOut(cmd, opts = {}) {
|
|
46
|
+
try {
|
|
47
|
+
const out = execSync(cmd, {
|
|
48
|
+
encoding: 'utf-8',
|
|
49
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
50
|
+
timeout: opts.timeout || 15000,
|
|
51
|
+
env: opts.env ? { ...process.env, ...opts.env } : undefined,
|
|
52
|
+
});
|
|
53
|
+
return { ok: true, out: String(out || '') };
|
|
54
|
+
} catch (e) { return { ok: false, error: e.message, out: String((e && e.stdout) || '') }; }
|
|
35
55
|
}
|
|
36
56
|
|
|
57
|
+
// Argumento de shell entre aspas simples, à prova de apóstrofo. O truque
|
|
58
|
+
// POSIX é fechar a aspa, escapar o apóstrofo e reabrir: 'don'\''t'.
|
|
59
|
+
function sh1(s) {
|
|
60
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// PowerShell via -EncodedCommand: o script vai em base64 (UTF-16LE), então
|
|
64
|
+
// aspas, cifrão e acento do texto do usuário NUNCA passam pelo cmd.exe cru.
|
|
65
|
+
// A versão antiga escapava aspas à mão dentro de -Command "..." e quebrava
|
|
66
|
+
// com qualquer texto que tivesse caractere especial — era um dos motivos de
|
|
67
|
+
// "clica mas não digita" no Windows.
|
|
68
|
+
function pwshCmd(script) {
|
|
69
|
+
const b64 = Buffer.from(script, 'utf16le').toString('base64');
|
|
70
|
+
return `powershell -NoProfile -NonInteractive -EncodedCommand ${b64}`;
|
|
71
|
+
}
|
|
72
|
+
function pwshRun(script, opts = {}) { return run(pwshCmd(script), opts); }
|
|
73
|
+
function pwshOut(script, opts = {}) { return runOut(pwshCmd(script), opts); }
|
|
74
|
+
|
|
37
75
|
// Clique/scroll de verdade no Windows precisam de mouse_event (user32).
|
|
38
76
|
// A abordagem antiga só posicionava o cursor — nunca clicava.
|
|
39
|
-
const WIN_USER32 =
|
|
77
|
+
const WIN_USER32 = `Add-Type -Name U -Namespace W -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetCursorPos(int X,int Y); [DllImport("user32.dll")] public static extern void mouse_event(uint f,uint dx,uint dy,uint d,int e);';`;
|
|
40
78
|
|
|
41
79
|
// Primeira ferramenta cujo comando existe no PATH.
|
|
42
80
|
function firstCmd(list) {
|
|
@@ -44,86 +82,574 @@ function firstCmd(list) {
|
|
|
44
82
|
return null;
|
|
45
83
|
}
|
|
46
84
|
|
|
85
|
+
// Coordenada tem de ser número. Validar aqui vale para as três plataformas —
|
|
86
|
+
// sem isso um x="abc" virava comando de shell quebrado, não erro legível.
|
|
87
|
+
function coord(v) {
|
|
88
|
+
const n = Math.round(Number(v));
|
|
89
|
+
return Number.isFinite(n) ? n : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Teclas: um dialeto por SO, uma tradução só ───────────────────────────
|
|
93
|
+
// O modelo fala "ctrl+shift+t", "Enter", "alt+tab" — e cada SO tem a própria
|
|
94
|
+
// gramática. Estas funções são puras (testáveis sem SO nenhum).
|
|
95
|
+
|
|
96
|
+
/** "ctrl+shift+t" → { mods: ['ctrl','shift'], key: 't' } */
|
|
97
|
+
function parseCombo(name) {
|
|
98
|
+
const parts = String(name || '').trim().split('+').map((s) => s.trim()).filter(Boolean);
|
|
99
|
+
const key = parts.pop() || '';
|
|
100
|
+
const mods = [];
|
|
101
|
+
for (const p of parts) {
|
|
102
|
+
const m = p.toLowerCase();
|
|
103
|
+
if (m === 'ctrl' || m === 'control') mods.push('ctrl');
|
|
104
|
+
else if (m === 'shift') mods.push('shift');
|
|
105
|
+
else if (m === 'alt' || m === 'option' || m === 'opt') mods.push('alt');
|
|
106
|
+
else if (m === 'cmd' || m === 'command' || m === 'win' || m === 'windows' || m === 'super' || m === 'meta') mods.push('super');
|
|
107
|
+
}
|
|
108
|
+
return { mods, key };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const F_RE = /^f([1-9]|1[0-2])$/i;
|
|
112
|
+
|
|
113
|
+
// xdotool: nomes de keysym.
|
|
114
|
+
const XKEYS = {
|
|
115
|
+
enter: 'Return', return: 'Return', tab: 'Tab', escape: 'Escape', esc: 'Escape',
|
|
116
|
+
backspace: 'BackSpace', delete: 'Delete', del: 'Delete', space: 'space',
|
|
117
|
+
arrowup: 'Up', up: 'Up', arrowdown: 'Down', down: 'Down',
|
|
118
|
+
arrowleft: 'Left', left: 'Left', arrowright: 'Right', right: 'Right',
|
|
119
|
+
home: 'Home', end: 'End', pageup: 'Prior', pagedown: 'Next',
|
|
120
|
+
insert: 'Insert', printscreen: 'Print',
|
|
121
|
+
win: 'Super_L', windows: 'Super_L', super: 'Super_L', cmd: 'Super_L', meta: 'Super_L',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** "ctrl+shift+ArrowLeft" → "ctrl+shift+Left" (gramática do xdotool). */
|
|
125
|
+
function xdoKey(name) {
|
|
126
|
+
const { mods, key } = parseCombo(name);
|
|
127
|
+
const k = key.toLowerCase();
|
|
128
|
+
const traduzida = XKEYS[k] || (F_RE.test(k) ? k.toUpperCase() : key);
|
|
129
|
+
return [...mods, traduzida].join('+');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Windows SendKeys: ^=ctrl +=shift %=alt, especiais entre chaves.
|
|
133
|
+
const WINKEYS = {
|
|
134
|
+
enter: '{ENTER}', return: '{ENTER}', tab: '{TAB}', escape: '{ESC}', esc: '{ESC}',
|
|
135
|
+
backspace: '{BACKSPACE}', delete: '{DEL}', del: '{DEL}', space: ' ',
|
|
136
|
+
arrowup: '{UP}', up: '{UP}', arrowdown: '{DOWN}', down: '{DOWN}',
|
|
137
|
+
arrowleft: '{LEFT}', left: '{LEFT}', arrowright: '{RIGHT}', right: '{RIGHT}',
|
|
138
|
+
home: '{HOME}', end: '{END}', pageup: '{PGUP}', pagedown: '{PGDN}',
|
|
139
|
+
insert: '{INS}', printscreen: '{PRTSC}',
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** Texto literal para SendKeys: +^%~(){}[] têm significado — vão entre chaves. */
|
|
143
|
+
function escapeSendKeys(text) {
|
|
144
|
+
return String(text).replace(/[+^%~(){}[\]]/g, '{$&}');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* "ctrl+shift+t" → "^+t" (SendKeys). Devolve null quando o atalho usa a tecla
|
|
149
|
+
* Windows, que o SendKeys não alcança — o chamador explica a alternativa.
|
|
150
|
+
* "win" sozinho vira ^{ESC}, que abre o menu Iniciar desde sempre.
|
|
151
|
+
*/
|
|
152
|
+
function winSendKeys(name) {
|
|
153
|
+
const { mods, key } = parseCombo(name);
|
|
154
|
+
const k = key.toLowerCase();
|
|
155
|
+
if (!mods.length && (k === 'win' || k === 'windows' || k === 'super' || k === 'meta' || k === 'cmd')) return '^{ESC}';
|
|
156
|
+
if (mods.includes('super')) return null;
|
|
157
|
+
const prefix = mods.map((m) => (m === 'ctrl' ? '^' : m === 'shift' ? '+' : '%')).join('');
|
|
158
|
+
let token = WINKEYS[k] || (F_RE.test(k) ? `{${k.toUpperCase()}}` : escapeSendKeys(key.length === 1 ? key.toLowerCase() : key));
|
|
159
|
+
// Modificador se aplica à PRÓXIMA tecla; um token de várias teclas precisa
|
|
160
|
+
// de parênteses para o modificador cobrir todas.
|
|
161
|
+
if (prefix && token.length > 1 && !/^\{[^}]+\}$/.test(token)) token = `(${token})`;
|
|
162
|
+
return prefix + token;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// macOS: tecla especial é key code NUMÉRICO; letra é keystroke.
|
|
166
|
+
const MACCODES = {
|
|
167
|
+
enter: 36, return: 36, tab: 48, escape: 53, esc: 53, backspace: 51, delete: 117, del: 117,
|
|
168
|
+
space: 49, arrowleft: 123, left: 123, arrowright: 124, right: 124,
|
|
169
|
+
arrowdown: 125, down: 125, arrowup: 126, up: 126,
|
|
170
|
+
home: 115, end: 119, pageup: 116, pagedown: 121,
|
|
171
|
+
f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97, f7: 98, f8: 100, f9: 101, f10: 109, f11: 103, f12: 111,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** A linha de System Events para uma tecla/atalho: `key code 36 using {...}`. */
|
|
175
|
+
function macKeyScript(name) {
|
|
176
|
+
const { mods, key } = parseCombo(name);
|
|
177
|
+
const using = mods.map((m) => (m === 'ctrl' ? 'control down' : m === 'shift' ? 'shift down' : m === 'alt' ? 'option down' : 'command down')).join(', ');
|
|
178
|
+
const sufixo = using ? ` using {${using}}` : '';
|
|
179
|
+
const k = key.toLowerCase();
|
|
180
|
+
if (MACCODES[k] != null) return `key code ${MACCODES[k]}${sufixo}`;
|
|
181
|
+
const safe = String(key).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
182
|
+
return `keystroke "${safe}"${sufixo}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function osascript(linhas, opts = {}) {
|
|
186
|
+
const script = Array.isArray(linhas) ? linhas.join('\n') : String(linhas);
|
|
187
|
+
return run(`osascript -e ${sh1(script)}`, opts);
|
|
188
|
+
}
|
|
189
|
+
function osascriptOut(linhas, opts = {}) {
|
|
190
|
+
const script = Array.isArray(linhas) ? linhas.join('\n') : String(linhas);
|
|
191
|
+
return runOut(`osascript -e ${sh1(script)}`, opts);
|
|
192
|
+
}
|
|
193
|
+
|
|
47
194
|
// ── Mouse ─────────────────────────────────────────────
|
|
48
195
|
function click(x, y, button = 'left') {
|
|
49
|
-
|
|
50
|
-
if (
|
|
196
|
+
const [xi, yi] = [coord(x), coord(y)];
|
|
197
|
+
if (xi == null || yi == null) return { ok: false, error: `Coordenada inválida: x=${x}, y=${y}` };
|
|
198
|
+
if (TOOLS.xdotool) return run(`xdotool mousemove ${xi} ${yi} click ${button === 'right' ? 3 : 1}`);
|
|
199
|
+
if (TOOLS.cliclick) return run(`cliclick ${button === 'right' ? 'rc' : 'c'}:${xi},${yi}`);
|
|
51
200
|
if (PLATFORM === 'win32') {
|
|
52
201
|
const down = button === 'right' ? '0x0008' : '0x0002';
|
|
53
202
|
const up = button === 'right' ? '0x0010' : '0x0004';
|
|
54
|
-
return
|
|
203
|
+
return pwshRun(`${WIN_USER32} [W.U]::SetCursorPos(${xi},${yi}); Start-Sleep -Milliseconds 40; [W.U]::mouse_event(${down},0,0,0,0); [W.U]::mouse_event(${up},0,0,0,0)`);
|
|
55
204
|
}
|
|
56
205
|
return { ok: false, error: 'Nenhuma ferramenta de automação encontrada. Instale xdotool (Linux) ou cliclick (macOS), ou use as tools browser_*.' };
|
|
57
206
|
}
|
|
58
207
|
|
|
208
|
+
// Clique duplo é o que ABRE as coisas fora do navegador: ícone da área de
|
|
209
|
+
// trabalho, arquivo no Explorer, célula de planilha. Sem ele o agente "clicava
|
|
210
|
+
// mas nada abria".
|
|
211
|
+
function doubleClick(x, y) {
|
|
212
|
+
const [xi, yi] = [coord(x), coord(y)];
|
|
213
|
+
if (xi == null || yi == null) return { ok: false, error: `Coordenada inválida: x=${x}, y=${y}` };
|
|
214
|
+
if (TOOLS.xdotool) return run(`xdotool mousemove ${xi} ${yi} click --repeat 2 --delay 120 1`);
|
|
215
|
+
if (TOOLS.cliclick) return run(`cliclick dc:${xi},${yi}`);
|
|
216
|
+
if (PLATFORM === 'win32') {
|
|
217
|
+
const clique = '[W.U]::mouse_event(0x0002,0,0,0,0); [W.U]::mouse_event(0x0004,0,0,0,0)';
|
|
218
|
+
return pwshRun(`${WIN_USER32} [W.U]::SetCursorPos(${xi},${yi}); Start-Sleep -Milliseconds 40; ${clique}; Start-Sleep -Milliseconds 90; ${clique}`);
|
|
219
|
+
}
|
|
220
|
+
return { ok: false, error: 'Clique duplo não disponível: instale xdotool (Linux) ou cliclick (macOS).' };
|
|
221
|
+
}
|
|
222
|
+
|
|
59
223
|
function move(x, y) {
|
|
60
|
-
|
|
61
|
-
if (
|
|
224
|
+
const [xi, yi] = [coord(x), coord(y)];
|
|
225
|
+
if (xi == null || yi == null) return { ok: false, error: `Coordenada inválida: x=${x}, y=${y}` };
|
|
226
|
+
if (TOOLS.xdotool) return run(`xdotool mousemove ${xi} ${yi}`);
|
|
227
|
+
if (TOOLS.cliclick) return run(`cliclick m:${xi},${yi}`);
|
|
62
228
|
if (PLATFORM === 'win32') {
|
|
63
|
-
return
|
|
229
|
+
return pwshRun(`${WIN_USER32} [W.U]::SetCursorPos(${xi},${yi})`);
|
|
64
230
|
}
|
|
65
231
|
return { ok: false, error: 'Automação não disponível' };
|
|
66
232
|
}
|
|
67
233
|
|
|
68
234
|
function drag(fromX, fromY, toX, toY) {
|
|
235
|
+
const [fx, fy, tx, ty] = [coord(fromX), coord(fromY), coord(toX), coord(toY)];
|
|
236
|
+
if ([fx, fy, tx, ty].some((v) => v == null)) return { ok: false, error: 'Coordenada inválida no drag' };
|
|
69
237
|
if (TOOLS.xdotool) {
|
|
70
|
-
return run(`xdotool mousemove ${
|
|
238
|
+
return run(`xdotool mousemove ${fx} ${fy} mousedown 1 mousemove ${tx} ${ty} mouseup 1`);
|
|
71
239
|
}
|
|
72
240
|
if (TOOLS.cliclick) {
|
|
73
|
-
return run(`cliclick dd:${
|
|
241
|
+
return run(`cliclick dd:${fx},${fy} du:${tx},${ty}`);
|
|
242
|
+
}
|
|
243
|
+
if (PLATFORM === 'win32') {
|
|
244
|
+
// Arrastar de verdade exige passos intermediários: soltar o botão no
|
|
245
|
+
// destino sem passar pelo caminho não conta como drag para o Windows.
|
|
246
|
+
const passos = [];
|
|
247
|
+
const N = 8;
|
|
248
|
+
for (let i = 1; i <= N; i++) {
|
|
249
|
+
const px = Math.round(fx + ((tx - fx) * i) / N);
|
|
250
|
+
const py = Math.round(fy + ((ty - fy) * i) / N);
|
|
251
|
+
passos.push(`[W.U]::SetCursorPos(${px},${py}); Start-Sleep -Milliseconds 25`);
|
|
252
|
+
}
|
|
253
|
+
return pwshRun(`${WIN_USER32} [W.U]::SetCursorPos(${fx},${fy}); Start-Sleep -Milliseconds 60; [W.U]::mouse_event(0x0002,0,0,0,0); Start-Sleep -Milliseconds 120; ${passos.join('; ')}; [W.U]::mouse_event(0x0004,0,0,0,0)`);
|
|
74
254
|
}
|
|
75
255
|
return { ok: false, error: 'Drag não suportado nesta plataforma sem xdotool/cliclick' };
|
|
76
256
|
}
|
|
77
257
|
|
|
78
258
|
function scroll(dy) {
|
|
79
|
-
|
|
80
|
-
|
|
259
|
+
const d = Number(dy);
|
|
260
|
+
const passo = Number.isFinite(d) ? d : 300;
|
|
261
|
+
if (TOOLS.xdotool) return run(`xdotool click --repeat ${Math.max(1, Math.abs(Math.round(passo / 50)))} ${passo > 0 ? 5 : 4}`);
|
|
262
|
+
if (TOOLS.cliclick) return run(`cliclick scroll:${Math.round(passo / 50)}`);
|
|
81
263
|
if (PLATFORM === 'win32') {
|
|
82
264
|
// Roda do mouse: 120 = um "clique". dy>0 rola para baixo (delta negativo).
|
|
83
|
-
const delta = -Math.round(
|
|
84
|
-
return
|
|
265
|
+
const delta = -Math.round(passo / 50) * 120;
|
|
266
|
+
return pwshRun(`${WIN_USER32} [W.U]::mouse_event(0x0800,0,0,${delta},0)`);
|
|
85
267
|
}
|
|
86
268
|
return { ok: false, error: 'Scroll não suportado nesta plataforma' };
|
|
87
269
|
}
|
|
88
270
|
|
|
89
271
|
// ── Teclado ───────────────────────────────────────────
|
|
90
272
|
function type(text) {
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
273
|
+
const t = String(text ?? '');
|
|
274
|
+
if (!t) return { ok: true, digitado: 0 };
|
|
275
|
+
if (TOOLS.xdotool) return run(`xdotool type --delay 12 -- ${sh1(t)}`);
|
|
276
|
+
if (PLATFORM === 'darwin') {
|
|
277
|
+
// Uma keystroke por linha; entre elas, Return de verdade (key code 36) —
|
|
278
|
+
// keystroke com "\n" no meio não quebra linha em app nenhum.
|
|
279
|
+
const linhas = t.split(/\r?\n/);
|
|
280
|
+
const corpo = [];
|
|
281
|
+
linhas.forEach((l, i) => {
|
|
282
|
+
if (i > 0) corpo.push('key code 36');
|
|
283
|
+
if (l) corpo.push(`keystroke "${l.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`);
|
|
284
|
+
});
|
|
285
|
+
return osascript(['tell application "System Events"', ...corpo, 'end tell']);
|
|
95
286
|
}
|
|
96
287
|
if (PLATFORM === 'win32') {
|
|
97
|
-
|
|
98
|
-
|
|
288
|
+
// SendKeys trata +^%~(){}[] como comando — escapar TUDO, senão digitar
|
|
289
|
+
// "olá (teste)" dispara atalho em vez de texto. Depois do escape, o Enter
|
|
290
|
+
// literal vira {ENTER}.
|
|
291
|
+
const seq = escapeSendKeys(t).replace(/\r?\n/g, '{ENTER}');
|
|
292
|
+
return pwshRun(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait($env:PRIMO_KEYS)`, { env: { PRIMO_KEYS: seq } });
|
|
99
293
|
}
|
|
100
294
|
return { ok: false, error: 'Automação de teclado não disponível' };
|
|
101
295
|
}
|
|
102
296
|
|
|
103
297
|
function key(name) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
Enter: 'Return', Return: 'Return', Tab: 'Tab', Escape: 'Escape', Esc: 'Escape',
|
|
107
|
-
Backspace: 'BackSpace', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left',
|
|
108
|
-
ArrowRight: 'Right', Space: 'space', Delete: 'Delete',
|
|
109
|
-
};
|
|
110
|
-
if (TOOLS.xdotool) return run(`xdotool key ${xmap[name] || name}`);
|
|
298
|
+
if (!name) return { ok: false, error: 'Diga a tecla: "Enter", "ctrl+c", "alt+tab"…' };
|
|
299
|
+
if (TOOLS.xdotool) return run(`xdotool key --clearmodifiers ${xdoKey(name)}`);
|
|
111
300
|
|
|
112
301
|
if (PLATFORM === 'darwin') {
|
|
113
|
-
|
|
114
|
-
const mac = { Enter: 36, Return: 36, Tab: 48, Escape: 53, Esc: 53, Backspace: 51, Delete: 51, Space: 49, ArrowLeft: 123, ArrowRight: 124, ArrowDown: 125, ArrowUp: 126 };
|
|
115
|
-
if (mac[name] != null) return run(`osascript -e 'tell application "System Events" to key code ${mac[name]}'`);
|
|
116
|
-
// Atalho com modificadores, ex "cmd+c": usa keystroke.
|
|
117
|
-
return run(`osascript -e 'tell application "System Events" to keystroke "${String(name).replace(/"/g, '\\"')}"'`);
|
|
302
|
+
return osascript(`tell application "System Events" to ${macKeyScript(name)}`);
|
|
118
303
|
}
|
|
119
304
|
if (PLATFORM === 'win32') {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
305
|
+
const seq = winSendKeys(name);
|
|
306
|
+
if (seq === null) {
|
|
307
|
+
return { ok: false, error: 'Atalho com a tecla Windows não é alcançável por SendKeys. Alternativas: desktop_key("win") abre o menu Iniciar; desktop_open_app abre programas direto.' };
|
|
308
|
+
}
|
|
309
|
+
return pwshRun(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait($env:PRIMO_KEYS)`, { env: { PRIMO_KEYS: seq } });
|
|
123
310
|
}
|
|
124
311
|
return { ok: false, error: 'Key não suportado nesta plataforma' };
|
|
125
312
|
}
|
|
126
313
|
|
|
314
|
+
// ── Programas e janelas ───────────────────────────────
|
|
315
|
+
// Abrir app o agente já conseguia pelo shell; o que faltava era o resto da
|
|
316
|
+
// conversa: saber QUAIS janelas existem, trazer uma para a frente e enxergar
|
|
317
|
+
// o que há dentro dela. É isso que transforma "abre mas não interage" em
|
|
318
|
+
// interação de verdade.
|
|
319
|
+
|
|
320
|
+
// Nomes em português do dia a dia → executável/app real. Pequena de propósito:
|
|
321
|
+
// só o que tem tradução óbvia. O resto passa direto como o usuário falou.
|
|
322
|
+
const APP_ALIASES = {
|
|
323
|
+
win32: {
|
|
324
|
+
'bloco de notas': 'notepad', 'notepad': 'notepad', 'calculadora': 'calc',
|
|
325
|
+
'paint': 'mspaint', 'explorador de arquivos': 'explorer', 'arquivos': 'explorer',
|
|
326
|
+
'explorer': 'explorer', 'prompt de comando': 'cmd', 'terminal': 'wt',
|
|
327
|
+
'gerenciador de tarefas': 'taskmgr', 'configuracoes': 'ms-settings:',
|
|
328
|
+
'painel de controle': 'control', 'edge': 'msedge', 'word': 'winword',
|
|
329
|
+
'excel': 'excel', 'powerpoint': 'powerpnt',
|
|
330
|
+
},
|
|
331
|
+
darwin: {
|
|
332
|
+
'calculadora': 'Calculator', 'bloco de notas': 'TextEdit', 'notas': 'Notes',
|
|
333
|
+
'arquivos': 'Finder', 'finder': 'Finder', 'terminal': 'Terminal',
|
|
334
|
+
'chrome': 'Google Chrome', 'safari': 'Safari', 'musica': 'Music',
|
|
335
|
+
'fotos': 'Photos', 'configuracoes': 'System Settings', 'ajustes': 'System Settings',
|
|
336
|
+
},
|
|
337
|
+
linux: {
|
|
338
|
+
'calculadora': 'gnome-calculator', 'arquivos': 'nautilus',
|
|
339
|
+
'bloco de notas': 'gedit', 'editor de texto': 'gedit',
|
|
340
|
+
'terminal': 'gnome-terminal', 'chrome': 'google-chrome',
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
function normaliza(s) {
|
|
345
|
+
return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function aliasDoApp(nome, platform = PLATFORM) {
|
|
349
|
+
const mapa = APP_ALIASES[platform] || {};
|
|
350
|
+
return mapa[normaliza(nome)] || String(nome || '').trim();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function openApp(nome) {
|
|
354
|
+
const pedido = String(nome || '').trim();
|
|
355
|
+
if (!pedido) return { ok: false, error: 'Diga o nome do programa (ex: "chrome", "bloco de notas").' };
|
|
356
|
+
const app = aliasDoApp(pedido);
|
|
357
|
+
|
|
358
|
+
let r;
|
|
359
|
+
if (PLATFORM === 'win32') {
|
|
360
|
+
// Start-Process resolve PATH e App Paths (chrome, winword…); o start do
|
|
361
|
+
// cmd cobre o resto (URIs tipo ms-settings:, associações).
|
|
362
|
+
r = pwshOut(`$ErrorActionPreference='Stop'; $a=$env:PRIMO_APP; try { Start-Process $a; Write-Output 'OK' } catch { try { Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c','start','',$a) -WindowStyle Hidden; Write-Output 'OK' } catch { Write-Output ('ERRO ' + $_.Exception.Message) } }`, { env: { PRIMO_APP: app } });
|
|
363
|
+
if (r.ok && !/^ERRO/m.test(r.out)) r = { ok: true };
|
|
364
|
+
else r = { ok: false, error: (r.out || r.error || '').trim() || `não consegui abrir "${app}"` };
|
|
365
|
+
} else if (PLATFORM === 'darwin') {
|
|
366
|
+
r = run(`open -a ${sh1(app)}`);
|
|
367
|
+
if (!r.ok && app !== pedido) r = run(`open -a ${sh1(pedido)}`);
|
|
368
|
+
if (!r.ok) r = { ok: false, error: `Não achei o app "${pedido}". Use o nome exato como aparece em /Applications.` };
|
|
369
|
+
} else {
|
|
370
|
+
const bin = app.split(/\s+/)[0];
|
|
371
|
+
if (hasCmd('gtk-launch') && run(`gtk-launch ${sh1(bin)}`).ok) r = { ok: true };
|
|
372
|
+
else if (hasCmd(bin)) r = run(`bash -c ${sh1(`setsid ${app} >/dev/null 2>&1 < /dev/null &`)}`);
|
|
373
|
+
else r = { ok: false, error: `Não achei o programa "${pedido}" no PATH.` };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!r.ok) return r;
|
|
377
|
+
return { ok: true, app, dica: 'A janela pode levar 1–2s para abrir. Confirme com desktop_windows e traga para a frente com desktop_focus.' };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function listWindows() {
|
|
381
|
+
let linhas = [];
|
|
382
|
+
if (PLATFORM === 'win32') {
|
|
383
|
+
const r = pwshOut(`Get-Process | Where-Object { $_.MainWindowTitle } | ForEach-Object { $t = $_.MainWindowTitle -replace "[\\r\\n|]", " "; Write-Output ("$($_.ProcessName)|$t") }`, { timeout: 20000 });
|
|
384
|
+
if (!r.ok && !r.out) return { ok: false, error: r.error || 'não consegui listar as janelas' };
|
|
385
|
+
linhas = r.out.split('\n');
|
|
386
|
+
} else if (PLATFORM === 'darwin') {
|
|
387
|
+
const r = osascriptOut([
|
|
388
|
+
'set saida to ""',
|
|
389
|
+
'tell application "System Events"',
|
|
390
|
+
' repeat with p in (application processes whose visible is true)',
|
|
391
|
+
' repeat with w in windows of p',
|
|
392
|
+
' try',
|
|
393
|
+
' set saida to saida & (name of p) & "|" & (name of w) & linefeed',
|
|
394
|
+
' end try',
|
|
395
|
+
' end repeat',
|
|
396
|
+
' end repeat',
|
|
397
|
+
'end tell',
|
|
398
|
+
'return saida',
|
|
399
|
+
], { timeout: 20000 });
|
|
400
|
+
if (!r.ok) return { ok: false, error: 'Não consegui listar as janelas. Conceda permissão de Acessibilidade ao Terminal em Ajustes → Privacidade e Segurança.' };
|
|
401
|
+
linhas = r.out.split('\n');
|
|
402
|
+
} else {
|
|
403
|
+
if (hasCmd('wmctrl')) {
|
|
404
|
+
const r = runOut('wmctrl -l');
|
|
405
|
+
if (r.ok) linhas = r.out.split('\n').map((l) => {
|
|
406
|
+
const m = l.match(/^\S+\s+-?\d+\s+\S+\s+(.*)$/);
|
|
407
|
+
return m ? `|${m[1]}` : '';
|
|
408
|
+
});
|
|
409
|
+
} else if (TOOLS.xdotool) {
|
|
410
|
+
const r = runOut(`xdotool search --onlyvisible --name '.*' getwindowname %@ 2>/dev/null`);
|
|
411
|
+
if (r.ok) linhas = r.out.split('\n').map((t) => (t.trim() ? `|${t.trim()}` : ''));
|
|
412
|
+
} else {
|
|
413
|
+
return { ok: false, error: 'Instale wmctrl ou xdotool para listar janelas no Linux.' };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const janelas = [];
|
|
418
|
+
for (const l of linhas) {
|
|
419
|
+
const linha = l.replace(/\r/g, '').trim();
|
|
420
|
+
if (!linha) continue;
|
|
421
|
+
const sep = linha.indexOf('|');
|
|
422
|
+
const app = sep >= 0 ? linha.slice(0, sep).trim() : '';
|
|
423
|
+
const titulo = (sep >= 0 ? linha.slice(sep + 1) : linha).trim();
|
|
424
|
+
if (titulo) janelas.push({ app, titulo });
|
|
425
|
+
}
|
|
426
|
+
return { ok: true, total: janelas.length, janelas: janelas.slice(0, 60) };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function focusWindow(titulo) {
|
|
430
|
+
const t = String(titulo || '').trim();
|
|
431
|
+
if (!t) return { ok: false, error: 'Diga o título (ou parte dele) da janela.' };
|
|
432
|
+
|
|
433
|
+
if (PLATFORM === 'win32') {
|
|
434
|
+
const r = pwshOut(
|
|
435
|
+
`$ErrorActionPreference='SilentlyContinue'
|
|
436
|
+
Add-Type -Name W -Namespace P -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);'
|
|
437
|
+
$t = $env:PRIMO_TITLE
|
|
438
|
+
$p = Get-Process | Where-Object { $_.MainWindowTitle -and ($_.MainWindowTitle -match [regex]::Escape($t) -or $_.ProcessName -match [regex]::Escape($t)) } | Select-Object -First 1
|
|
439
|
+
if (-not $p) { Write-Output 'NOTFOUND'; exit 0 }
|
|
440
|
+
if ([P.W]::IsIconic($p.MainWindowHandle)) { [P.W]::ShowWindow($p.MainWindowHandle, 9) | Out-Null }
|
|
441
|
+
[P.W]::SetForegroundWindow($p.MainWindowHandle) | Out-Null
|
|
442
|
+
Write-Output $p.MainWindowTitle`,
|
|
443
|
+
{ env: { PRIMO_TITLE: t }, timeout: 20000 },
|
|
444
|
+
);
|
|
445
|
+
const saida = (r.out || '').trim();
|
|
446
|
+
if (!r.ok && !saida) return { ok: false, error: r.error || 'falhou ao focar' };
|
|
447
|
+
if (/^NOTFOUND/m.test(saida)) return naoAchouJanela(t);
|
|
448
|
+
return { ok: true, janela: saida.split('\n')[0] };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (PLATFORM === 'darwin') {
|
|
452
|
+
const safe = t.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
453
|
+
const r = osascript([
|
|
454
|
+
'tell application "System Events"',
|
|
455
|
+
` set alvo to first application process whose visible is true and (name contains "${safe}" or displayed name contains "${safe}")`,
|
|
456
|
+
' set frontmost of alvo to true',
|
|
457
|
+
'end tell',
|
|
458
|
+
]);
|
|
459
|
+
if (!r.ok) return naoAchouJanela(t);
|
|
460
|
+
return { ok: true, janela: t };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (TOOLS.xdotool) {
|
|
464
|
+
const busca = runOut(`xdotool search --onlyvisible --name ${sh1(t)} 2>/dev/null`);
|
|
465
|
+
const id = (busca.out || '').trim().split('\n')[0];
|
|
466
|
+
if (!id) return naoAchouJanela(t);
|
|
467
|
+
const r = run(`xdotool windowactivate --sync ${id}`);
|
|
468
|
+
return r.ok ? { ok: true, janela: t } : r;
|
|
469
|
+
}
|
|
470
|
+
return { ok: false, error: 'Focar janela não disponível: instale xdotool (Linux).' };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Erro que sabe o que EXISTE leva a informação junto — mesma ideia dos
|
|
474
|
+
// "trechosParecidos" do edit_file. Erro seco faz o modelo chutar de novo.
|
|
475
|
+
function naoAchouJanela(titulo) {
|
|
476
|
+
const w = listWindows();
|
|
477
|
+
return {
|
|
478
|
+
ok: false,
|
|
479
|
+
error: `Nenhuma janela combinou com "${titulo}".`,
|
|
480
|
+
janelasAbertas: w.ok ? w.janelas.map((j) => j.titulo).slice(0, 30) : undefined,
|
|
481
|
+
comoCorrigir: 'Use um título de "janelasAbertas", copiado literalmente (ou um pedaço único dele). Se o programa nem abriu, chame desktop_open_app antes.',
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── Elementos da janela — os "olhos" do agente ────────────────────────────
|
|
486
|
+
// Os modelos do servidor são só-texto: mandar um PNG não adianta nada. O que
|
|
487
|
+
// funciona é ler a árvore de acessibilidade — a mesma que o leitor de tela usa
|
|
488
|
+
// — e devolver NOME + POSIÇÃO de cada botão, campo e ícone. Aí "clique em
|
|
489
|
+
// Salvar" vira coordenada exata, sem chute.
|
|
490
|
+
|
|
491
|
+
const MAX_ELEMENTOS = 120;
|
|
492
|
+
|
|
493
|
+
const PS_ELEMENTOS = `$ErrorActionPreference='SilentlyContinue'
|
|
494
|
+
Add-Type -AssemblyName UIAutomationClient
|
|
495
|
+
Add-Type -AssemblyName UIAutomationTypes
|
|
496
|
+
Add-Type -Name W -Namespace P -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();'
|
|
497
|
+
$titulo = $env:PRIMO_TITLE
|
|
498
|
+
$root = [System.Windows.Automation.AutomationElement]::RootElement
|
|
499
|
+
$win = $null
|
|
500
|
+
if ($titulo) {
|
|
501
|
+
foreach ($w in $root.FindAll([System.Windows.Automation.TreeScope]::Children, [System.Windows.Automation.Condition]::TrueCondition)) {
|
|
502
|
+
if ($w.Current.Name -like ('*' + $titulo + '*')) { $win = $w; break }
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (-not $win) {
|
|
506
|
+
$h = [P.W]::GetForegroundWindow()
|
|
507
|
+
if ($h -ne [IntPtr]::Zero) { $win = [System.Windows.Automation.AutomationElement]::FromHandle($h) }
|
|
508
|
+
}
|
|
509
|
+
if (-not $win) { Write-Output 'SEMJANELA'; exit 0 }
|
|
510
|
+
Write-Output ('JANELA|' + ($win.Current.Name -replace "[\\r\\n|]", ' '))
|
|
511
|
+
$todos = $win.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition)
|
|
512
|
+
$n = 0
|
|
513
|
+
foreach ($e in $todos) {
|
|
514
|
+
if ($n -ge ${MAX_ELEMENTOS}) { break }
|
|
515
|
+
$c = $e.Current
|
|
516
|
+
$nome = $c.Name
|
|
517
|
+
if (-not $nome) { continue }
|
|
518
|
+
if ($c.IsOffscreen) { continue }
|
|
519
|
+
$r = $c.BoundingRectangle
|
|
520
|
+
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
|
|
521
|
+
$tipo = $c.ControlType.ProgrammaticName -replace 'ControlType\\.', ''
|
|
522
|
+
$cx = [int]($r.X + $r.Width / 2); $cy = [int]($r.Y + $r.Height / 2)
|
|
523
|
+
$nome = $nome -replace "[\\r\\n|]", ' '
|
|
524
|
+
Write-Output ("$tipo|$cx|$cy|$nome")
|
|
525
|
+
$n++
|
|
526
|
+
}`;
|
|
527
|
+
|
|
528
|
+
function uiElements(janela) {
|
|
529
|
+
const titulo = String(janela || '').trim();
|
|
530
|
+
|
|
531
|
+
if (PLATFORM === 'win32') {
|
|
532
|
+
const r = pwshOut(PS_ELEMENTOS, { env: { PRIMO_TITLE: titulo }, timeout: 45000 });
|
|
533
|
+
if (!r.ok && !r.out) return { ok: false, error: r.error || 'não consegui ler os elementos' };
|
|
534
|
+
if (/^SEMJANELA/m.test(r.out)) return { ok: false, error: 'Nenhuma janela em foco. Abra o programa (desktop_open_app) ou foque a janela (desktop_focus) primeiro.' };
|
|
535
|
+
return parseElementos(r.out);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (PLATFORM === 'darwin') {
|
|
539
|
+
// `entire contents` lê a janela toda de uma vez; elemento sem nome ou
|
|
540
|
+
// sem posição só é pulado (o try engole).
|
|
541
|
+
const r = osascriptOut([
|
|
542
|
+
'set saida to ""',
|
|
543
|
+
'set n to 0',
|
|
544
|
+
'tell application "System Events"',
|
|
545
|
+
' set p to first application process whose frontmost is true',
|
|
546
|
+
' set saida to "JANELA|" & (name of p) & linefeed',
|
|
547
|
+
' try',
|
|
548
|
+
' set els to entire contents of front window of p',
|
|
549
|
+
' repeat with e in els',
|
|
550
|
+
` if n ≥ ${MAX_ELEMENTOS} then exit repeat`,
|
|
551
|
+
' try',
|
|
552
|
+
' set nm to name of e',
|
|
553
|
+
' if nm is not missing value and nm is not "" then',
|
|
554
|
+
' set {ex, ey} to position of e',
|
|
555
|
+
' set {ew, eh} to size of e',
|
|
556
|
+
' set saida to saida & (role of e) & "|" & (ex + ew div 2) & "|" & (ey + eh div 2) & "|" & nm & linefeed',
|
|
557
|
+
' set n to n + 1',
|
|
558
|
+
' end if',
|
|
559
|
+
' end try',
|
|
560
|
+
' end repeat',
|
|
561
|
+
' end try',
|
|
562
|
+
'end tell',
|
|
563
|
+
'return saida',
|
|
564
|
+
], { timeout: 45000 });
|
|
565
|
+
if (!r.ok) return { ok: false, error: 'Não consegui ler a janela. Conceda permissão de Acessibilidade ao Terminal em Ajustes → Privacidade e Segurança → Acessibilidade.' };
|
|
566
|
+
return parseElementos(r.out);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return { ok: false, error: 'Leitura de elementos não disponível no Linux. Caminho alternativo: desktop_screenshot para o usuário ver, e desktop_click com coordenadas (xdotool).' };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function parseElementos(saida) {
|
|
573
|
+
const elementos = [];
|
|
574
|
+
let janela = '';
|
|
575
|
+
const vistos = new Set();
|
|
576
|
+
for (const linha of String(saida || '').split('\n')) {
|
|
577
|
+
const l = linha.replace(/\r/g, '').trim();
|
|
578
|
+
if (!l) continue;
|
|
579
|
+
if (l.startsWith('JANELA|')) { janela = l.slice(7).trim(); continue; }
|
|
580
|
+
const partes = l.split('|');
|
|
581
|
+
if (partes.length < 4) continue;
|
|
582
|
+
const [tipo, xs, ys] = partes;
|
|
583
|
+
const nome = partes.slice(3).join('|').trim();
|
|
584
|
+
const x = Math.round(Number(xs)), y = Math.round(Number(ys));
|
|
585
|
+
if (!nome || !Number.isFinite(x) || !Number.isFinite(y)) continue;
|
|
586
|
+
const chave = `${tipo}|${x}|${y}|${nome}`;
|
|
587
|
+
if (vistos.has(chave)) continue;
|
|
588
|
+
vistos.add(chave);
|
|
589
|
+
elementos.push({ tipo: tipo.trim(), x, y, nome: nome.slice(0, 120) });
|
|
590
|
+
if (elementos.length >= MAX_ELEMENTOS) break;
|
|
591
|
+
}
|
|
592
|
+
if (!elementos.length) {
|
|
593
|
+
return { ok: false, error: 'A janela não expôs elementos nomeados (alguns apps escondem a árvore de acessibilidade). Use desktop_screenshot + desktop_click por coordenada.' , janela };
|
|
594
|
+
}
|
|
595
|
+
return { ok: true, janela, total: elementos.length, elementos };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Melhor elemento para um nome pedido — sem acento, sem caixa, aceitando
|
|
600
|
+
* pedaço ("salvar" acha "Salvar como…"). Pura, para o teste bater sem SO.
|
|
601
|
+
*/
|
|
602
|
+
function escolherElemento(elementos, consulta) {
|
|
603
|
+
const q = normaliza(consulta);
|
|
604
|
+
if (!q) return null;
|
|
605
|
+
let melhor = null;
|
|
606
|
+
let nota = 0;
|
|
607
|
+
for (const e of elementos || []) {
|
|
608
|
+
const n = normaliza(e.nome);
|
|
609
|
+
if (!n) continue;
|
|
610
|
+
let s = 0;
|
|
611
|
+
// O nome que ESTENDE a consulta ("salvar como" → "Salvar como…") vale
|
|
612
|
+
// mais que a consulta estender o nome: quem pediu a frase maior quis o
|
|
613
|
+
// elemento maior, não o irmão de nome curto.
|
|
614
|
+
if (n === q) s = 100;
|
|
615
|
+
else if (n.startsWith(q)) s = 85;
|
|
616
|
+
else if (q.startsWith(n)) s = 75;
|
|
617
|
+
else if (n.includes(q)) s = 65;
|
|
618
|
+
else if (q.includes(n)) s = 55;
|
|
619
|
+
else {
|
|
620
|
+
const termos = q.split(/\s+/).filter((t) => t.length > 1);
|
|
621
|
+
const acertos = termos.filter((t) => n.includes(t)).length;
|
|
622
|
+
if (acertos) s = Math.round((40 * acertos) / termos.length);
|
|
623
|
+
}
|
|
624
|
+
if (!s) continue;
|
|
625
|
+
// Empate: nome mais curto costuma ser o botão em si, não a frase que o contém.
|
|
626
|
+
if (s > nota || (s === nota && melhor && e.nome.length < melhor.nome.length)) { nota = s; melhor = e; }
|
|
627
|
+
}
|
|
628
|
+
return nota >= 40 ? melhor : null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function clickElement(nome, opts = {}) {
|
|
632
|
+
const pedido = String(nome || '').trim();
|
|
633
|
+
if (!pedido) return { ok: false, error: 'Diga o nome do elemento (o texto do botão, ícone ou campo).' };
|
|
634
|
+
|
|
635
|
+
const lidos = uiElements(opts.window);
|
|
636
|
+
if (!lidos.ok) return lidos;
|
|
637
|
+
|
|
638
|
+
const alvo = escolherElemento(lidos.elementos, pedido);
|
|
639
|
+
if (!alvo) {
|
|
640
|
+
return {
|
|
641
|
+
ok: false,
|
|
642
|
+
error: `Nenhum elemento parecido com "${pedido}" na janela "${lidos.janela}".`,
|
|
643
|
+
elementosDisponiveis: lidos.elementos.slice(0, 40).map((e) => e.nome),
|
|
644
|
+
comoCorrigir: 'Chame de novo com um nome de "elementosDisponiveis", copiado literalmente.',
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const r = opts.double ? doubleClick(alvo.x, alvo.y) : click(alvo.x, alvo.y, opts.button || 'left');
|
|
649
|
+
if (!r.ok) return r;
|
|
650
|
+
return { ok: true, clicado: alvo.nome, tipo: alvo.tipo, x: alvo.x, y: alvo.y, duplo: !!opts.double };
|
|
651
|
+
}
|
|
652
|
+
|
|
127
653
|
// ── Screenshot ────────────────────────────────────────
|
|
128
654
|
function screenshot(saveTo) {
|
|
129
655
|
const filename = saveTo || `desktop-${Date.now()}.png`;
|
|
@@ -133,8 +659,8 @@ function screenshot(saveTo) {
|
|
|
133
659
|
return done(run(`screencapture -x "${filename}"`));
|
|
134
660
|
}
|
|
135
661
|
if (PLATFORM === 'win32') {
|
|
136
|
-
const ps = `Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bmp=New-Object System.Drawing.Bitmap($b.Width,$b.Height); $g=[System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$bmp.Size); $bmp.Save('${filename}'); $g.Dispose(); $bmp.Dispose()`;
|
|
137
|
-
return done(
|
|
662
|
+
const ps = `Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bmp=New-Object System.Drawing.Bitmap($b.Width,$b.Height); $g=[System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$bmp.Size); $bmp.Save('${filename.replace(/'/g, "''")}'); $g.Dispose(); $bmp.Dispose()`;
|
|
663
|
+
return done(pwshRun(ps));
|
|
138
664
|
}
|
|
139
665
|
// Linux: tenta as ferramentas comuns, na ordem.
|
|
140
666
|
const scrot = firstCmd(['scrot', 'gnome-screenshot', 'spectacle', 'import', 'maim']);
|
|
@@ -182,8 +708,13 @@ function getScreenSize() {
|
|
|
182
708
|
}
|
|
183
709
|
|
|
184
710
|
module.exports = {
|
|
185
|
-
click, move, drag, scroll,
|
|
711
|
+
click, doubleClick, move, drag, scroll,
|
|
186
712
|
type, key,
|
|
713
|
+
openApp, listWindows, focusWindow, uiElements, clickElement,
|
|
187
714
|
screenshot, getScreenSize,
|
|
188
715
|
TOOLS,
|
|
716
|
+
// Puras, exportadas para o test/desktop.test.js provar a tradução de teclas
|
|
717
|
+
// e a escolha de elemento sem depender do SO da máquina de teste.
|
|
718
|
+
parseCombo, xdoKey, winSendKeys, macKeyScript, escapeSendKeys,
|
|
719
|
+
escolherElemento, aliasDoApp,
|
|
189
720
|
};
|