chocolatito-code 1.0.0 → 1.1.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 +89 -0
- package/dist/agent/compaction.d.ts +65 -0
- package/dist/agent/compaction.js +134 -0
- package/dist/agent/context.d.ts +3 -0
- package/dist/agent/context.js +35 -0
- package/dist/agent/loop.d.ts +91 -2
- package/dist/agent/loop.js +536 -78
- package/dist/agent/loopDetector.d.ts +57 -0
- package/dist/agent/loopDetector.js +0 -0
- package/dist/agent/mentions.d.ts +13 -0
- package/dist/agent/mentions.js +49 -0
- package/dist/agent/repoMap.d.ts +117 -0
- package/dist/agent/repoMap.js +560 -0
- package/dist/agent/result.d.ts +64 -0
- package/dist/agent/result.js +48 -0
- package/dist/agent/retry.d.ts +35 -0
- package/dist/agent/retry.js +123 -4
- package/dist/agent/subagent.js +62 -15
- package/dist/agent/syntaxValidator.js +288 -52
- package/dist/agent/toolGate.d.ts +58 -0
- package/dist/agent/toolGate.js +111 -0
- package/dist/agent/tracker.js +4 -7
- package/dist/agent/undoManager.d.ts +44 -11
- package/dist/agent/undoManager.js +95 -24
- package/dist/agent/usageMeter.d.ts +56 -0
- package/dist/agent/usageMeter.js +51 -0
- package/dist/agent/verifier.d.ts +49 -0
- package/dist/agent/verifier.js +158 -0
- package/dist/config/constants.js +14 -0
- package/dist/config/engine.d.ts +19 -0
- package/dist/config/engine.js +35 -3
- package/dist/config/license.d.ts +5 -0
- package/dist/config/license.js +107 -9
- package/dist/config/limits.d.ts +28 -0
- package/dist/config/limits.js +51 -0
- package/dist/config/permissions.d.ts +76 -1
- package/dist/config/permissions.js +379 -23
- package/dist/config/quota.js +24 -1
- package/dist/config/updater.d.ts +68 -0
- package/dist/config/updater.js +215 -0
- package/dist/hooks/manager.js +159 -14
- package/dist/index.js +222 -34
- package/dist/mcp/client.d.ts +22 -0
- package/dist/mcp/client.js +93 -11
- package/dist/prompts/systemPrompt.js +12 -1
- package/dist/sessions/manager.d.ts +12 -0
- package/dist/sessions/manager.js +33 -5
- package/dist/sessions/resume.d.ts +36 -0
- package/dist/sessions/resume.js +43 -0
- package/dist/skills/manager.d.ts +1 -0
- package/dist/skills/manager.js +9 -0
- package/dist/tools/binary.d.ts +46 -0
- package/dist/tools/binary.js +100 -0
- package/dist/tools/browserCdp.js +13 -1
- package/dist/tools/browserExtension.d.ts +5 -0
- package/dist/tools/browserExtension.js +202 -18
- package/dist/tools/browserSession.d.ts +23 -0
- package/dist/tools/browserSession.js +28 -0
- package/dist/tools/computerUse.js +33 -1
- package/dist/tools/createImage.d.ts +11 -0
- package/dist/tools/createImage.js +13 -1
- package/dist/tools/definitions.js +9 -3
- package/dist/tools/editDiagnosis.d.ts +54 -0
- package/dist/tools/editDiagnosis.js +142 -0
- package/dist/tools/editFile.d.ts +1 -0
- package/dist/tools/editFile.js +118 -62
- package/dist/tools/findFiles.d.ts +21 -0
- package/dist/tools/findFiles.js +75 -6
- package/dist/tools/getSystemInfo.js +15 -7
- package/dist/tools/gitTools.d.ts +24 -0
- package/dist/tools/gitTools.js +87 -26
- package/dist/tools/moveCopyFile.d.ts +18 -1
- package/dist/tools/moveCopyFile.js +74 -4
- package/dist/tools/patchCascade.d.ts +57 -0
- package/dist/tools/patchCascade.js +338 -0
- package/dist/tools/runCommand.js +213 -25
- package/dist/tools/runner.js +57 -4
- package/dist/tools/toolDefsComputer.js +12 -2
- package/dist/tools/truncator.d.ts +27 -1
- package/dist/tools/truncator.js +117 -11
- package/dist/tools/viewFile.js +15 -11
- package/dist/tools/visionBridge.js +7 -0
- package/dist/tools/webSearch.d.ts +21 -0
- package/dist/tools/webSearch.js +125 -14
- package/dist/tools/writeFile.js +18 -1
- package/dist/ui/interrupt.d.ts +4 -1
- package/dist/ui/interrupt.js +56 -5
- package/dist/ui/keyboardGuard.d.ts +55 -0
- package/dist/ui/keyboardGuard.js +65 -0
- package/dist/ui/loopPrompt.d.ts +17 -0
- package/dist/ui/loopPrompt.js +59 -0
- package/dist/ui/permissionPrompt.js +84 -2
- package/dist/ui/prompt.d.ts +35 -0
- package/dist/ui/prompt.js +345 -125
- package/dist/ui/reasoningStream.d.ts +8 -0
- package/dist/ui/reasoningStream.js +99 -7
- package/extension/README.md +95 -74
- package/extension/background.js +598 -178
- package/extension/content.js +92 -9
- package/extension/manifest.json +2 -1
- package/extension/popup.js +28 -12
- package/package.json +1 -1
package/extension/background.js
CHANGED
|
@@ -13,18 +13,120 @@
|
|
|
13
13
|
// todas pueden trabajar.
|
|
14
14
|
const WS_PORTS = [8787, 8788, 8789, 8790];
|
|
15
15
|
const GROUP_TITLE = "Chocolatito Code";
|
|
16
|
+
const VERSION = "1.2.0";
|
|
16
17
|
|
|
17
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Tope de un comando dentro de la extension.
|
|
20
|
+
*
|
|
21
|
+
* La CLI ya tiene el suyo, pero el suyo solo hace que ella deje de esperar: la
|
|
22
|
+
* accion se queda corriendo aqui y puede llegar tarde, cuando el modelo ya ha
|
|
23
|
+
* pedido otra cosa. Cortando de este lado, lo que la CLI recibe es un error
|
|
24
|
+
* explicable en vez de un silencio.
|
|
25
|
+
*/
|
|
26
|
+
const TIMEOUT_COMANDO_MS = 45000;
|
|
27
|
+
|
|
28
|
+
/** puerto -> { socket, delay, timer, state, sessionId, reconectando } */
|
|
18
29
|
const links = new Map();
|
|
19
30
|
|
|
20
|
-
/**
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
31
|
+
/** Paginas donde Chrome no deja entrar a ninguna extension. */
|
|
32
|
+
const ESQUEMAS_VETADOS = /^(chrome|edge|about|devtools|view-source|chrome-untrusted|chrome-search|chrome-error):/i;
|
|
33
|
+
const TIENDAS_VETADAS = /^https:\/\/(chromewebstore\.google\.com|chrome\.google\.com\/webstore)/i;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Estado de UNA sesion de la CLI.
|
|
37
|
+
*
|
|
38
|
+
* Antes esto eran tres variables de modulo. Como la extension se conecta a los
|
|
39
|
+
* cuatro puertos desde el MISMO service worker, dos sesiones compartian indice
|
|
40
|
+
* de refs, pestana de trabajo y grupo: el snapshot de la sesion B borraba el
|
|
41
|
+
* indice de A, y el clic de A caia sobre la pestana de B. Un estado por sesion
|
|
42
|
+
* es lo que hace cierto lo que promete la CLI: que varias convivan.
|
|
43
|
+
*
|
|
44
|
+
* refIndex: ref -> { tabId, frameId }, para resolver despues los clics.
|
|
45
|
+
* propias: pestanas que ESTA sesion abrio o adopto con select. Es lo unico
|
|
46
|
+
* que puede cerrar; ver close_tab.
|
|
47
|
+
*/
|
|
48
|
+
function nuevoEstado() {
|
|
49
|
+
return { refIndex: new Map(), workingTabId: null, groupId: null, propias: new Set() };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ======================================================= estado persistente
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* En Manifest V3 el service worker se apaga cuando le parece. Cuando eso pasa,
|
|
56
|
+
* el estado en memoria —indice de refs, pestana de trabajo, grupo— desaparece
|
|
57
|
+
* entero y sin avisar: el siguiente clic responde "el ref 3 no existe" y el
|
|
58
|
+
* siguiente close_tab dice que no hay pestana de trabajo, aunque en pantalla
|
|
59
|
+
* siga todo donde estaba. La extension ya pedia el permiso `storage` en el
|
|
60
|
+
* manifiesto y no lo usaba para nada.
|
|
61
|
+
*
|
|
62
|
+
* Se guarda en `storage.session`, que vive en memoria y se borra al cerrar
|
|
63
|
+
* Chrome: es un apunte para sobrevivir a un reinicio del worker, no un
|
|
64
|
+
* historial de navegacion en el disco del usuario.
|
|
65
|
+
*/
|
|
66
|
+
const almacen = (() => {
|
|
67
|
+
try {
|
|
68
|
+
return chrome.storage && chrome.storage.session ? chrome.storage.session : null;
|
|
69
|
+
} catch (_) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
|
|
74
|
+
const claveDe = (sessionId) => "sesion:" + sessionId;
|
|
75
|
+
|
|
76
|
+
function serializar(st) {
|
|
77
|
+
return {
|
|
78
|
+
workingTabId: st.workingTabId,
|
|
79
|
+
groupId: st.groupId,
|
|
80
|
+
propias: [...st.propias],
|
|
81
|
+
refIndex: [...st.refIndex.entries()],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function deserializar(plano) {
|
|
86
|
+
const st = nuevoEstado();
|
|
87
|
+
if (!plano) return st;
|
|
88
|
+
st.workingTabId = plano.workingTabId ?? null;
|
|
89
|
+
st.groupId = plano.groupId ?? null;
|
|
90
|
+
st.propias = new Set(plano.propias || []);
|
|
91
|
+
st.refIndex = new Map(plano.refIndex || []);
|
|
92
|
+
return st;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function guardarEstado(link) {
|
|
96
|
+
if (!almacen || !link || !link.sessionId) return;
|
|
97
|
+
try {
|
|
98
|
+
await almacen.set({ [claveDe(link.sessionId)]: serializar(link.state) });
|
|
99
|
+
} catch (_) {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function restaurarEstado(link, sessionId) {
|
|
103
|
+
link.sessionId = sessionId;
|
|
104
|
+
if (!almacen) return;
|
|
105
|
+
try {
|
|
106
|
+
const guardado = await almacen.get(claveDe(sessionId));
|
|
107
|
+
const plano = guardado && guardado[claveDe(sessionId)];
|
|
108
|
+
if (!plano) return;
|
|
109
|
+
link.state = deserializar(plano);
|
|
110
|
+
// La pestana pudo cerrarse mientras el worker estaba dormido.
|
|
111
|
+
if (link.state.workingTabId) {
|
|
112
|
+
try {
|
|
113
|
+
await chrome.tabs.get(link.state.workingTabId);
|
|
114
|
+
} catch (_) {
|
|
115
|
+
link.state.workingTabId = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} catch (_) {}
|
|
119
|
+
}
|
|
24
120
|
|
|
25
121
|
// ============================================================ conexion
|
|
26
122
|
|
|
27
123
|
function connect(port) {
|
|
124
|
+
const existente = links.get(port);
|
|
125
|
+
// Sin esta guarda, dos reintentos solapados dejaban dos sockets vivos contra
|
|
126
|
+
// el mismo puerto: la CLI se quedaba con el ultimo y el otro seguia
|
|
127
|
+
// consumiendo un keepalive para siempre.
|
|
128
|
+
if (existente && existente.socket && existente.socket.readyState === WebSocket.OPEN) return;
|
|
129
|
+
|
|
28
130
|
let ws;
|
|
29
131
|
try {
|
|
30
132
|
ws = new WebSocket("ws://127.0.0.1:" + port);
|
|
@@ -33,16 +135,29 @@ function connect(port) {
|
|
|
33
135
|
return;
|
|
34
136
|
}
|
|
35
137
|
|
|
36
|
-
const link =
|
|
138
|
+
const link = existente || { delay: 1000, state: nuevoEstado() };
|
|
139
|
+
if (!link.state) link.state = nuevoEstado();
|
|
37
140
|
link.socket = ws;
|
|
141
|
+
link.reconectando = false;
|
|
38
142
|
links.set(port, link);
|
|
39
143
|
|
|
40
144
|
ws.onopen = () => {
|
|
41
145
|
link.delay = 1000;
|
|
42
|
-
|
|
146
|
+
// Que este puerto vuelva a aceptar conexion puede significar dos cosas muy
|
|
147
|
+
// distintas: otra sesion de la CLI (hay que empezar en limpio, heredar su
|
|
148
|
+
// pestana de trabajo es como acaban los comandos sin tabId actuando sobre
|
|
149
|
+
// una pestana ajena) o el mismo proceso de siempre despues de que Chrome
|
|
150
|
+
// apagara este worker (hay que RECUPERAR el estado, o el usuario ve como
|
|
151
|
+
// el agente se olvida de donde estaba trabajando a mitad de tarea).
|
|
152
|
+
//
|
|
153
|
+
// No se puede distinguir sin preguntar, asi que se arranca en limpio y se
|
|
154
|
+
// recupera solo si la CLI se identifica con el mismo sessionId de antes.
|
|
155
|
+
link.state = nuevoEstado();
|
|
156
|
+
link.sessionId = null;
|
|
157
|
+
sendTo(ws, { type: "hello", agent: "chocolatito-extension", version: VERSION, port, resumable: !!almacen });
|
|
43
158
|
refreshBadge();
|
|
44
|
-
//
|
|
45
|
-
//
|
|
159
|
+
// Un ping periodico mantiene viva la conexion y, con ella, el worker: la
|
|
160
|
+
// actividad de WebSocket reinicia el temporizador de inactividad de MV3.
|
|
46
161
|
clearInterval(link.timer);
|
|
47
162
|
link.timer = setInterval(() => sendTo(ws, { type: "keepalive" }), 20000);
|
|
48
163
|
};
|
|
@@ -52,13 +167,27 @@ function connect(port) {
|
|
|
52
167
|
try {
|
|
53
168
|
msg = JSON.parse(ev.data);
|
|
54
169
|
} catch (_) {
|
|
170
|
+
return; // sin id no hay a quien contestarle
|
|
171
|
+
}
|
|
172
|
+
if (!msg) return;
|
|
173
|
+
|
|
174
|
+
// La CLI se identifica al conectar. Si es la misma sesion que ya conocia
|
|
175
|
+
// este worker, se recupera su estado; si es otra, se queda el limpio.
|
|
176
|
+
if (msg.type === "session" && typeof msg.sessionId === "string") {
|
|
177
|
+
await restaurarEstado(link, msg.sessionId);
|
|
55
178
|
return;
|
|
56
179
|
}
|
|
57
|
-
|
|
180
|
+
|
|
181
|
+
if (typeof msg.id !== "number") return;
|
|
182
|
+
|
|
183
|
+
// Un comando sin nombre antes se caia por el `throw` de handle y se
|
|
184
|
+
// contestaba igual; se deja explicito para que el error diga que pasa.
|
|
58
185
|
try {
|
|
59
|
-
const data = await handle(msg);
|
|
186
|
+
const data = await conTimeout(handle(msg, link.state), TIMEOUT_COMANDO_MS, msg.cmd);
|
|
187
|
+
await guardarEstado(link);
|
|
60
188
|
sendTo(ws, { type: "result", id: msg.id, ok: true, data });
|
|
61
189
|
} catch (err) {
|
|
190
|
+
await guardarEstado(link);
|
|
62
191
|
sendTo(ws, { type: "result", id: msg.id, ok: false, error: String((err && err.message) || err) });
|
|
63
192
|
}
|
|
64
193
|
};
|
|
@@ -78,7 +207,14 @@ function connect(port) {
|
|
|
78
207
|
function scheduleReconnect(port) {
|
|
79
208
|
const link = links.get(port) || { delay: 1000 };
|
|
80
209
|
links.set(port, link);
|
|
81
|
-
|
|
210
|
+
// onerror cierra el socket y onclose vuelve a llamar aqui: sin esta marca se
|
|
211
|
+
// encadenaban dos temporizadores por cada caida.
|
|
212
|
+
if (link.reconectando) return;
|
|
213
|
+
link.reconectando = true;
|
|
214
|
+
setTimeout(() => {
|
|
215
|
+
link.reconectando = false;
|
|
216
|
+
connect(port);
|
|
217
|
+
}, link.delay);
|
|
82
218
|
// Tope bajo a proposito: el servidor es local y reintentar cuesta nada.
|
|
83
219
|
link.delay = Math.min((link.delay || 1000) * 1.5, 3000);
|
|
84
220
|
}
|
|
@@ -109,13 +245,66 @@ function setBadge(text) {
|
|
|
109
245
|
|
|
110
246
|
// ============================================================ utilidades
|
|
111
247
|
|
|
248
|
+
function sleep(ms) {
|
|
249
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Corta una operacion que no vuelve, con un error que dice cual era. */
|
|
253
|
+
function conTimeout(promesa, ms, etiqueta) {
|
|
254
|
+
return new Promise((resolve, reject) => {
|
|
255
|
+
const t = setTimeout(() => {
|
|
256
|
+
reject(new Error(`"${etiqueta || "el comando"}" no respondio en ${Math.round(ms / 1000)} s. ` +
|
|
257
|
+
"La pagina puede estar bloqueada o pidiendo algo en un dialogo del navegador."));
|
|
258
|
+
}, ms);
|
|
259
|
+
promesa.then(
|
|
260
|
+
(v) => { clearTimeout(t); resolve(v); },
|
|
261
|
+
(e) => { clearTimeout(t); reject(e); }
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Por que no se puede trabajar en esta URL, o null si si se puede.
|
|
268
|
+
*
|
|
269
|
+
* Chrome prohibe inyectar scripts en sus propias paginas y en la tienda. Antes
|
|
270
|
+
* el intento fallaba en `chrome.scripting.executeScript` con un error de
|
|
271
|
+
* Chrome ("Cannot access contents of the page"), que no le dice al agente ni
|
|
272
|
+
* que es culpa de la URL ni que no lo reintente.
|
|
273
|
+
*/
|
|
274
|
+
function motivoUrlVetada(url) {
|
|
275
|
+
const u = String(url || "");
|
|
276
|
+
if (ESQUEMAS_VETADOS.test(u)) {
|
|
277
|
+
return `Chrome no deja que ninguna extension actue en "${u.slice(0, 60)}". ` +
|
|
278
|
+
"Es una pagina interna del navegador: abre una pestana normal (https://...) para trabajar.";
|
|
279
|
+
}
|
|
280
|
+
if (TIENDAS_VETADAS.test(u)) {
|
|
281
|
+
return "Chrome bloquea las extensiones en la Chrome Web Store. No se puede leer ni pulsar ahi.";
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function comprobarUrl(tabId) {
|
|
287
|
+
let url;
|
|
288
|
+
try {
|
|
289
|
+
url = (await chrome.tabs.get(tabId)).url;
|
|
290
|
+
} catch (_) {
|
|
291
|
+
return; // la pestana ya no esta; el error de quien llama lo explica mejor
|
|
292
|
+
}
|
|
293
|
+
const motivo = motivoUrlVetada(url);
|
|
294
|
+
if (motivo) throw new Error(motivo);
|
|
295
|
+
}
|
|
296
|
+
|
|
112
297
|
/** Manda un mensaje al content script, inyectandolo si aun no estaba. */
|
|
113
298
|
async function tell(tabId, frameId, msg) {
|
|
114
299
|
const opts = frameId === undefined || frameId === null ? {} : { frameId };
|
|
115
300
|
try {
|
|
116
301
|
return await chrome.tabs.sendMessage(tabId, msg, opts);
|
|
117
302
|
} catch (_) {
|
|
118
|
-
// Pagina cargada antes de instalar la extension: se inyecta al vuelo.
|
|
303
|
+
// Pagina cargada antes de instalar la extension: se inyecta al vuelo. Si la
|
|
304
|
+
// URL es de las que Chrome veta, se dice claramente en vez de devolver el
|
|
305
|
+
// "Cannot access contents of the page" de Chrome, que no explica ni que es
|
|
306
|
+
// culpa de la URL ni que no hay que reintentarlo.
|
|
307
|
+
await comprobarUrl(tabId);
|
|
119
308
|
await chrome.scripting.executeScript({
|
|
120
309
|
target: frameId ? { tabId, frameIds: [frameId] } : { tabId, allFrames: true },
|
|
121
310
|
files: ["content.js"],
|
|
@@ -133,44 +322,106 @@ async function framesOf(tabId) {
|
|
|
133
322
|
}
|
|
134
323
|
}
|
|
135
324
|
|
|
136
|
-
|
|
325
|
+
/** Pestana explicita, o la de trabajo de ESTA sesion. null si no hay ninguna. */
|
|
326
|
+
async function ownTab(tabId, st) {
|
|
137
327
|
if (tabId) return tabId;
|
|
138
|
-
if (workingTabId) {
|
|
328
|
+
if (st.workingTabId) {
|
|
139
329
|
try {
|
|
140
|
-
await chrome.tabs.get(workingTabId);
|
|
141
|
-
return workingTabId;
|
|
330
|
+
await chrome.tabs.get(st.workingTabId);
|
|
331
|
+
return st.workingTabId;
|
|
142
332
|
} catch (_) {
|
|
143
|
-
workingTabId = null;
|
|
333
|
+
st.workingTabId = null;
|
|
144
334
|
}
|
|
145
335
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Pestana para los comandos que ACTUAN (navigate, close_tab, eval, teclas).
|
|
341
|
+
*
|
|
342
|
+
* Aqui NO se cae a la pestana activa. Esa es la que el usuario tiene delante, y
|
|
343
|
+
* cuando no habia pestana de trabajo -arranque en frio, o despues de un
|
|
344
|
+
* `done`/`ungroup`- close_tab se la cerraba con el formulario a medio rellenar y
|
|
345
|
+
* navigate se la llevaba a otra URL. El dialogo de permiso solo dice la accion
|
|
346
|
+
* ("close_tab"), asi que el usuario ni siquiera podia ver cual moria. Fallar es
|
|
347
|
+
* lo correcto: que el agente abra o seleccione una pestana suya.
|
|
348
|
+
*/
|
|
349
|
+
async function actTab(tabId, st) {
|
|
350
|
+
const id = await ownTab(tabId, st);
|
|
351
|
+
if (id) return id;
|
|
352
|
+
throw new Error(
|
|
353
|
+
"no hay pestana de trabajo: abrela con open, elige una con select, o pasa tabId. " +
|
|
354
|
+
"No se actua sobre la pestana que el usuario tiene delante."
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* La pestana que el usuario tiene delante, con varios planes B.
|
|
360
|
+
*
|
|
361
|
+
* `lastFocusedWindow` era el unico criterio, y es justo el que falla en el caso
|
|
362
|
+
* de uso que la extension anuncia: si el usuario esta en un juego a pantalla
|
|
363
|
+
* completa, Chrome no tiene ninguna ventana enfocada y la consulta vuelve
|
|
364
|
+
* vacia. El agente recibia entonces "no hay ninguna pestana disponible" con
|
|
365
|
+
* doce pestanas abiertas delante.
|
|
366
|
+
*/
|
|
367
|
+
async function pestanaVisible() {
|
|
368
|
+
const intentos = [
|
|
369
|
+
{ active: true, lastFocusedWindow: true },
|
|
370
|
+
{ active: true, currentWindow: true },
|
|
371
|
+
{ active: true },
|
|
372
|
+
];
|
|
373
|
+
for (const q of intentos) {
|
|
374
|
+
try {
|
|
375
|
+
const [t] = await chrome.tabs.query(q);
|
|
376
|
+
if (t) return t;
|
|
377
|
+
} catch (_) {}
|
|
378
|
+
}
|
|
379
|
+
try {
|
|
380
|
+
const [t] = await chrome.tabs.query({});
|
|
381
|
+
if (t) return t;
|
|
382
|
+
} catch (_) {}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Pestana para los comandos de solo lectura (snapshot, getText, screenshot,
|
|
388
|
+
* waitFor, banner): mirar lo que el usuario tiene delante es aceptable.
|
|
389
|
+
*
|
|
390
|
+
* Devuelve own=false cuando es prestada, para que el que lee NO la adopte como
|
|
391
|
+
* pestana de trabajo: adoptarla convertiria el siguiente close_tab sin tabId en
|
|
392
|
+
* el cierre de la pestana del usuario, por la puerta de atras.
|
|
393
|
+
*/
|
|
394
|
+
async function readTab(tabId, st) {
|
|
395
|
+
const id = await ownTab(tabId, st);
|
|
396
|
+
if (id) return { tabId: id, own: true };
|
|
397
|
+
const activa = await pestanaVisible();
|
|
398
|
+
if (!activa) throw new Error("no hay ninguna pestana disponible");
|
|
399
|
+
return { tabId: activa.id, own: false };
|
|
149
400
|
}
|
|
150
401
|
|
|
151
402
|
/**
|
|
152
403
|
* Mete la pestana en un grupo visible llamado "Chocolatito Code", para que el
|
|
153
404
|
* usuario vea de un vistazo donde esta trabajando el agente.
|
|
154
405
|
*/
|
|
155
|
-
async function groupTab(tabId) {
|
|
406
|
+
async function groupTab(tabId, st) {
|
|
156
407
|
try {
|
|
157
|
-
if (groupId !== null) {
|
|
408
|
+
if (st.groupId !== null) {
|
|
158
409
|
try {
|
|
159
|
-
await chrome.tabGroups.get(groupId);
|
|
410
|
+
await chrome.tabGroups.get(st.groupId);
|
|
160
411
|
} catch (_) {
|
|
161
|
-
groupId = null;
|
|
412
|
+
st.groupId = null;
|
|
162
413
|
}
|
|
163
414
|
}
|
|
164
415
|
const newGroupId = await chrome.tabs.group(
|
|
165
|
-
groupId === null ? { tabIds: [tabId] } : { tabIds: [tabId], groupId }
|
|
416
|
+
st.groupId === null ? { tabIds: [tabId] } : { tabIds: [tabId], groupId: st.groupId }
|
|
166
417
|
);
|
|
167
|
-
groupId = newGroupId;
|
|
168
|
-
await chrome.tabGroups.update(groupId, {
|
|
418
|
+
st.groupId = newGroupId;
|
|
419
|
+
await chrome.tabGroups.update(st.groupId, {
|
|
169
420
|
title: GROUP_TITLE,
|
|
170
421
|
color: "orange",
|
|
171
422
|
collapsed: false,
|
|
172
423
|
});
|
|
173
|
-
return groupId;
|
|
424
|
+
return st.groupId;
|
|
174
425
|
} catch (err) {
|
|
175
426
|
return null;
|
|
176
427
|
}
|
|
@@ -182,33 +433,142 @@ async function banner(tabId, text) {
|
|
|
182
433
|
} catch (_) {}
|
|
183
434
|
}
|
|
184
435
|
|
|
436
|
+
/**
|
|
437
|
+
* Espera a que la pestana termine de cargar DE VERDAD.
|
|
438
|
+
*
|
|
439
|
+
* La version anterior solo miraba `status === "complete"`, y lo miraba justo
|
|
440
|
+
* despues de pedir la navegacion: la pestana todavia informaba del estado de la
|
|
441
|
+
* pagina ANTERIOR, asi que la espera volvia al instante y el snapshot siguiente
|
|
442
|
+
* leia la pagina vieja. Un fallo de los que no dan error: el agente cree que ya
|
|
443
|
+
* esta en la pagina nueva y describe la anterior.
|
|
444
|
+
*
|
|
445
|
+
* Ahora hay que ver primero la transicion (estado "loading" o cambio de URL) y
|
|
446
|
+
* solo despues aceptar el "complete". El evento onUpdated resuelve rapido; el
|
|
447
|
+
* sondeo esta de red por si la extension no puede suscribirse.
|
|
448
|
+
*/
|
|
449
|
+
function esperarCarga(tabId, timeout, urlAnterior) {
|
|
450
|
+
return new Promise((resolve) => {
|
|
451
|
+
const deadline = Date.now() + (timeout || 30000);
|
|
452
|
+
let vioTransicion = urlAnterior === undefined || urlAnterior === null;
|
|
453
|
+
let terminado = false;
|
|
454
|
+
let oyente = null;
|
|
455
|
+
|
|
456
|
+
const acabar = (valor) => {
|
|
457
|
+
if (terminado) return;
|
|
458
|
+
terminado = true;
|
|
459
|
+
if (oyente) {
|
|
460
|
+
try { chrome.tabs.onUpdated.removeListener(oyente); } catch (_) {}
|
|
461
|
+
}
|
|
462
|
+
resolve(valor);
|
|
463
|
+
};
|
|
185
464
|
|
|
465
|
+
try {
|
|
466
|
+
if (chrome.tabs.onUpdated && chrome.tabs.onUpdated.addListener) {
|
|
467
|
+
oyente = (id, info) => {
|
|
468
|
+
if (id !== tabId) return;
|
|
469
|
+
if (info.status === "loading" || (info.url && info.url !== urlAnterior)) vioTransicion = true;
|
|
470
|
+
if (vioTransicion && info.status === "complete") acabar(true);
|
|
471
|
+
};
|
|
472
|
+
chrome.tabs.onUpdated.addListener(oyente);
|
|
473
|
+
}
|
|
474
|
+
} catch (_) {}
|
|
186
475
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
476
|
+
const sondeo = async () => {
|
|
477
|
+
if (terminado) return;
|
|
478
|
+
try {
|
|
479
|
+
const t = await chrome.tabs.get(tabId);
|
|
480
|
+
if (!vioTransicion && (t.status === "loading" || (t.url && t.url !== urlAnterior))) {
|
|
481
|
+
vioTransicion = true;
|
|
482
|
+
}
|
|
483
|
+
if (vioTransicion && t.status === "complete") return acabar(true);
|
|
484
|
+
} catch (_) {
|
|
485
|
+
return acabar(false); // la pestana se fue
|
|
486
|
+
}
|
|
487
|
+
if (Date.now() > deadline) return acabar(false);
|
|
488
|
+
setTimeout(sondeo, 150);
|
|
489
|
+
};
|
|
490
|
+
sondeo();
|
|
491
|
+
});
|
|
492
|
+
}
|
|
199
493
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
494
|
+
// ==================================================== acciones de confianza
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Cola de adjuntos del depurador POR PESTANA.
|
|
498
|
+
*
|
|
499
|
+
* chrome.debugger.attach falla si ya hay otro depurador adjunto a esa pestana,
|
|
500
|
+
* y con cuatro sesiones hablando por el mismo worker eso pasa en cuanto dos
|
|
501
|
+
* escriben a la vez. Antes cada una lo intentaba a lo bruto y la segunda caia
|
|
502
|
+
* al camino del DOM sin que nadie supiera por que.
|
|
503
|
+
*/
|
|
504
|
+
const colaPorPestana = new Map();
|
|
505
|
+
|
|
506
|
+
function enCola(tabId, fn) {
|
|
507
|
+
const anterior = colaPorPestana.get(tabId) || Promise.resolve();
|
|
508
|
+
const turno = anterior.then(fn, fn);
|
|
509
|
+
const marca = turno.catch(() => {});
|
|
510
|
+
colaPorPestana.set(tabId, marca);
|
|
511
|
+
marca.then(() => {
|
|
512
|
+
if (colaPorPestana.get(tabId) === marca) colaPorPestana.delete(tabId);
|
|
513
|
+
});
|
|
514
|
+
return turno;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function cdp(target, method, params) {
|
|
518
|
+
return chrome.debugger.sendCommand(target, method, params || {});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Activa la pestana, ejecuta la accion por CDP y lo deja todo como estaba.
|
|
523
|
+
*
|
|
524
|
+
* El renderizador de una pestana que no esta activa descarta los eventos de
|
|
525
|
+
* entrada: por eso escribir en segundo plano dejaba el campo vacio. Se activa
|
|
526
|
+
* solo lo que dura la accion y despues se devuelve la que estaba.
|
|
527
|
+
*
|
|
528
|
+
* Importante: esto NO toca el foco del sistema operativo. Solo cambia que
|
|
529
|
+
* pestana se ve dentro de Chrome. Si el usuario esta en un juego a pantalla
|
|
530
|
+
* completa o en otra aplicacion, no nota nada.
|
|
531
|
+
*/
|
|
532
|
+
function withTrustedTab(tabId, fn, preparar) {
|
|
533
|
+
return enCola(tabId, async () => {
|
|
534
|
+
const target = { tabId };
|
|
535
|
+
let previaId = null;
|
|
536
|
+
let attached = false;
|
|
537
|
+
try {
|
|
538
|
+
const tab = await chrome.tabs.get(tabId);
|
|
539
|
+
const [activa] = await chrome.tabs.query({ active: true, windowId: tab.windowId });
|
|
540
|
+
if (activa && activa.id !== tabId) previaId = activa.id;
|
|
541
|
+
if (!tab.active) await chrome.tabs.update(tabId, { active: true });
|
|
542
|
+
await sleep(250);
|
|
543
|
+
} catch (_) {}
|
|
544
|
+
|
|
545
|
+
// `preparar` corre DENTRO de la cola y con la pestana ya activa. Ahi es
|
|
546
|
+
// donde se piden las coordenadas del elemento: pedirlas fuera —como se
|
|
547
|
+
// hacia— significa medirlas antes de que le toque el turno, y para entonces
|
|
548
|
+
// la accion anterior puede haber movido la pagina. El clic salia entonces
|
|
549
|
+
// sobre lo que hubiera quedado en ese punto, no sobre el elemento pedido.
|
|
550
|
+
const preparado = preparar ? await preparar() : undefined;
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
await chrome.debugger.attach(target, "1.3");
|
|
554
|
+
attached = true;
|
|
555
|
+
return await fn(target, preparado);
|
|
556
|
+
} finally {
|
|
557
|
+
if (attached) {
|
|
558
|
+
try { await chrome.debugger.detach(target); } catch (_) {}
|
|
559
|
+
}
|
|
560
|
+
if (previaId !== null) {
|
|
561
|
+
try { await chrome.tabs.update(previaId, { active: true }); } catch (_) {}
|
|
562
|
+
}
|
|
210
563
|
}
|
|
211
|
-
}
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Pide al content script donde esta el elemento, en coordenadas del viewport. */
|
|
568
|
+
async function situar(tabId, frameId, ref, queEs) {
|
|
569
|
+
const pos = await tell(tabId, frameId, { cmd: "rect", ref });
|
|
570
|
+
if (!pos || !pos.ok) throw new Error((pos && pos.error) || `no se pudo situar ${queEs}`);
|
|
571
|
+
return pos;
|
|
212
572
|
}
|
|
213
573
|
|
|
214
574
|
/**
|
|
@@ -219,26 +579,20 @@ async function withTrustedTab(tabId, fn) {
|
|
|
219
579
|
* En Flow, el boton de enviar recibia el clic sintetico y no pasaba nada.
|
|
220
580
|
*/
|
|
221
581
|
async function clickTrusted(tabId, frameId, ref) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// ==================================================== escritura de confianza
|
|
239
|
-
|
|
240
|
-
function cdp(target, method, params) {
|
|
241
|
-
return chrome.debugger.sendCommand(target, method, params || {});
|
|
582
|
+
return await withTrustedTab(
|
|
583
|
+
tabId,
|
|
584
|
+
async (target, pos) => {
|
|
585
|
+
const base = { x: pos.x, y: pos.y, button: "left", clickCount: 1 };
|
|
586
|
+
await cdp(target, "Input.dispatchMouseEvent", { type: "mouseMoved", x: pos.x, y: pos.y });
|
|
587
|
+
await sleep(60);
|
|
588
|
+
await cdp(target, "Input.dispatchMouseEvent", { ...base, type: "mousePressed", buttons: 1 });
|
|
589
|
+
await sleep(60);
|
|
590
|
+
await cdp(target, "Input.dispatchMouseEvent", { ...base, type: "mouseReleased", buttons: 0 });
|
|
591
|
+
await sleep(300);
|
|
592
|
+
return { label: pos.label };
|
|
593
|
+
},
|
|
594
|
+
() => situar(tabId, frameId, ref, "el elemento")
|
|
595
|
+
);
|
|
242
596
|
}
|
|
243
597
|
|
|
244
598
|
/**
|
|
@@ -250,36 +604,10 @@ function cdp(target, method, params) {
|
|
|
250
604
|
* proporcionar una instruccion". Es exactamente lo que pasaba.
|
|
251
605
|
*
|
|
252
606
|
* Input.insertText y Input.dispatchKeyEvent los procesa el renderizador igual que
|
|
253
|
-
* si los hubiera tecleado una persona
|
|
254
|
-
* plano: no hace falta foco ni traer la ventana al frente.
|
|
607
|
+
* si los hubiera tecleado una persona.
|
|
255
608
|
*/
|
|
256
609
|
async function typeTrusted(tabId, frameId, ref, text, submit, replace) {
|
|
257
|
-
const
|
|
258
|
-
if (!pos || !pos.ok) throw new Error((pos && pos.error) || "no se pudo situar el campo");
|
|
259
|
-
|
|
260
|
-
const target = { tabId };
|
|
261
|
-
let attached = false;
|
|
262
|
-
|
|
263
|
-
// El renderizador de una pestana que no esta activa descarta los eventos de
|
|
264
|
-
// texto: por eso escribir en segundo plano dejaba el campo vacio. Se activa la
|
|
265
|
-
// pestana solo lo que dura la escritura y despues se devuelve la que estaba.
|
|
266
|
-
//
|
|
267
|
-
// Importante: esto NO toca el foco del sistema operativo. Solo cambia que
|
|
268
|
-
// pestana se ve dentro de Chrome, y se deja como estaba. Si el usuario esta en
|
|
269
|
-
// un juego a pantalla completa o en otra aplicacion, no nota nada.
|
|
270
|
-
let previaId = null;
|
|
271
|
-
try {
|
|
272
|
-
const tab = await chrome.tabs.get(tabId);
|
|
273
|
-
const [activa] = await chrome.tabs.query({ active: true, windowId: tab.windowId });
|
|
274
|
-
if (activa && activa.id !== tabId) previaId = activa.id;
|
|
275
|
-
if (!tab.active) await chrome.tabs.update(tabId, { active: true });
|
|
276
|
-
await sleep(250);
|
|
277
|
-
} catch (_) {}
|
|
278
|
-
|
|
279
|
-
try {
|
|
280
|
-
await chrome.debugger.attach(target, "1.3");
|
|
281
|
-
attached = true;
|
|
282
|
-
|
|
610
|
+
const etiqueta = await withTrustedTab(tabId, async (target, pos) => {
|
|
283
611
|
// Clic real para que el editor tome el foco de verdad.
|
|
284
612
|
const click = { x: pos.x, y: pos.y, button: "left", clickCount: 1, buttons: 1 };
|
|
285
613
|
await cdp(target, "Input.dispatchMouseEvent", { type: "mouseMoved", x: pos.x, y: pos.y });
|
|
@@ -316,50 +644,93 @@ async function typeTrusted(tabId, frameId, ref, text, submit, replace) {
|
|
|
316
644
|
await cdp(target, "Input.dispatchKeyEvent", { ...enter, type: "keyUp" });
|
|
317
645
|
await sleep(400);
|
|
318
646
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
try { await chrome.debugger.detach(target); } catch (_) {}
|
|
322
|
-
}
|
|
323
|
-
// Se devuelve la pestana que el usuario tenia delante.
|
|
324
|
-
if (previaId !== null) {
|
|
325
|
-
try { await chrome.tabs.update(previaId, { active: true }); } catch (_) {}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
647
|
+
return pos.label;
|
|
648
|
+
}, () => situar(tabId, frameId, ref, "el campo"));
|
|
328
649
|
|
|
329
650
|
const after = await tell(tabId, frameId, { cmd: "value", ref });
|
|
330
|
-
return { label:
|
|
651
|
+
return { label: etiqueta, value: after && after.ok ? after.value : "" };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Captura la pestana.
|
|
656
|
+
*
|
|
657
|
+
* Se prefiere Page.captureScreenshot del protocolo DevTools porque funciona con
|
|
658
|
+
* la pestana en segundo plano. Pero si hay otro depurador adjunto —lo mas
|
|
659
|
+
* comun: el usuario tiene DevTools abierto— el adjunto falla, y antes eso
|
|
660
|
+
* dejaba el comando sin captura y sin explicacion, mientras que escribir y
|
|
661
|
+
* pulsar si tenian camino alternativo. Ahora tambien lo tiene: se activa la
|
|
662
|
+
* pestana un instante y se usa la captura normal de Chrome.
|
|
663
|
+
*/
|
|
664
|
+
async function capturar(tabId) {
|
|
665
|
+
try {
|
|
666
|
+
return await withTrustedTab(tabId, async (target) => {
|
|
667
|
+
const res = await cdp(target, "Page.captureScreenshot", { format: "png", captureBeyondViewport: false });
|
|
668
|
+
return { data: res.data, via: "cdp" };
|
|
669
|
+
});
|
|
670
|
+
} catch (err) {
|
|
671
|
+
if (!chrome.tabs.captureVisibleTab) throw err;
|
|
672
|
+
return await enCola(tabId, async () => {
|
|
673
|
+
let previaId = null;
|
|
674
|
+
let windowId;
|
|
675
|
+
try {
|
|
676
|
+
const tab = await chrome.tabs.get(tabId);
|
|
677
|
+
windowId = tab.windowId;
|
|
678
|
+
const [activa] = await chrome.tabs.query({ active: true, windowId });
|
|
679
|
+
if (activa && activa.id !== tabId) previaId = activa.id;
|
|
680
|
+
if (!tab.active) await chrome.tabs.update(tabId, { active: true });
|
|
681
|
+
await sleep(250);
|
|
682
|
+
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, { format: "png" });
|
|
683
|
+
return {
|
|
684
|
+
data: String(dataUrl).replace(/^data:image\/png;base64,/, ""),
|
|
685
|
+
via: "tabs",
|
|
686
|
+
warning: "el depurador no estaba disponible (¿DevTools abierto?): captura por la via normal",
|
|
687
|
+
};
|
|
688
|
+
} finally {
|
|
689
|
+
if (previaId !== null) {
|
|
690
|
+
try { await chrome.tabs.update(previaId, { active: true }); } catch (_) {}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
}
|
|
331
695
|
}
|
|
332
696
|
|
|
333
697
|
// ============================================================ comandos
|
|
334
698
|
|
|
335
|
-
async function handle(msg) {
|
|
699
|
+
async function handle(msg, st) {
|
|
336
700
|
const cmd = msg.cmd;
|
|
701
|
+
if (typeof cmd !== "string" || !cmd) {
|
|
702
|
+
throw new Error("mensaje sin comando");
|
|
703
|
+
}
|
|
337
704
|
|
|
338
705
|
if (cmd === "ping") {
|
|
339
|
-
return { pong: true, version:
|
|
706
|
+
return { pong: true, version: VERSION };
|
|
340
707
|
}
|
|
341
708
|
|
|
342
709
|
if (cmd === "tabs") {
|
|
343
710
|
const tabs = await chrome.tabs.query({});
|
|
344
711
|
return {
|
|
345
|
-
working: workingTabId,
|
|
712
|
+
working: st.workingTabId,
|
|
346
713
|
tabs: tabs.map((t) => ({
|
|
347
714
|
id: t.id,
|
|
348
715
|
title: t.title || "",
|
|
349
716
|
url: t.url || "",
|
|
350
717
|
active: !!t.active,
|
|
351
|
-
grouped: t.groupId !== undefined && t.groupId === groupId,
|
|
718
|
+
grouped: t.groupId !== undefined && t.groupId === st.groupId,
|
|
719
|
+
propia: st.propias.has(t.id),
|
|
352
720
|
})),
|
|
353
721
|
};
|
|
354
722
|
}
|
|
355
723
|
|
|
356
724
|
if (cmd === "open") {
|
|
725
|
+
const vetada = motivoUrlVetada(msg.url);
|
|
726
|
+
if (vetada) throw new Error(vetada);
|
|
357
727
|
// active:false es la clave: la pestana se crea SIN robar el foco ni cambiar
|
|
358
728
|
// lo que el usuario esta viendo.
|
|
359
729
|
const tab = await chrome.tabs.create({ url: msg.url, active: false });
|
|
360
|
-
workingTabId = tab.id;
|
|
361
|
-
|
|
362
|
-
await
|
|
730
|
+
st.workingTabId = tab.id;
|
|
731
|
+
st.propias.add(tab.id);
|
|
732
|
+
await esperarCarga(tab.id, msg.timeout || 30000);
|
|
733
|
+
await groupTab(tab.id, st);
|
|
363
734
|
await banner(tab.id, msg.note || "pestana abierta");
|
|
364
735
|
const info = await chrome.tabs.get(tab.id);
|
|
365
736
|
return { tabId: tab.id, title: info.title || "", url: info.url || "" };
|
|
@@ -372,42 +743,68 @@ async function handle(msg) {
|
|
|
372
743
|
(t) => (t.url || "").toLowerCase().includes(needle) || (t.title || "").toLowerCase().includes(needle)
|
|
373
744
|
);
|
|
374
745
|
if (!hit) throw new Error('ninguna pestana contiene "' + msg.match + '"');
|
|
375
|
-
workingTabId = hit.id;
|
|
376
|
-
|
|
746
|
+
st.workingTabId = hit.id;
|
|
747
|
+
// Elegirla es un acto explicito del agente: a partir de aqui es suya y la
|
|
748
|
+
// puede cerrar. Mirarla (readTab) nunca da ese derecho.
|
|
749
|
+
st.propias.add(hit.id);
|
|
750
|
+
await groupTab(hit.id, st);
|
|
377
751
|
await banner(hit.id, "pestana seleccionada");
|
|
378
752
|
return { tabId: hit.id, title: hit.title || "", url: hit.url || "" };
|
|
379
753
|
}
|
|
380
754
|
|
|
381
755
|
if (cmd === "navigate") {
|
|
382
|
-
const
|
|
756
|
+
const vetada = motivoUrlVetada(msg.url);
|
|
757
|
+
if (vetada) throw new Error(vetada);
|
|
758
|
+
const tabId = await actTab(msg.tabId, st);
|
|
759
|
+
let urlAnterior;
|
|
760
|
+
try {
|
|
761
|
+
urlAnterior = (await chrome.tabs.get(tabId)).url;
|
|
762
|
+
} catch (_) {}
|
|
763
|
+
// La espera se arma ANTES de pedir la navegacion: al reves se pierde el
|
|
764
|
+
// evento de las paginas que cargan rapido.
|
|
765
|
+
const cargando = esperarCarga(tabId, msg.timeout || 30000, urlAnterior);
|
|
383
766
|
await chrome.tabs.update(tabId, { url: msg.url });
|
|
384
|
-
await
|
|
385
|
-
workingTabId = tabId;
|
|
386
|
-
|
|
767
|
+
await cargando;
|
|
768
|
+
st.workingTabId = tabId;
|
|
769
|
+
st.propias.add(tabId);
|
|
770
|
+
await groupTab(tabId, st);
|
|
387
771
|
const info = await chrome.tabs.get(tabId);
|
|
388
772
|
await banner(tabId, "navegando a " + (info.url || "").slice(0, 60));
|
|
389
773
|
return { tabId, title: info.title || "", url: info.url || "" };
|
|
390
774
|
}
|
|
391
775
|
|
|
392
776
|
if (cmd === "snapshot") {
|
|
393
|
-
const tabId = await
|
|
394
|
-
|
|
777
|
+
const { tabId, own } = await readTab(msg.tabId, st);
|
|
778
|
+
// Solo se adopta como pestana de trabajo si ya era nuestra o vino con tabId.
|
|
779
|
+
if (own) st.workingTabId = tabId;
|
|
395
780
|
await banner(tabId, "leyendo la pagina");
|
|
396
|
-
refIndex.clear();
|
|
781
|
+
st.refIndex.clear();
|
|
397
782
|
|
|
398
783
|
const frames = await framesOf(tabId);
|
|
399
784
|
const rows = [];
|
|
400
785
|
let counter = 0;
|
|
786
|
+
let fallos = 0;
|
|
401
787
|
for (const frameId of frames) {
|
|
402
788
|
let res;
|
|
403
789
|
try {
|
|
404
|
-
|
|
790
|
+
// `nuevo` va a TODOS los frames, no solo al primero: cada frame tiene su
|
|
791
|
+
// propio content script con su propia tabla de identidades, y antes solo
|
|
792
|
+
// se limpiaba la del frame con startIndex 0. Los subframes acumulaban
|
|
793
|
+
// identidades de snapshots viejos y resolvian un ref a un boton que ya
|
|
794
|
+
// no era ese.
|
|
795
|
+
res = await tell(tabId, frameId, {
|
|
796
|
+
cmd: "snapshot",
|
|
797
|
+
startIndex: counter,
|
|
798
|
+
filter: msg.filter || "",
|
|
799
|
+
nuevo: true,
|
|
800
|
+
});
|
|
405
801
|
} catch (_) {
|
|
802
|
+
fallos++;
|
|
406
803
|
continue;
|
|
407
804
|
}
|
|
408
805
|
if (!res || !res.ok) continue;
|
|
409
806
|
for (const item of res.items) {
|
|
410
|
-
refIndex.set(item.ref, { tabId, frameId });
|
|
807
|
+
st.refIndex.set(item.ref, { tabId, frameId });
|
|
411
808
|
rows.push(item);
|
|
412
809
|
}
|
|
413
810
|
counter += res.items.length;
|
|
@@ -415,23 +812,33 @@ async function handle(msg) {
|
|
|
415
812
|
|
|
416
813
|
const info = await chrome.tabs.get(tabId);
|
|
417
814
|
const max = msg.max || 60;
|
|
815
|
+
if (rows.length === 0) {
|
|
816
|
+
// Cero elementos y todos los frames fallando casi siempre es una pagina
|
|
817
|
+
// donde Chrome no deja entrar. Decirlo aqui ahorra el viaje de intentar
|
|
818
|
+
// pulsar algo y recibir un error distinto.
|
|
819
|
+
const vetada = motivoUrlVetada(info.url);
|
|
820
|
+
if (vetada) throw new Error(vetada);
|
|
821
|
+
}
|
|
418
822
|
return {
|
|
419
823
|
tabId,
|
|
420
824
|
title: info.title || "",
|
|
421
825
|
url: info.url || "",
|
|
422
826
|
truncated: rows.length > max,
|
|
827
|
+
framesSinRespuesta: fallos || undefined,
|
|
423
828
|
elements: rows.slice(0, max),
|
|
424
829
|
};
|
|
425
830
|
}
|
|
426
831
|
|
|
427
832
|
if (cmd === "click" || cmd === "type" || cmd === "press") {
|
|
428
|
-
const loc = refIndex.get(msg.ref);
|
|
833
|
+
const loc = st.refIndex.get(msg.ref);
|
|
429
834
|
if (msg.ref !== undefined && msg.ref !== null && !loc) {
|
|
430
835
|
throw new Error("el ref " + msg.ref + " no existe; vuelve a hacer snapshot");
|
|
431
836
|
}
|
|
432
|
-
|
|
837
|
+
// Sin ref (press suele venir asi) esto escribe teclas en la pagina: es
|
|
838
|
+
// actuacion, nunca sobre la pestana que el usuario esta mirando.
|
|
839
|
+
const tabId = loc ? loc.tabId : await actTab(msg.tabId, st);
|
|
433
840
|
const frameId = loc ? loc.frameId : 0;
|
|
434
|
-
workingTabId = tabId;
|
|
841
|
+
st.workingTabId = tabId;
|
|
435
842
|
|
|
436
843
|
if (cmd === "click") await banner(tabId, "pulsando un elemento");
|
|
437
844
|
if (cmd === "type") await banner(tabId, "escribiendo");
|
|
@@ -473,27 +880,29 @@ async function handle(msg) {
|
|
|
473
880
|
if (!res || !res.ok) throw new Error((res && res.error) || "el content script no respondio");
|
|
474
881
|
|
|
475
882
|
// Se deja respirar a la pagina para que reaccione antes del siguiente paso.
|
|
476
|
-
await sleep(msg.settle
|
|
883
|
+
await sleep(msg.settle === undefined ? 600 : msg.settle);
|
|
477
884
|
const info = await chrome.tabs.get(tabId);
|
|
478
885
|
return { tabId, title: info.title || "", url: info.url || "", label: res.label, value: res.value, via: res.via, warning: res.warning };
|
|
479
886
|
}
|
|
480
887
|
|
|
481
888
|
if (cmd === "getText") {
|
|
482
|
-
const tabId = await
|
|
889
|
+
const { tabId } = await readTab(msg.tabId, st);
|
|
483
890
|
const res = await tell(tabId, 0, { cmd: "getText" });
|
|
484
891
|
if (!res || !res.ok) throw new Error("no se pudo leer el texto");
|
|
485
892
|
return res;
|
|
486
893
|
}
|
|
487
894
|
|
|
488
895
|
if (cmd === "eval") {
|
|
489
|
-
|
|
896
|
+
// eval ejecuta JavaScript arbitrario en la pagina: puede pulsar, enviar
|
|
897
|
+
// formularios y leerlo todo. Es actuacion, no lectura.
|
|
898
|
+
const tabId = await actTab(msg.tabId, st);
|
|
490
899
|
const res = await tell(tabId, 0, { cmd: "eval", script: msg.script });
|
|
491
|
-
if (!res || !res.ok) throw new Error("no se pudo evaluar");
|
|
900
|
+
if (!res || !res.ok) throw new Error((res && res.error) || "no se pudo evaluar");
|
|
492
901
|
return res;
|
|
493
902
|
}
|
|
494
903
|
|
|
495
904
|
if (cmd === "waitFor") {
|
|
496
|
-
const tabId = await
|
|
905
|
+
const { tabId } = await readTab(msg.tabId, st);
|
|
497
906
|
const deadline = Date.now() + (msg.timeout || 20000);
|
|
498
907
|
if (msg.text) {
|
|
499
908
|
while (Date.now() < deadline) {
|
|
@@ -508,68 +917,79 @@ async function handle(msg) {
|
|
|
508
917
|
}
|
|
509
918
|
|
|
510
919
|
if (cmd === "screenshot") {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
// estuviera activa y la ventana enfocada.
|
|
514
|
-
const tabId = await resolveTab(msg.tabId);
|
|
515
|
-
const target = { tabId };
|
|
516
|
-
await chrome.debugger.attach(target, "1.3");
|
|
517
|
-
try {
|
|
518
|
-
const res = await chrome.debugger.sendCommand(target, "Page.captureScreenshot", {
|
|
519
|
-
format: "png",
|
|
520
|
-
captureBeyondViewport: false,
|
|
521
|
-
});
|
|
522
|
-
return { data: res.data };
|
|
523
|
-
} finally {
|
|
524
|
-
try { await chrome.debugger.detach(target); } catch (_) {}
|
|
525
|
-
}
|
|
920
|
+
const { tabId } = await readTab(msg.tabId, st);
|
|
921
|
+
return await capturar(tabId);
|
|
526
922
|
}
|
|
527
923
|
|
|
528
924
|
if (cmd === "banner") {
|
|
529
|
-
const tabId = await
|
|
925
|
+
const { tabId } = await readTab(msg.tabId, st);
|
|
530
926
|
await banner(tabId, msg.text);
|
|
531
927
|
return { ok: true };
|
|
532
928
|
}
|
|
533
929
|
|
|
534
930
|
if (cmd === "close_tab") {
|
|
535
931
|
// Cerrar lo que se abre es parte de dejar el navegador como se encontro.
|
|
536
|
-
|
|
932
|
+
// Lo que NO puede pasar es cerrar lo que no se abrio.
|
|
933
|
+
//
|
|
934
|
+
// Sin tabId y sin pestana de trabajo, actTab falla en vez de llevarse la del
|
|
935
|
+
// usuario. Y con una lista explicita de tabIds -por donde antes se colaba
|
|
936
|
+
// cualquier pestana, incluida la del usuario, sin comprobacion ninguna- se
|
|
937
|
+
// exige que cada una sea de esta sesion.
|
|
938
|
+
let ids;
|
|
939
|
+
if (Array.isArray(msg.tabIds)) {
|
|
940
|
+
const ajenas = msg.tabIds.filter((id) => !st.propias.has(id) && id !== st.workingTabId);
|
|
941
|
+
if (ajenas.length > 0) {
|
|
942
|
+
throw new Error(
|
|
943
|
+
"esta sesion no abrio las pestanas " + ajenas.join(", ") + ", asi que no las cierra. " +
|
|
944
|
+
"Solo se cierran las que abrio con open o eligio con select."
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
ids = msg.tabIds;
|
|
948
|
+
} else {
|
|
949
|
+
ids = [await actTab(msg.tabId, st)];
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if (ids.length === 0) return { closed: 0 };
|
|
537
953
|
await chrome.tabs.remove(ids);
|
|
538
|
-
|
|
954
|
+
for (const id of ids) st.propias.delete(id);
|
|
955
|
+
if (ids.includes(st.workingTabId)) st.workingTabId = null;
|
|
539
956
|
return { closed: ids.length };
|
|
540
957
|
}
|
|
541
958
|
|
|
542
959
|
if (cmd === "ungroup") {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
960
|
+
// Antes solo se sacaba del grupo la pestana de trabajo: si la sesion habia
|
|
961
|
+
// abierto tres, las otras dos se quedaban dentro de un grupo naranja que ya
|
|
962
|
+
// no era de nadie, y el usuario tenia que deshacerlo a mano.
|
|
963
|
+
const aSoltar = new Set(st.propias);
|
|
964
|
+
if (st.workingTabId) aSoltar.add(st.workingTabId);
|
|
965
|
+
for (const id of aSoltar) {
|
|
966
|
+
try {
|
|
967
|
+
await chrome.tabs.ungroup(id);
|
|
968
|
+
} catch (_) {}
|
|
969
|
+
await banner(id, null);
|
|
546
970
|
}
|
|
547
|
-
groupId = null;
|
|
548
|
-
workingTabId = null;
|
|
549
|
-
|
|
971
|
+
st.groupId = null;
|
|
972
|
+
st.workingTabId = null;
|
|
973
|
+
st.propias.clear();
|
|
974
|
+
st.refIndex.clear();
|
|
975
|
+
return { ok: true, soltadas: aSoltar.size };
|
|
550
976
|
}
|
|
551
977
|
|
|
552
978
|
throw new Error("comando desconocido: " + cmd);
|
|
553
979
|
}
|
|
554
980
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
} catch (_) {
|
|
567
|
-
return resolve();
|
|
568
|
-
}
|
|
569
|
-
setTimeout(check, 250);
|
|
570
|
-
};
|
|
571
|
-
check();
|
|
981
|
+
// Una pestana cerrada por el usuario no puede seguir siendo la de trabajo de
|
|
982
|
+
// nadie: sin esto, el siguiente comando fallaba con el error de Chrome ("No tab
|
|
983
|
+
// with id") en vez de decir que ya no hay donde trabajar.
|
|
984
|
+
try {
|
|
985
|
+
chrome.tabs.onRemoved.addListener((tabId) => {
|
|
986
|
+
for (const link of links.values()) {
|
|
987
|
+
if (!link.state) continue;
|
|
988
|
+
link.state.propias.delete(tabId);
|
|
989
|
+
if (link.state.workingTabId === tabId) link.state.workingTabId = null;
|
|
990
|
+
guardarEstado(link);
|
|
991
|
+
}
|
|
572
992
|
});
|
|
573
|
-
}
|
|
993
|
+
} catch (_) {}
|
|
574
994
|
|
|
575
995
|
for (const p of WS_PORTS) connect(p);
|