chocolatito-code 1.6.13 → 1.6.15

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.
Files changed (49) hide show
  1. package/COMPUTER-USE.md +37 -0
  2. package/README.md +29 -3
  3. package/dist/agent/historialDeCambios.d.ts +77 -0
  4. package/dist/agent/historialDeCambios.js +220 -0
  5. package/dist/agent/salidaDelAgente.d.ts +9 -0
  6. package/dist/agent/salidaDelAgente.js +19 -0
  7. package/dist/agent/undoManager.d.ts +21 -0
  8. package/dist/agent/undoManager.js +85 -5
  9. package/dist/config/permissions.js +4 -0
  10. package/dist/index.js +216 -27
  11. package/dist/prompts/systemPrompt.js +15 -11
  12. package/dist/servidor/archivos.d.ts +69 -0
  13. package/dist/servidor/archivos.js +156 -0
  14. package/dist/servidor/captura.d.ts +9 -0
  15. package/dist/servidor/captura.js +48 -0
  16. package/dist/servidor/fondo.d.ts +85 -0
  17. package/dist/servidor/fondo.js +134 -0
  18. package/dist/servidor/marca.d.ts +14 -0
  19. package/dist/servidor/marca.js +14 -0
  20. package/dist/servidor/pagina.d.ts +0 -26
  21. package/dist/servidor/pagina.js +337 -10
  22. package/dist/servidor/puente.d.ts +13 -0
  23. package/dist/servidor/puente.js +11 -0
  24. package/dist/servidor/servidor.d.ts +76 -2
  25. package/dist/servidor/servidor.js +245 -13
  26. package/dist/tools/computerUse.d.ts +11 -1
  27. package/dist/tools/computerUse.js +376 -34
  28. package/dist/tools/toolDefsComputer.js +39 -14
  29. package/dist/tools/visionBridge.d.ts +4 -2
  30. package/dist/tools/visionBridge.js +40 -15
  31. package/dist/tools/win/hostScript.js +300 -29
  32. package/dist/ui/comandos.js +1 -0
  33. package/dist/ui/ink/App.js +23 -3
  34. package/dist/ui/ink/Prompt.d.ts +5 -1
  35. package/dist/ui/ink/Prompt.js +92 -4
  36. package/dist/ui/ink/control.d.ts +2 -0
  37. package/dist/ui/ink/control.js +15 -0
  38. package/dist/ui/ink/montarApp.d.ts +0 -19
  39. package/dist/ui/ink/montarApp.js +3 -0
  40. package/dist/ui/permissionPrompt.js +44 -9
  41. package/dist/ui/pieFijo.js +5 -0
  42. package/dist/ui/prompt.js +6 -0
  43. package/dist/ui/renderer.d.ts +1 -0
  44. package/dist/ui/renderer.js +27 -2
  45. package/dist/ui/selector.js +5 -0
  46. package/dist/ui/spinner.d.ts +1 -0
  47. package/dist/ui/spinner.js +6 -0
  48. package/extension/manifest.json +1 -1
  49. package/package.json +1 -1
@@ -24,6 +24,7 @@
24
24
  * aqui a `<span>` en vez de quitarlos: el diff en verde y rojo ES el producto, y
25
25
  * un diff en gris no vende nada.
26
26
  */
27
+ import { MASCOTA_PNG_BASE64, FUENTE_MARCA_BASE64 } from "./marca.js";
27
28
  export function paginaHtml() {
28
29
  return PAGINA;
29
30
  }
@@ -44,12 +45,72 @@ const PAGINA = [
44
45
  ' font:14px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }',
45
46
  'header { display:flex; align-items:center; gap:8px; background:var(--caja);',
46
47
  ' padding:calc(8px + env(safe-area-inset-top)) 12px 8px; border-bottom:1px solid var(--borde); }',
47
- 'header b { color:var(--zorro); font-weight:600; }',
48
- '#estado { margin-left:auto; font-size:12px; color:var(--suave); display:flex; align-items:center; gap:6px; }',
48
+ `@font-face {`,
49
+ ` font-family: 'Chocolatito Marca';`,
50
+ ` src: url('data:font/ttf;base64,${FUENTE_MARCA_BASE64}') format('truetype');`,
51
+ ` font-display: swap;`,
52
+ `}`,
53
+ '/* EL ZORRITO Y EL TITULO DE LA MARCA',
54
+ ' La mascota oficial con su alto fijado por CSS para que no salte al cargar,',
55
+ ' y la tipografia Chocolatito Marca declarada con @font-face en base64 (sin',
56
+ ' dependencias de red ni CDN). SOLO para el titulo: la conversacion se queda',
57
+ ' en monospace porque es salida de terminal y ahi las columnas tienen que',
58
+ ' cuadrar exactas. Todo cabe en 390px sin empujar nada. */',
59
+ 'header { font-family:Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; white-space:nowrap; }',
60
+ 'header .mascota { height:26px; width:auto; flex-shrink:0; display:block; }',
61
+ "header b { font-family:'Chocolatito Marca', sans-serif; color:var(--zorro); font-weight:700; font-size:16px; letter-spacing:-0.01em; flex-shrink:0; }",
62
+ "header .sub { font-family:'Chocolatito Marca', sans-serif; color:var(--suave); font-size:13px; font-weight:500; letter-spacing:0.02em; flex-shrink:0; }",
63
+ '#estado { margin-left:auto; font-size:12px; color:var(--suave); display:flex; align-items:center; gap:6px; flex-shrink:0; }',
49
64
  '#punto { width:8px; height:8px; border-radius:50%; background:#d14; transition:background .2s; }',
65
+ '/* Lo que produce el agente, mirable. max-width en porcentaje y no en pixeles:',
66
+ ' en un movil en vertical caben 300px justos y una pieza de 1080 rompe el ancho. */',
67
+ '.mini { margin:6px 0; }',
68
+ '/* El enlace de guardar, pegado bajo la miniatura y con sitio para el dedo. */',
69
+ '.guardar { display:inline-flex; align-items:center; gap:6px; margin-top:4px; padding:6px 12px;',
70
+ ' background:var(--caja); border:1px solid var(--borde); border-radius:999px;',
71
+ ' color:var(--texto); font-size:12px; text-decoration:none; }',
72
+ '.guardar:active { border-color:var(--zorro); }',
73
+ '.mini img, .mini video { max-width:min(100%, 420px); max-height:320px; border-radius:8px;',
74
+ ' border:1px solid var(--borde); background:#000; display:block; }',
75
+ '/* Lo que esta a punto de subir, en fila sobre la caja de escribir. */',
76
+ '#adjuntos { display:flex; gap:6px; overflow-x:auto; padding:6px 12px 0; }',
77
+ '#adjuntos:empty { display:none; }',
78
+ '.chip { display:flex; align-items:center; gap:6px; flex:0 0 auto; max-width:60%;',
79
+ ' background:var(--caja); border:1px solid var(--borde); border-radius:999px; padding:4px 10px; font-size:12px; }',
80
+ '.chip img { width:22px; height:22px; object-fit:cover; border-radius:4px; }',
81
+ '.chip span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }',
82
+ '.chip b { color:var(--suave); cursor:pointer; font-weight:400; padding:0 2px; }',
83
+ '#clip { background:none; border:1px solid var(--borde); color:var(--suave); border-radius:10px;',
84
+ ' font-size:18px; line-height:1; padding:0 12px; cursor:pointer; }',
85
+ '/* El velo de arrastrar. pointer-events:none evita el parpadeo: si el velo',
86
+ ' recibe los eventos, al aparecer dispara dragleave y se va solo. */',
87
+ '#suelta { position:fixed; inset:0; display:none; place-items:center; z-index:20;',
88
+ ' background:rgba(11,11,13,.88); color:var(--zorro); font-size:16px; pointer-events:none; }',
89
+ '#suelta.visible { display:grid; }',
50
90
  '#punto.ok { background:#2ea043; }',
51
91
  '#salida { flex:1; overflow-y:auto; overflow-x:auto; padding:12px; white-space:pre; -webkit-overflow-scrolling:touch; }',
92
+ '/* EN UNA PANTALLA ESTRECHA, LAS LINEAS SE PARTEN.',
93
+ ' Con white-space:pre cada linea del terminal conserva su largo entero, asi',
94
+ ' que en un movil hay que arrastrar de lado para leer cada renglon: la',
95
+ ' conversacion se vuelve ilegible. Se parten solo por debajo de 700px para',
96
+ ' que en el PC las columnas -tablas, diffs, el zorro del arranque- sigan',
97
+ ' cuadrando exactas. */',
98
+ '@media (max-width: 700px) {',
99
+ ' #salida { white-space:pre-wrap; overflow-wrap:anywhere; overflow-x:hidden; }',
100
+ ' .mini img, .mini video { max-width:100%; }',
101
+ ' /* EL CARTEL DEL ARRANQUE NO CABE, Y PARTIDO ES PEOR QUE NADA.',
102
+ ' El zorro y las letras de bloque miden 95 columnas. En un movil se parten',
103
+ ' por la mitad y quedan doce renglones de simbolos rotos ocupando la',
104
+ ' pantalla entera antes de la primera palabra util. Se esconden y arriba',
105
+ ' queda el zorro de la cabecera, que ya dice lo mismo. */',
106
+ ' .fila.arte { display:none; }',
107
+ '}',
52
108
  '#salida .fila { min-height:1.5em; }',
109
+ '/* LO QUE MANDAS TU, SEPARADO DE LO QUE CONTESTA EL.',
110
+ ' En un movil la conversacion es una columna estrecha de texto igual, y sin',
111
+ ' esto no se ve donde empieza cada turno: hay que leer para encontrarse. */',
112
+ '#salida .fila.mio { background:#22222a; border-left:3px solid var(--zorro);',
113
+ ' border-radius:6px; padding:6px 10px; margin:8px 0 4px; color:#fff; font-weight:600; }',
53
114
  'form { display:flex; gap:8px; background:var(--caja); border-top:1px solid var(--borde);',
54
115
  ' padding:8px 8px calc(8px + env(safe-area-inset-bottom)); }',
55
116
  'textarea { flex:1; resize:none; min-height:44px; max-height:40dvh; padding:10px; border-radius:10px;',
@@ -81,13 +142,17 @@ const PAGINA = [
81
142
  '</style>',
82
143
  '</head>',
83
144
  '<body>',
84
- '<header><b>Chocolatito</b><span class="d" style="font-size:12px">Code</span>',
85
- '<span id="estado"><span id="punto"></span><span id="estadoTexto">conectando</span></span></header>',
145
+ `<header><img class="mascota" src="data:image/png;base64,${MASCOTA_PNG_BASE64}" alt="" width="28" height="26"><b>Chocolatito</b><span class="sub">Code</span>` +
146
+ '<span id="estado"><span id="punto"></span><span id="estadoTexto">conectando</span></span></header>',
86
147
  '<div id="salida"></div>',
148
+ '<div id="adjuntos"></div>',
87
149
  '<form id="envio">',
150
+ '<input id="fichero" type="file" multiple style="display:none">',
151
+ '<button id="clip" type="button" title="Adjuntar archivos">+</button>',
88
152
  '<textarea id="texto" rows="1" placeholder="Escribele lo que quieres que haga" autocapitalize="sentences" autocomplete="off"></textarea>',
89
153
  '<button id="enviar" type="submit">Ir</button>',
90
154
  '</form>',
155
+ '<div id="suelta">Suelta aqui los archivos</div>',
91
156
  '<div id="velo"><div id="permiso">',
92
157
  '<h2 id="permisoTitulo">Pide permiso</h2>',
93
158
  '<p id="permisoDetalle"></p>',
@@ -96,10 +161,30 @@ const PAGINA = [
96
161
  '</div></div>',
97
162
  '<script>',
98
163
  '(function () {',
99
- ' var token = new URLSearchParams(location.search).get("t") || "";',
100
- ' // Se quita de la barra de direcciones en cuanto se lee: el token no tiene',
101
- ' // por que quedarse en el historial del navegador ni salir en una captura.',
102
- ' if (token && history.replaceState) history.replaceState(null, "", location.pathname);',
164
+ ' // EL TOKEN TIENE QUE SOBREVIVIR A UNA RECARGA',
165
+ ' //',
166
+ ' // Se quitaba de la barra de direcciones en cuanto se leia -no tiene por que',
167
+ ' // quedarse en el historial ni salir en una captura- y ahi estaba el fallo:',
168
+ ' // al recargar, la URL ya no lo traia y la sesion se quedaba muda. En un PC',
169
+ ' // casi no pasa; en un MOVIL pasa todo el rato, porque recargar es el gesto',
170
+ ' // de tirar hacia abajo y porque Android descarta la pestana en cuanto te',
171
+ ' // vas a otra aplicacion... que es justo lo que hay que hacer para probar',
172
+ ' // las notificaciones. En la primera prueba con un telefono de verdad salio',
173
+ ' // trece veces seguidas el aviso de conexion rechazada.',
174
+ ' //',
175
+ ' // sessionStorage es el sitio exacto: vive lo que vive la pestana -sobrevive',
176
+ ' // a recargas y a que el sistema la descarte- y se va al cerrarla. No queda',
177
+ ' // en el historial ni lo hereda otra pestana.',
178
+ ' var CLAVE = "chocolatito.token";',
179
+ ' var enLaUrl = new URLSearchParams(location.search).get("t") || "";',
180
+ ' var token = enLaUrl;',
181
+ ' try {',
182
+ ' if (enLaUrl) sessionStorage.setItem(CLAVE, enLaUrl);',
183
+ ' else token = sessionStorage.getItem(CLAVE) || "";',
184
+ ' } catch (e) {',
185
+ ' // Navegador con el almacenamiento capado: se sigue con lo que traiga la URL.',
186
+ ' }',
187
+ ' if (enLaUrl && history.replaceState) history.replaceState(null, "", location.pathname);',
103
188
  ' var salida = document.getElementById("salida");',
104
189
  ' var punto = document.getElementById("punto");',
105
190
  ' var estadoTexto = document.getElementById("estadoTexto");',
@@ -144,6 +229,102 @@ const PAGINA = [
144
229
  ' }',
145
230
  ' return html || "&nbsp;";',
146
231
  ' }',
232
+ ' // MIRAR, NO SOLO LEER LA RUTA',
233
+ ' //',
234
+ ' // Sin esto, cuando el agente exporta una imagen contesta con la ruta. Delante',
235
+ ' // del PC se abre la carpeta; en un movil no puedes mirarlo, asi que no puedes',
236
+ ' // opinar, asi que no puedes trabajar. La ruta se detecta aqui y el permiso lo',
237
+ ' // da el servidor: /archivo solo sirve lo que este dentro de la carpeta.',
238
+ ' var EXT_VISIBLE = /^(png|jpe?g|webp|gif|bmp|avif|svg|mp4|webm|mov)$/i;',
239
+ ' // Todo lo que en una linea tiene pinta de ruta: con separador y con punto.',
240
+ ' function rutasDelTexto(crudo) {',
241
+ ' var limpio = String(crudo).replace(/\\x1b\\[[0-9;?]*[A-Za-z]/g, "");',
242
+ ' var encontradas = [];',
243
+ ' var trozos = limpio.split(/[\\s"\\u0027`<>|()\\[\\]]+/);',
244
+ ' for (var i = 0; i < trozos.length; i++) {',
245
+ ' var t = trozos[i].replace(/[.,;:]+$/, "");',
246
+ ' if (t.indexOf("/") < 0 && t.indexOf("\\\\") < 0) continue;',
247
+ ' if (t.lastIndexOf(".") < 0) continue;',
248
+ ' if (encontradas.indexOf(t) < 0) encontradas.push(t);',
249
+ ' if (encontradas.length >= 6) break;',
250
+ ' }',
251
+ ' return encontradas;',
252
+ ' }',
253
+ ' function extensionDe(ruta) {',
254
+ ' var punto = String(ruta).lastIndexOf(".");',
255
+ ' return punto < 0 ? "" : ruta.slice(punto + 1);',
256
+ ' }',
257
+ ' function rutasVisibles(crudo) {',
258
+ ' return rutasDelTexto(crudo).filter(function (r) { return EXT_VISIBLE.test(extensionDe(r)); }).slice(0, 4);',
259
+ ' }',
260
+ ' // Las que no se pueden ensenar pero si llevarse: el ZIP, el PDF, el CSV.',
261
+ ' function rutasGuardables(crudo, yaVistas) {',
262
+ ' return rutasDelTexto(crudo)',
263
+ ' .filter(function (r) { return yaVistas.indexOf(r) < 0 && EXT_GUARDABLE.test(extensionDe(r)); })',
264
+ ' .slice(0, 3);',
265
+ ' }',
266
+ ' // GUARDARLO, QUE ES LA ULTIMA MILLA',
267
+ ' //',
268
+ ' // Ver la pieza en el movil no termina el trabajo: el diseno sale del PC para',
269
+ ' // ir a un cliente, y ese cliente esta en el WhatsApp del telefono. Sin esto,',
270
+ ' // el circuito se queda a un paso: recibes por WhatsApp, procesas en el PC,',
271
+ ' // ves el resultado... y tienes que ir al ordenador a por el archivo.',
272
+ ' //',
273
+ ' // Y no solo imagenes: el ZIP del entregable, el PDF, el CSV y los subtitulos',
274
+ ' // no tienen miniatura que ensenar, asi que sin un enlace no existen para el',
275
+ ' // movil. Para esos se pregunta antes con HEAD si el archivo esta de verdad,',
276
+ ' // para no ofrecer un enlace roto sobre cualquier palabra con un punto.',
277
+ ' var EXT_GUARDABLE = /^(pdf|zip|rar|7z|csv|txt|md|srt|vtt|json|docx|xlsx|pptx|psd|ai|svg|mp3|wav|ogg|mp4|webm|mov|png|jpe?g|webp|gif)$/i;',
278
+ ' function urlDe(ruta, descargar) {',
279
+ ' return "/archivo?t=" + encodeURIComponent(token) + "&ruta=" + encodeURIComponent(ruta) +',
280
+ ' (descargar ? "&descargar=1" : "");',
281
+ ' }',
282
+ ' function nombreDe(ruta) {',
283
+ ' var trozos = String(ruta).split(/[\\\\/]/);',
284
+ ' return trozos[trozos.length - 1] || ruta;',
285
+ ' }',
286
+ ' function enlaceDeGuardar(ruta) {',
287
+ ' var a = document.createElement("a");',
288
+ ' a.className = "guardar";',
289
+ ' a.href = urlDe(ruta, true);',
290
+ ' a.setAttribute("download", nombreDe(ruta));',
291
+ ' a.textContent = "\u2913 Guardar " + nombreDe(ruta);',
292
+ ' return a;',
293
+ ' }',
294
+ ' // Un archivo sin miniatura: se ofrece solo si existe de verdad.',
295
+ ' function quizaDescargable(ruta, donde) {',
296
+ ' var punto = String(ruta).lastIndexOf(".");',
297
+ ' if (punto < 0 || !EXT_GUARDABLE.test(ruta.slice(punto + 1))) return;',
298
+ ' fetch(urlDe(ruta, false), { method: "HEAD" }).then(function (r) {',
299
+ ' if (!r.ok) return;',
300
+ ' donde.appendChild(enlaceDeGuardar(ruta));',
301
+ ' }).catch(function () {});',
302
+ ' }',
303
+ ' function rutasGuardables(crudo, yaVistas) {',
304
+ ' var todas = rutasDelTexto(crudo);',
305
+ ' var fuera = [];',
306
+ ' for (var i = 0; i < todas.length; i++) {',
307
+ ' if (yaVistas.indexOf(todas[i]) < 0) fuera.push(todas[i]);',
308
+ ' }',
309
+ ' return fuera.slice(0, 3);',
310
+ ' }',
311
+ ' function miniatura(ruta) {',
312
+ ' var url = "/archivo?t=" + encodeURIComponent(token) + "&ruta=" + encodeURIComponent(ruta);',
313
+ ' var caja = document.createElement("div");',
314
+ ' caja.className = "mini";',
315
+ ' var esVideo = /(mp4|webm|mov)$/i.test(ruta);',
316
+ ' var vista = document.createElement(esVideo ? "video" : "img");',
317
+ ' vista.src = url;',
318
+ ' if (esVideo) { vista.controls = true; vista.preload = "metadata"; }',
319
+ ' // Si no se puede servir -fuera de la carpeta, borrado, o es una ruta que',
320
+ ' // solo lo parecia- no se deja el icono de imagen rota: se quita la caja.',
321
+ ' vista.onerror = function () { try { caja.parentNode.removeChild(caja); } catch (e) {} };',
322
+ ' // El enlace solo aparece cuando la imagen ha cargado: si el archivo no se',
323
+ ' // puede servir, la caja entera se va y no queda un boton que no guarda nada.',
324
+ ' vista.onload = function () { caja.appendChild(enlaceDeGuardar(ruta)); };',
325
+ ' caja.appendChild(vista);',
326
+ ' return caja;',
327
+ ' }',
147
328
  ' function pintar(lineas) {',
148
329
  ' if (!lineas || !lineas.length) return;',
149
330
  ' var pegado = alFinal();',
@@ -151,14 +332,76 @@ const PAGINA = [
151
332
  ' for (var i = 0; i < lineas.length; i++) {',
152
333
  ' var div = document.createElement("div");',
153
334
  ' div.className = "fila";',
335
+ ' // Sin los codigos de color no queda mas que el texto, que es lo que hay',
336
+ ' // que mirar para saber de quien es la linea.',
337
+ 'var pelado = String(lineas[i].texto).replace(/\\x1b\\[[0-9;?]*[A-Za-z]/g, "");',
338
+ ' // Lo que mandaste tu: el eco que escribe el agente empieza por >.',
339
+ ' if (/^\\s*>\\s/.test(pelado)) div.className += " mio";',
340
+ ' // Y el cartel del arranque: solo el usa caracteres de bloque.',
341
+ ' else if (/[\\u2580-\\u259f]/.test(pelado)) div.className += " arte";',
154
342
  ' div.innerHTML = ansi(lineas[i].texto);',
155
343
  ' frag.appendChild(div);',
344
+ ' var vistas = rutasVisibles(lineas[i].texto);',
345
+ ' for (var v = 0; v < vistas.length; v++) frag.appendChild(miniatura(vistas[v]));',
346
+ ' var otras = rutasGuardables(lineas[i].texto, vistas);',
347
+ ' if (otras.length) {',
348
+ ' var fila = document.createElement("div");',
349
+ ' fila.className = "mini";',
350
+ ' frag.appendChild(fila);',
351
+ ' for (var g = 0; g < otras.length; g++) quizaDescargable(otras[g], fila);',
352
+ ' }',
156
353
  ' if (lineas[i].id > ultimoId) ultimoId = lineas[i].id;',
157
354
  ' }',
158
355
  ' salida.appendChild(frag);',
159
356
  ' while (salida.childNodes.length > 3000) salida.removeChild(salida.firstChild);',
160
357
  ' if (pegado) salida.scrollTop = salida.scrollHeight;',
161
358
  ' }',
359
+ ' // AVISAR AL MOVIL, SIN SER PESADO',
360
+ ' //',
361
+ ' // Sin esto, la sesion web solo sirve si estas mirandola. Dejas una tarea de',
362
+ ' // cinco minutos, te vas, y acabas sacando el telefono cada treinta segundos',
363
+ ' // por si acaso. Que es exactamente lo que hace inutil trabajar desde el movil.',
364
+ ' //',
365
+ ' // TRES DECISIONES:',
366
+ ' //',
367
+ ' // 1. SOLO SI NO ESTAS MIRANDO. Con la pagina delante, una notificacion de',
368
+ ' // algo que ya ves en pantalla es ruido, y el ruido se acaba silenciando',
369
+ ' // entero, tambien el aviso que si importaba.',
370
+ ' // 2. EL PERMISO SE PIDE AL ENVIAR, NO AL CARGAR. Chrome ignora -y Safari',
371
+ ' // rechaza- una peticion de notificaciones que no venga de un gesto del',
372
+ ' // usuario. Pedirlo al abrir la pagina es gastar la unica oportunidad.',
373
+ ' // 3. EL TITULO TAMBIEN AVISA. Si las notificaciones estan denegadas, el',
374
+ ' // punto en la pestana es lo unico que queda, y se ve igual de bien.',
375
+ ' var tituloNormal = document.title;',
376
+ ' var avisoPuesto = false;',
377
+ ' function puedeAvisar() {',
378
+ ' return typeof Notification !== "undefined" && Notification.permission === "granted";',
379
+ ' }',
380
+ ' function pedirPermisoDeAviso() {',
381
+ ' if (typeof Notification === "undefined" || Notification.permission !== "default") return;',
382
+ ' try { Notification.requestPermission(); } catch (e) {}',
383
+ ' }',
384
+ ' function marcarTitulo(si) {',
385
+ ' avisoPuesto = si;',
386
+ ' document.title = si ? "\u25cf " + tituloNormal : tituloNormal;',
387
+ ' }',
388
+ ' function avisar(titulo, cuerpo, urgente) {',
389
+ ' // Mirando la pagina no se avisa de nada: ya lo estas viendo.',
390
+ ' if (!document.hidden) return;',
391
+ ' marcarTitulo(true);',
392
+ ' if (navigator.vibrate) {',
393
+ ' // Dos pulsos para un permiso -que te esta esperando- y uno para el final.',
394
+ ' try { navigator.vibrate(urgente ? [120, 80, 120] : [90]); } catch (e) {}',
395
+ ' }',
396
+ ' if (!puedeAvisar()) return;',
397
+ ' try {',
398
+ ' var n = new Notification(titulo, { body: cuerpo, tag: "chocolatito", renotify: true });',
399
+ ' n.onclick = function () { try { window.focus(); } catch (e) {} try { n.close(); } catch (e) {} };',
400
+ ' } catch (e) {}',
401
+ ' }',
402
+ ' document.addEventListener("visibilitychange", function () {',
403
+ ' if (!document.hidden && avisoPuesto) marcarTitulo(false);',
404
+ ' });',
162
405
  ' function conectado(si, etiqueta) {',
163
406
  ' punto.className = si ? "ok" : "";',
164
407
  ' estadoTexto.textContent = etiqueta;',
@@ -171,6 +414,7 @@ const PAGINA = [
171
414
  ' peticionActual = null;',
172
415
  ' }',
173
416
  ' function abrirPermiso(p) {',
417
+ ' avisar("Chocolatito te pide permiso", (p.detalle && p.detalle[0]) || p.herramienta, true);',
174
418
  ' peticionActual = p;',
175
419
  ' document.getElementById("permisoTitulo").textContent = "Permiso: " + p.herramienta;',
176
420
  ' document.getElementById("permisoDetalle").textContent = (p.detalle || []).join("\\n");',
@@ -220,6 +464,7 @@ const PAGINA = [
220
464
  ' pintar(m.lineas);',
221
465
  ' }',
222
466
  ' else if (m.t === "permiso") abrirPermiso(m);',
467
+ ' else if (m.t === "turno") avisar("Chocolatito ha terminado", "Te toca a ti.", false);',
223
468
  ' else if (m.t === "cierre") { velo.className = ""; peticionActual = null; }',
224
469
  ' else if (m.t === "fin") { terminada = true; conectado(false, "sesion terminada"); try { ws.close(); } catch (e) {} }',
225
470
  ' };',
@@ -233,11 +478,93 @@ const PAGINA = [
233
478
  ' };',
234
479
  ' ws.onerror = function () { try { ws.close(); } catch (e) {} };',
235
480
  ' }',
481
+ ' // LOS ARCHIVOS QUE ENTRAN',
482
+ ' //',
483
+ ' // Tres gestos, porque son tres sitios distintos: el boton y arrastrar (PC), y',
484
+ ' // PEGAR, que es el mas usado de los tres y el que casi nadie pone: una captura',
485
+ ' // de pantalla se pega, no se guarda antes en una carpeta para luego buscarla.',
486
+ ' var adjuntos = [];',
487
+ ' var tiraAdjuntos = document.getElementById("adjuntos");',
488
+ ' var fichero = document.getElementById("fichero");',
489
+ ' var suelta = document.getElementById("suelta");',
490
+ ' function pintarAdjuntos() {',
491
+ ' tiraAdjuntos.innerHTML = "";',
492
+ ' for (var i = 0; i < adjuntos.length; i++) {',
493
+ ' (function (a) {',
494
+ ' var chip = document.createElement("div");',
495
+ ' chip.className = "chip";',
496
+ ' if (a.vista) { var im = document.createElement("img"); im.src = a.vista; chip.appendChild(im); }',
497
+ ' var nom = document.createElement("span");',
498
+ ' nom.textContent = a.subiendo ? a.nombre + "\u2026" : a.nombre;',
499
+ ' chip.appendChild(nom);',
500
+ ' var x = document.createElement("b");',
501
+ ' x.textContent = "\u00d7";',
502
+ ' x.onclick = function () { adjuntos.splice(adjuntos.indexOf(a), 1); pintarAdjuntos(); };',
503
+ ' chip.appendChild(x);',
504
+ ' tiraAdjuntos.appendChild(chip);',
505
+ ' })(adjuntos[i]);',
506
+ ' }',
507
+ ' }',
508
+ ' function adjuntar(archivos) {',
509
+ ' for (var i = 0; i < archivos.length; i++) {',
510
+ ' (function (archivo) {',
511
+ ' var entrada = {',
512
+ ' nombre: archivo.name || "pegado.png",',
513
+ ' subiendo: true,',
514
+ ' ruta: "",',
515
+ ' vista: /^image\\//.test(archivo.type) ? URL.createObjectURL(archivo) : ""',
516
+ ' };',
517
+ ' adjuntos.push(entrada);',
518
+ ' pintarAdjuntos();',
519
+ ' fetch("/subir?t=" + encodeURIComponent(token) + "&nombre=" + encodeURIComponent(entrada.nombre), {',
520
+ ' method: "POST",',
521
+ ' body: archivo',
522
+ ' }).then(function (r) { return r.json(); }).then(function (j) {',
523
+ ' if (j && j.ok) { entrada.ruta = j.ruta; entrada.subiendo = false; }',
524
+ ' else {',
525
+ ' adjuntos.splice(adjuntos.indexOf(entrada), 1);',
526
+ ' pintar([{ id: 0, texto: "\u001b[33m No se pudo subir " + entrada.nombre + ": " + ((j && j.error) || "error") + "\u001b[39m" }]);',
527
+ ' }',
528
+ ' pintarAdjuntos();',
529
+ ' }).catch(function () {',
530
+ ' adjuntos.splice(adjuntos.indexOf(entrada), 1);',
531
+ ' pintarAdjuntos();',
532
+ ' });',
533
+ ' })(archivos[i]);',
534
+ ' }',
535
+ ' }',
536
+ ' document.getElementById("clip").onclick = function () { fichero.click(); };',
537
+ ' fichero.onchange = function () { adjuntar(fichero.files); fichero.value = ""; };',
538
+ ' document.addEventListener("paste", function (ev) {',
539
+ ' var datos = ev.clipboardData;',
540
+ ' if (!datos || !datos.files || !datos.files.length) return;',
541
+ ' ev.preventDefault();',
542
+ ' adjuntar(datos.files);',
543
+ ' });',
544
+ ' var arrastres = 0;',
545
+ ' document.addEventListener("dragenter", function (ev) { ev.preventDefault(); arrastres++; suelta.className = "visible"; });',
546
+ ' document.addEventListener("dragover", function (ev) { ev.preventDefault(); });',
547
+ ' document.addEventListener("dragleave", function () { if (--arrastres <= 0) { arrastres = 0; suelta.className = ""; } });',
548
+ ' document.addEventListener("drop", function (ev) {',
549
+ ' ev.preventDefault();',
550
+ ' arrastres = 0;',
551
+ ' suelta.className = "";',
552
+ ' if (ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files.length) adjuntar(ev.dataTransfer.files);',
553
+ ' });',
236
554
  ' document.getElementById("envio").onsubmit = function (ev) {',
237
555
  ' ev.preventDefault();',
238
556
  ' var valor = texto.value.trim();',
239
- ' if (!valor || !ws || ws.readyState !== 1) return;',
240
- ' ws.send(JSON.stringify({ t: "entrada", texto: valor }));',
557
+ ' var listos = [];',
558
+ ' for (var i = 0; i < adjuntos.length; i++) if (adjuntos[i].ruta) listos.push(adjuntos[i].ruta);',
559
+ ' // Se puede mandar SOLO archivos: subir cinco fotos y no escribir nada es una',
560
+ ' // orden perfectamente clara para el que la recibe.',
561
+ ' if ((!valor && !listos.length) || !ws || ws.readyState !== 1) return;',
562
+ ' // Aqui, y no al cargar: Chrome ignora una peticion de notificaciones que no',
563
+ ' // venga de un gesto del usuario, y pedirla al abrir gasta la unica que hay.',
564
+ ' pedirPermisoDeAviso();',
565
+ ' ws.send(JSON.stringify({ t: "entrada", texto: valor, archivos: listos }));',
566
+ ' adjuntos = [];',
567
+ ' pintarAdjuntos();',
241
568
  ' texto.value = "";',
242
569
  ' texto.style.height = "auto";',
243
570
  ' salida.scrollTop = salida.scrollHeight;',
@@ -3,6 +3,19 @@ export declare function servirActivo(): boolean;
3
3
  export declare function marcarServirActivo(valor: boolean): void;
4
4
  /** Llega algo del navegador. */
5
5
  export declare function entregarEntrada(texto: string): void;
6
+ /**
7
+ * QUIEN AVISA AL MOVIL DE QUE YA PUEDE MIRAR
8
+ *
9
+ * El navegador sabia cuando le preguntaban algo -el permiso llega por su
10
+ * canal- pero no sabia cuando el agente TERMINABA. Y ese es justo el momento
11
+ * que importa cuando no estas delante: dejas una tarea de cinco minutos, te vas,
12
+ * y vuelves a mirar el telefono cada treinta segundos por si acaso.
13
+ *
14
+ * El momento no hay que inventarlo ni medirlo: es exactamente cuando el agente
15
+ * se pone a esperar lo que escribas. Ni un evento nuevo ni un temporizador.
16
+ */
17
+ type AvisoDeTurno = () => void;
18
+ export declare function fijarAvisoDeTurno(fn: AvisoDeTurno | null): void;
6
19
  export declare function esperarEntradaWeb(): Promise<string>;
7
20
  export declare function hayEntradaEnCola(): boolean;
8
21
  export interface PeticionDePermiso {
@@ -48,10 +48,20 @@ export function entregarEntrada(texto) {
48
48
  // vez de perderse. Es lo que ya hace el type-ahead del terminal.
49
49
  cola.push(texto);
50
50
  }
51
+ let avisarDelTurno = null;
52
+ export function fijarAvisoDeTurno(fn) {
53
+ avisarDelTurno = fn;
54
+ }
51
55
  export function esperarEntradaWeb() {
52
56
  const pendiente = cola.shift();
57
+ // Con algo en la cola NO se avisa: el usuario ya habia escrito mientras el
58
+ // agente trabajaba, asi que no ha terminado nada para el, sigue la faena.
53
59
  if (pendiente !== undefined)
54
60
  return Promise.resolve(pendiente);
61
+ try {
62
+ avisarDelTurno?.();
63
+ }
64
+ catch { }
55
65
  return new Promise((resolve) => {
56
66
  esperando = resolve;
57
67
  });
@@ -106,6 +116,7 @@ export function permisosAbiertos() {
106
116
  }
107
117
  /** Solo para pruebas. */
108
118
  export function vaciarPuente() {
119
+ avisarDelTurno = null;
109
120
  cola.length = 0;
110
121
  esperando = null;
111
122
  emisor = null;
@@ -36,6 +36,17 @@ export interface OpcionesServidor {
36
36
  enRed?: boolean;
37
37
  /** Para pruebas: token fijo en vez de uno aleatorio. */
38
38
  token?: string;
39
+ /**
40
+ * Un nombre de dominio mas que se acepta como Host, para entrar por un tunel
41
+ * (cloudflared, ngrok). Uno concreto, no cualquiera.
42
+ */
43
+ anfitrionAutorizado?: string;
44
+ /**
45
+ * La carpeta de trabajo, leida en cada peticion y no copiada al arrancar.
46
+ * Con /cd el agente se cambia de carpeta a mitad de sesion, y lo que se sube
47
+ * tiene que caer donde se esta trabajando AHORA, no donde se empezo.
48
+ */
49
+ carpeta?: () => string;
39
50
  }
40
51
  export interface ServidorWeb {
41
52
  puerto: number;
@@ -52,9 +63,72 @@ export interface ServidorWeb {
52
63
  * Host valido: localhost o una IP literal. Ver el punto 2 de la cabecera.
53
64
  * Se le quita el puerto y los corchetes de IPv6 antes de mirarlo.
54
65
  */
55
- export declare function anfitrionAceptable(host: string | undefined): boolean;
66
+ /**
67
+ * Host valido: localhost, una IP literal, o EL NOMBRE QUE SE HAYA AUTORIZADO.
68
+ *
69
+ * Lo tercero existe por un caso real: el README dice desde el primer dia que
70
+ * para entrar desde fuera de casa se use Tailscale o cloudflared... y con
71
+ * cloudflared esto devolvia 401. Tailscale funciona porque reparte IPs, que
72
+ * pasan la comprobacion; un tunel llega siempre con un nombre de dominio.
73
+ * O sea que el programa prometia algo que el mismo rechazaba.
74
+ *
75
+ * Se arregla SIN quitar la cerradura. No es "acepta cualquier dominio": es
76
+ * "acepta exactamente este, el que me acabas de decir". Contra el DNS rebinding
77
+ * -una web cualquiera que apunta su dominio a tu 127.0.0.1 y usa el navegador de
78
+ * la victima de puente- la defensa sigue entera, porque ese ataque llega con el
79
+ * dominio del atacante y no con el que tu autorizaste.
80
+ */
81
+ export declare function anfitrionAceptable(host: string | undefined, autorizado?: string): boolean;
82
+ /**
83
+ * De lo que pegue el usuario, saca el nombre.
84
+ *
85
+ * Nadie copia "abc-def.trycloudflare.com": se copia el enlace entero con su
86
+ * https:// delante, y a veces con la barra final. Exigir el nombre pelado seria
87
+ * un 401 que nadie sabria explicar.
88
+ */
89
+ export declare function nombreDeAnfitrion(entrada: string | undefined): string;
56
90
  /** Origin, si viene, tiene que ser el mismo sitio. Ver el punto 3. */
57
- export declare function origenAceptable(origin: string | undefined, host: string | undefined): boolean;
91
+ /**
92
+ * Origin, si viene, tiene que ser el mismo sitio. Ver el punto 3.
93
+ *
94
+ * CON UN TUNEL DELANTE, "EL MISMO SITIO" NO ES EL MISMO TEXTO
95
+ *
96
+ * Se vio en la primera prueba de verdad desde un movil con datos: la pagina
97
+ * cargaba y el WebSocket rebotaba con 401. El motivo es que cloudflared
98
+ * REESCRIBE la cabecera Host con la del destino -127.0.0.1:4700-, mientras que
99
+ * el navegador manda el Origin que el ve, que es el del tunel. Comparados a
100
+ * pelo no coinciden nunca, asi que la sesion se quedaba muda: el usuario
101
+ * escribia y no pasaba nada.
102
+ *
103
+ * Por eso el dominio autorizado vale tambien como Origin. Y sigue siendo una
104
+ * cerradura: se acepta EXACTAMENTE ese, el que se escribio en --tunel.
105
+ */
106
+ export declare function origenAceptable(origin: string | undefined, host: string | undefined, autorizado?: string): boolean;
107
+ /**
108
+ * EL TOKEN TIENE QUE SOBREVIVIR A UNA RECARGA, Y LA URL NO LO HACE
109
+ *
110
+ * DE DONDE SALE: primera prueba con un movil de verdad. El enlace se abre bien,
111
+ * la pagina quita el token de la barra de direcciones -para que no se quede en
112
+ * el historial ni salga en una captura- y a la primera recarga se acabo la
113
+ * sesion: la peticion de la propia pagina ya no lleva token, el servidor
114
+ * contesta 401 "No." y ahi no hay javascript que valga, porque la pagina ni
115
+ * llega a cargarse. En el terminal salieron trece rechazos seguidos.
116
+ *
117
+ * Y en un movil recargar no es raro: es tirar hacia abajo sin querer, y sobre
118
+ * todo es lo que hace Android solo cuando la pestana se queda de fondo. O sea,
119
+ * exactamente lo que hay que hacer para probar las notificaciones.
120
+ *
121
+ * Se arregla como se arregla en cualquier sitio: al entrar con el token bueno se
122
+ * deja una cookie, y a partir de ahi el navegador la manda sola en todo -la
123
+ * pagina, el socket, las imagenes-.
124
+ *
125
+ * - SameSite=Strict: una web ajena no puede hacer que tu navegador la mande.
126
+ * Es la misma defensa que el Origin, por otro camino.
127
+ * - HttpOnly: no la lee ningun javascript, ni el nuestro.
128
+ * - Sin Max-Age: se muere al cerrar el navegador, como la sesion.
129
+ */
130
+ export declare const COOKIE_DE_SESION = "chocolatito_web";
131
+ export declare function tokenDeLaCookie(cabecera: string | undefined): string;
58
132
  export declare function tokenDeLaPeticion(url: string | undefined): string;
59
133
  export declare function iniciarServidorWeb(opciones?: OpcionesServidor): Promise<ServidorWeb>;
60
134
  /** Las IP de la maquina en la red local, para abrirla desde el movil. */