@glassnote/client 2.4.10 → 2.4.12

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/INSTALACION.md CHANGED
@@ -41,6 +41,7 @@ que es justo lo que hace `glassnote uninstall`.
41
41
  | `glassnote start --detach` | lo arranca y libera la terminal |
42
42
  | `glassnote install [version]` | instala o reinstala (`latest` por defecto) |
43
43
  | `glassnote update [version]` | actualiza a la última publicada |
44
+ | `glassnote pair` | da de alta el equipo con la API key de la organización |
44
45
  | `glassnote uninstall` | quita autoarranque, `~/.glassnote` y la línea del rc |
45
46
  | `glassnote status` | versión, modo, rutas, autoarranque y estado del sandbox |
46
47
  | `glassnote fix-sandbox` | (Linux) restaura el sandbox de Chromium; pide sudo una vez |
@@ -48,6 +49,89 @@ que es justo lo que hace `glassnote uninstall`.
48
49
  Banderas de `install`: `--no-path` (no toca el rc, imprime la línea) y `--no-start`
49
50
  (instala y no arranca).
50
51
 
52
+ ## Alta masiva: emparejar sin que nadie mire la pantalla
53
+
54
+ El emparejamiento normal es a dos manos: el cliente muestra un código y una persona lo
55
+ escribe en la web. Para un equipo está bien. Para trescientos —una cadena que abre
56
+ locales, un parque que se reinstala entero— es una tarde entera y un error de tipeo cada
57
+ veinte equipos.
58
+
59
+ Con la **API key de la organización**, el equipo se da de alta solo durante la
60
+ instalación. La API contesta con el servidor y el token, el instalador los deja escritos
61
+ donde la app los busca, y el cliente arranca ya conectado.
62
+
63
+ ```sh
64
+ # Linux / macOS — ojo con el `-s --`, es lo que hace que sh le pase las banderas al script
65
+ curl -fsSL https://glassnote.intermark.ec/install.sh | sh -s -- \
66
+ --org-id 55 --api-key <clave> --device-name "Caja 01" --user-name "Santiago"
67
+ ```
68
+
69
+ ```powershell
70
+ # Windows
71
+ & ([scriptblock]::Create((irm https://glassnote.intermark.ec/install.ps1))) `
72
+ -OrgId 55 -ApiKey <clave> -DeviceName 'Caja 01' -UserName 'Santiago'
73
+ ```
74
+
75
+ | bandera | variable de entorno | por defecto |
76
+ |---|---|---|
77
+ | `--org-id` | `GLASSNOTE_ORG_ID` | — (obligatoria) |
78
+ | `--api-key` | `GLASSNOTE_API_KEY` | — (obligatoria) |
79
+ | `--device-name` | `GLASSNOTE_DEVICE_NAME` | el hostname del equipo |
80
+ | `--user-name` | `GLASSNOTE_USER_NAME` | el usuario del sistema |
81
+ | `--api-url` | `GLASSNOTE_API_URL` | `https://glassnoteapi.intermark.ec` |
82
+ | `--ws-url` | `GLASSNOTE_WS_URL` | el que anuncie la API |
83
+
84
+ Las variables de entorno existen porque un despliegue por GPO o por MDM muchas veces no
85
+ puede pasar argumentos pero sí poner entorno.
86
+
87
+ ### Otro servidor
88
+
89
+ **El websocket no se elige en el instalador: lo decide la API que atiende el alta.** El
90
+ token que emite sólo vale en su propio websocket, así que apuntar a otro sitio no daría
91
+ un equipo conectado a otro servidor, daría un equipo dado de alta que no conecta. Para
92
+ instalar contra otra API se cambia `--api-url` y el websocket viene de vuelta en la
93
+ respuesta:
94
+
95
+ ```sh
96
+ curl -fsSL https://glassnote.intermark.ec/install.sh | sh -s -- \
97
+ --api-url https://api.otrocliente.com --org-id 12 --api-key <clave>
98
+ ```
99
+
100
+ Esa URL de websocket es además de donde el cliente saca el origen HTTP de la API para
101
+ reportar su versión, así que es **el único dato de servidor** que el equipo guarda.
102
+
103
+ `--ws-url` cubre el único caso que lo anterior no: que la API anuncie una dirección a la
104
+ que el equipo no llega —una IP de LAN detrás de un NAT, un proxy inverso publicado con
105
+ otro nombre, DNS partido—. Ahí el alta sale bien y los equipos guardan una URL muerta sin
106
+ ninguna señal en el momento de instalar. Por eso, cuando el host que anuncia la API no es
107
+ el mismo al que se pidió el alta, el instalador lo dice en pantalla. **Lo correcto es
108
+ arreglar `WEBSOCKET_URL` en la API**; `--ws-url` es la salida cuando no se puede.
109
+
110
+ ```sh
111
+ glassnote pair --org-id 12 --api-key <clave> \
112
+ --api-url https://api.cliente.local --ws-url wss://glassnote.cliente.com/ws
113
+ ```
114
+
115
+ **El nombre por defecto es el del propio equipo, no uno genérico.** Es lo que hace que el
116
+ *mismo* comando sirva para todo el parque: sin eso, el tablero termina con trescientos
117
+ "Unnamed Device" y hay que renombrarlos a mano, que es justo el trabajo que esto viene a
118
+ evitar.
119
+
120
+ La API key se saca en la web, en las claves de la organización. **Da de alta equipos en
121
+ esa organización**: es un secreto de despliegue, no algo que se reparta. Caduca a los 30
122
+ días, lo que acota lo que se puede hacer con una que se filtre.
123
+
124
+ Un equipo ya instalado se empareja sin reinstalar:
125
+
126
+ ```sh
127
+ glassnote pair --org-id 55 --api-key <clave> --device-name "Caja 01"
128
+ ```
129
+
130
+ Si el alta falla, el comando **sale con código distinto de cero** y dice por qué (la
131
+ clave no sirve, la organización no es la de esa clave, el plan llegó a su tope de
132
+ equipos). El cliente queda instalado igual: se reintenta con `glassnote pair`. Emparejar
133
+ dos veces el mismo equipo no lo duplica ni gasta un cupo extra; solo le renueva el token.
134
+
51
135
  `install` y `update` también aceptan un *spec* de npm en vez de una versión: un `.tgz`,
52
136
  una ruta local o una URL. Es lo que permite probar un paquete antes de publicarlo:
53
137
 
package/bin/glassnote.js CHANGED
@@ -17,6 +17,7 @@ const os = require('os');
17
17
  const { spawn, spawnSync } = require('child_process');
18
18
 
19
19
  const mode = require('../npmMode');
20
+ const pairing = require('../pairing');
20
21
  const pkg = require('../package.json');
21
22
 
22
23
  const IS_WIN = process.platform === 'win32';
@@ -140,6 +141,78 @@ function esperarAQueCierre(maxMs) {
140
141
  return false;
141
142
  }
142
143
 
144
+ // Windows no deja reemplazar los archivos de un proceso vivo. Reinstalar encima del
145
+ // cliente abierto muere con «EBUSY: resource busy or locked, rename ...\electron\dist\
146
+ // icudtl.dat», y muere A MITAD: npm ya apartó parte del árbol viejo, así que la
147
+ // instalación que había queda rota además de no haber instalado la nueva. Es el fallo
148
+ // que se lleva puesto a cualquiera que corra el `irm | iex` una segunda vez, que es
149
+ // justo lo que se le pide a la gente para actualizar.
150
+ //
151
+ // Se cierra por RUTA del ejecutable, nunca por nombre: "electron.exe" es también el
152
+ // VS Code y el Slack de quien esté delante, y cerrarle eso para instalar esto sería
153
+ // bastante peor que el fallo que se viene a arreglar. Sólo se toca lo que vive dentro
154
+ // de ~/.glassnote, que es nuestro por definición.
155
+ // El guion de PowerShell, aparte: decidir QUE se le pide al sistema es una funcion pura
156
+ // y se puede comprobar desde cualquier maquina; matar procesos de verdad, no. Y la parte
157
+ // que mas silenciosamente se rompe es justo esta, el entrecomillado de la ruta.
158
+ //
159
+ // Comillas SIMPLES de PowerShell: son literales y no interpretan la barra invertida. Con
160
+ // comillas dobles habria que escapar, y una ruta escapada a la manera de JSON le llega a
161
+ // PowerShell con las barras duplicadas y no coincide con nada: el filtro no encontraria
162
+ // un solo proceso y el EBUSY volveria, mudo.
163
+ //
164
+ // El filtro apunta a `<home>\npm\node_modules`, NO a `<home>` entero. La diferencia no es
165
+ // cosmetica: cuando el equipo no tenia Node, el instalador se baja el suyo a
166
+ // `<home>\node-v20.18.1-win-x64\node.exe` —que es el caso normal en Windows— y ese
167
+ // node.exe es el que esta ejecutando ESTE codigo. Filtrando por `<home>` el instalador se
168
+ // mataba a si mismo a mitad, salia con codigo -1 y no instalaba nada. Se vio en la VM de
169
+ // Windows el 2026-09-07; en Linux no pasa porque alli no hay nada que cerrar.
170
+ //
171
+ // Se excluye ademas el pid propio, por si algun dia el CLI viviera dentro de esa carpeta.
172
+ // Donde viven los binarios del cliente YA INSTALADO. Es lo unico que npm va a
173
+ // reemplazar, y por tanto lo unico que hay que cerrar.
174
+ function rutaDelClienteInstalado() {
175
+ return path.join(mode.NPM_PREFIX, 'node_modules');
176
+ }
177
+
178
+ function guionCerrarCliente(raizDir, pidPropio) {
179
+ const raiz = `'${String(raizDir).replace(/'/g, "''")}'`;
180
+ const pid = Number(pidPropio) || 0;
181
+ return [
182
+ `$raiz = ${raiz}`,
183
+ `$yo = ${pid}`,
184
+ '$procs = @(Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -ne $yo -and $_.ExecutablePath -and $_.ExecutablePath.StartsWith($raiz, [System.StringComparison]::OrdinalIgnoreCase) })',
185
+ '$procs | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }',
186
+ 'Write-Output $procs.Count',
187
+ ].join('; ');
188
+ }
189
+
190
+ function cerrarClienteWindows() {
191
+ if (!IS_WIN) return true; // en POSIX se puede reemplazar un archivo abierto
192
+
193
+ const res = spawnSync(
194
+ 'powershell.exe',
195
+ ['-NoProfile', '-NonInteractive', '-Command', guionCerrarCliente(rutaDelClienteInstalado(), process.pid)],
196
+ { encoding: 'utf8', timeout: 20000 }
197
+ );
198
+
199
+ // Si PowerShell no está o falló, no se sigue a ciegas: se mira si el cliente
200
+ // contesta, que es la señal barata de que sigue vivo.
201
+ if (res.error || res.status !== 0) {
202
+ if (!clienteEnMarcha()) return true;
203
+ console.error('glassnote: no pude cerrar el cliente automáticamente.');
204
+ return false;
205
+ }
206
+
207
+ const cerrados = parseInt(String(res.stdout || '').trim(), 10) || 0;
208
+ if (cerrados === 0) return true;
209
+
210
+ step(`el cliente estaba abierto: cerrados ${cerrados} procesos suyos`);
211
+ // El proceso desaparece de la lista antes de que el SO suelte los handles de sus
212
+ // archivos, y npm necesita los handles, no la lista.
213
+ return esperarAQueCierre(15000);
214
+ }
215
+
143
216
  const log = (...args) => console.log(...args);
144
217
  // Cada paso se dice EN CUANTO EMPIEZA: lo que hace que un instalador parezca colgado
145
218
  // no es que tarde, es que tarde sin decir en qué está.
@@ -464,16 +537,29 @@ function borrarAppMac() {
464
537
  }
465
538
  }
466
539
 
467
- function cmdInstall(args) {
468
- const version = args.find((a) => !a.startsWith('-')) || 'latest';
540
+ async function cmdInstall(args) {
541
+ // El argumento suelto es la version, pero las banderas de emparejamiento traen valor
542
+ // propio: sin saltarlo, `install --api-key abc` intentaba instalar la version "abc".
543
+ const version = pairing.argumentoPosicional(args) || 'latest';
469
544
  const noPath = args.includes('--no-path');
470
545
  const noStart = args.includes('--no-start');
546
+ const emparejamiento = pairing.leerOpciones(args, process.env);
471
547
 
472
548
  const spec = toSpec(version);
473
549
  log(`glassnote — instalando ${spec}`);
474
550
  fs.mkdirSync(mode.NPM_PREFIX, { recursive: true });
475
551
  fs.mkdirSync(mode.BIN_DIR, { recursive: true });
476
552
 
553
+ // ANTES de que npm mueva un solo archivo: si el cliente está abierto, npm apartaría
554
+ // el árbol viejo a medias y dejaría rota la instalación que ya funcionaba.
555
+ if (!cerrarClienteWindows()) {
556
+ fail(
557
+ 'el cliente sigue abierto y Windows no deja reemplazar sus archivos.\n' +
558
+ ' Cerralo desde el ícono de la bandeja (clic derecho → Salir) y volvé a correr esto.\n' +
559
+ ' No se tocó nada: la instalación que tenías sigue como estaba.'
560
+ );
561
+ }
562
+
477
563
  // Los scripts de instalación van ACTIVADOS a propósito: el binario de electron se
478
564
  // baja en un postinstall. Con --ignore-scripts quedaría instalado y roto.
479
565
  step(`npm install -g --prefix ${mode.NPM_PREFIX} (sin root, la primera vez tarda: baja electron)`);
@@ -530,6 +616,14 @@ function cmdInstall(args) {
530
616
  log('Para actualizar más adelante: glassnote update (o correr esto mismo otra vez).');
531
617
  log('');
532
618
 
619
+ // Antes de arrancar: si la instalacion trae credenciales de organizacion, el equipo se
620
+ // da de alta ahora. Arrancarlo primero solo consigue que aparezca la pantalla del
621
+ // keycode un instante antes de quedar emparejado igual.
622
+ if (pairing.pidenEmparejar(emparejamiento)) {
623
+ const codigo = await emparejar(emparejamiento, { reciénInstalado: true });
624
+ if (codigo !== 0) return codigo;
625
+ }
626
+
533
627
  if (noStart) {
534
628
  log('Arrancalo cuando quieras: glassnote');
535
629
  return 0;
@@ -549,6 +643,66 @@ function cmdInstall(args) {
549
643
  return 0;
550
644
  }
551
645
 
646
+ // Alta desatendida contra la API. Devuelve codigo de salida y no lanza: un despliegue
647
+ // masivo se guia por el codigo, no por lo que aparezca en pantalla.
648
+ async function emparejar(opciones, { reciénInstalado = false } = {}) {
649
+ const userData = conUserData((u) => u);
650
+ if (!userData) {
651
+ console.error('glassnote: no pude leer los datos de usuario para emparejar.');
652
+ return 1;
653
+ }
654
+
655
+ step(`dando de alta el equipo en la organizacion ${opciones.orgId || '(sin id)'}`);
656
+
657
+ try {
658
+ const alta = await pairing.emparejar(opciones, userData);
659
+ log('');
660
+ log(
661
+ alta.yaEstaba
662
+ ? 'El equipo ya estaba en la organizacion; se le renovo el token.'
663
+ : 'Equipo dado de alta.'
664
+ );
665
+ log(` nombre: ${alta.name}`);
666
+ log(` organizacion: ${alta.organizationId}`);
667
+ log(` servidor: ${alta.server}${alta.servidorForzado ? ' (forzado con --ws-url)' : ''}`);
668
+ log(` uuid: ${alta.uuid}`);
669
+ log('');
670
+ // El equipo va a hablar SOLO con este websocket, y de él saca también el origen
671
+ // HTTP de la API. Si no es el mismo host al que se acaba de pedir el alta, la API
672
+ // está anunciando otra dirección: puede ser a propósito, o puede ser un
673
+ // WEBSOCKET_URL mal puesto que deja al parque entero conectando a la nada. Se
674
+ // dice acá, que es el único momento en que alguien está mirando.
675
+ if (!alta.mismoOrigenQueLaApi) {
676
+ log(`Ojo: la API contestó que el equipo se conecte a ${alta.servidorAnunciado || alta.server},`);
677
+ log('que no es el host al que le pediste el alta. Si el equipo no llega ahí, se');
678
+ log('arregla WEBSOCKET_URL en la API, o se fuerza acá con --ws-url.');
679
+ log('');
680
+ }
681
+ return 0;
682
+ } catch (error) {
683
+ console.error('');
684
+ console.error(`glassnote: no se pudo dar de alta el equipo: ${error.message}`);
685
+ if (reciénInstalado) {
686
+ console.error('El cliente quedó instalado igual. Cuando esto se resuelva:');
687
+ } else {
688
+ console.error('El equipo sigue como estaba. Para reintentar:');
689
+ }
690
+ console.error(' glassnote pair --org-id <id> --api-key <clave> --device-name <nombre>');
691
+ console.error('');
692
+ return 1;
693
+ }
694
+ }
695
+
696
+ async function cmdPair(args) {
697
+ const opciones = pairing.leerOpciones(args, process.env);
698
+ if (!pairing.pidenEmparejar(opciones)) {
699
+ console.error('glassnote pair: falta --api-key (o la variable GLASSNOTE_API_KEY).\n');
700
+ cmdHelp();
701
+ return 2;
702
+ }
703
+ return emparejar(opciones);
704
+ }
705
+
552
706
  function cmdUpdate(args) {
553
707
  if (mode.isEphemeral()) {
554
708
  log('Estás corriendo por npx: npx ya baja la última versión cada vez.');
@@ -571,6 +725,16 @@ function cmdUpdate(args) {
571
725
 
572
726
  if (waitMs > 0) esperarAQueCierre(waitMs);
573
727
 
728
+ // Sin `--wait` esto lo está corriendo una persona a mano, con el cliente abierto: el
729
+ // mismo EBUSY de Windows que en install, y con el mismo remedio.
730
+ else if (!cerrarClienteWindows()) {
731
+ fail(
732
+ 'el cliente sigue abierto y Windows no deja reemplazar sus archivos.\n' +
733
+ ' Cerralo desde el ícono de la bandeja (clic derecho → Salir) y volvé a intentar.\n' +
734
+ ' No se tocó nada: la versión que tenías sigue como estaba.'
735
+ );
736
+ }
737
+
574
738
  // El cwd no puede estar dentro de lo que npm va a borrar: en Windows un directorio
575
739
  // que es el cwd de un proceso vivo no se puede eliminar, y ese proceso es este.
576
740
  try {
@@ -731,6 +895,7 @@ function cmdHelp() {
731
895
  glassnote arranca el cliente
732
896
  glassnote start --detach lo arranca y libera la terminal
733
897
  glassnote update [version] actualiza (por defecto, la última)
898
+ glassnote pair da de alta el equipo con la API key de la organización
734
899
  glassnote uninstall quita autoarranque, ~/.glassnote y la línea del rc
735
900
  glassnote status versión, modo, rutas y estado del autoarranque
736
901
  glassnote fix-sandbox (Linux) restaura el sandbox de Chromium; pide sudo una vez
@@ -738,6 +903,26 @@ function cmdHelp() {
738
903
  Banderas de install:
739
904
  --no-path no toca el rc; imprime la línea para pegarla
740
905
  --no-start instala y no arranca el cliente
906
+
907
+ Emparejamiento desatendido (install y pair) — para dar de alta muchos equipos sin que
908
+ nadie escriba el código en la web:
909
+ --org-id <id> organización a la que entra el equipo
910
+ --api-key <clave> API key de esa organización
911
+ --device-name <n> nombre del equipo (por defecto, el hostname)
912
+ --user-name <n> persona a cargo (por defecto, el usuario del sistema)
913
+ --api-url <url> otra API (por defecto, ${pairing.API_URL_POR_DEFECTO})
914
+ --ws-url <url> fuerza el websocket que guarda el equipo
915
+
916
+ El websocket NO se elige acá: lo decide la API que atiende el alta, porque el token que
917
+ emite sólo vale en el suyo. Para dar de alta contra otro servidor se cambia --api-url y el
918
+ websocket viene de vuelta solo. --ws-url es para el caso en que la API anuncia una
919
+ dirección a la que el equipo no llega (IP de LAN, proxy inverso con otro nombre).
920
+
921
+ Las mismas se pueden pasar por entorno, para despliegues que no admiten argumentos:
922
+ GLASSNOTE_ORG_ID, GLASSNOTE_API_KEY, GLASSNOTE_DEVICE_NAME, GLASSNOTE_USER_NAME,
923
+ GLASSNOTE_API_URL, GLASSNOTE_WS_URL.
924
+
925
+ npx @glassnote/client install --org-id 55 --api-key abc123 --device-name "Caja 01"
741
926
  `);
742
927
  return 0;
743
928
  }
@@ -762,6 +947,9 @@ function main() {
762
947
  });
763
948
  case 'install':
764
949
  return cmdInstall(rest);
950
+ case 'pair':
951
+ case 'enroll':
952
+ return cmdPair(rest);
765
953
  case 'update':
766
954
  case 'upgrade':
767
955
  return cmdUpdate(rest);
@@ -787,7 +975,20 @@ function main() {
787
975
  // Requerido desde un test, el archivo NO debe correr nada: solo expone las piezas que
788
976
  // se comprueban. Como CLI se comporta igual que siempre.
789
977
  if (require.main === module) {
790
- process.exit(main());
978
+ // `install` y `pair` hablan con la API, asi que main() puede devolver una promesa.
979
+ // Salir con process.exit() sin esperarla cortaria el alta a la mitad.
980
+ const resultado = main();
981
+ if (resultado && typeof resultado.then === 'function') {
982
+ resultado.then(
983
+ (codigo) => process.exit(codigo),
984
+ (error) => {
985
+ console.error(`glassnote: ${error && error.message ? error.message : error}`);
986
+ process.exit(1);
987
+ }
988
+ );
989
+ } else {
990
+ process.exit(resultado);
991
+ }
791
992
  } else {
792
- module.exports = { npmInvocation, npmEnv, toSpec };
993
+ module.exports = { npmInvocation, npmEnv, toSpec, guionCerrarCliente, cerrarClienteWindows };
793
994
  }
package/linuxCursor.js ADDED
@@ -0,0 +1,187 @@
1
+ // Donde esta el mouse, preguntado directo al servidor X.
2
+ //
3
+ // Hace falta porque screen.getCursorScreenPoint() de Electron miente justo en el unico caso
4
+ // que le importa a un overlay: mientras la ventana ignora el mouse (input shape de 1x1 px en
5
+ // X11) Chromium no recibe un solo evento de puntero, y esa funcion devuelve para siempre la
6
+ // ultima posicion que alcanzo a ver. Medido:
7
+ //
8
+ // ventana ignorando -> sigue al mouse (cae al XQueryPointer del servidor)
9
+ // ventana clickeable -> sigue al mouse (le llegan eventos reales)
10
+ // vuelve a ignorar -> CONGELADA en el ultimo punto que vio, para siempre
11
+ //
12
+ // O sea: funciona hasta la primera interaccion y despues miente. Ese es exactamente el
13
+ // "andaba y dejo de andar" del passthrough en Linux.
14
+ //
15
+ // Asi que se le pregunta al servidor: XQueryPointer, opcode 38. Es protocolo binario a mano,
16
+ // pero es protocolo congelado hace decadas y no arrastra ninguna dependencia nativa —que en
17
+ // este cliente, que se instala con npx y sin compilador, no es un lujo sino un requisito—.
18
+ // Si algo falla (Wayland, display por TCP, sin cookie) se devuelve null y quien llama se las
19
+ // arregla con lo que tenga.
20
+ 'use strict';
21
+
22
+ const net = require('net');
23
+ const fs = require('fs');
24
+ const os = require('os');
25
+ const path = require('path');
26
+
27
+ const OPCODE_QUERY_POINTER = 38;
28
+
29
+ let socket = null;
30
+ let ventanaRaiz = null;
31
+ let ultimaPosicion = null;
32
+ let consultaEnVuelo = false;
33
+ let inutilizable = false;
34
+
35
+ // El .Xauthority es una lista de bloques con largo de 2 bytes big-endian: familia, direccion,
36
+ // numero de display, nombre del metodo y la cookie. Solo interesa la MIT-MAGIC-COOKIE-1 de
37
+ // este display (o la que no fija display, que vale para todos).
38
+ function leerCookie(numeroDisplay) {
39
+ const archivo = process.env.XAUTHORITY || path.join(os.homedir(), '.Xauthority');
40
+ let buf;
41
+ try {
42
+ buf = fs.readFileSync(archivo);
43
+ } catch (error) {
44
+ return null;
45
+ }
46
+
47
+ let i = 0;
48
+ const bloque = () => {
49
+ if (i + 2 > buf.length) return null;
50
+ const largo = buf.readUInt16BE(i);
51
+ i += 2;
52
+ if (i + largo > buf.length) return null;
53
+ const datos = buf.subarray(i, i + largo);
54
+ i += largo;
55
+ return datos;
56
+ };
57
+
58
+ while (i + 2 <= buf.length) {
59
+ i += 2; // familia
60
+ const direccion = bloque();
61
+ const numero = bloque();
62
+ const nombre = bloque();
63
+ const cookie = bloque();
64
+ if (!direccion || !numero || !nombre || !cookie) break;
65
+ if (nombre.toString() !== 'MIT-MAGIC-COOKIE-1') continue;
66
+ const suDisplay = numero.toString();
67
+ if (suDisplay === '' || suDisplay === String(numeroDisplay)) return { nombre, cookie };
68
+ }
69
+ return null;
70
+ }
71
+
72
+ const relleno = (largo) => (4 - (largo % 4)) % 4;
73
+
74
+ function numeroDeDisplay() {
75
+ const display = process.env.DISPLAY || '';
76
+ // Solo sockets unix locales: ":0", ":0.0". Un display por TCP ("host:0") no se atiende.
77
+ const coincide = /^:(\d+)/.exec(display);
78
+ return coincide ? parseInt(coincide[1], 10) : null;
79
+ }
80
+
81
+ function conectar() {
82
+ const numero = numeroDeDisplay();
83
+ if (numero === null) {
84
+ inutilizable = true;
85
+ return;
86
+ }
87
+
88
+ const auth = leerCookie(numero) || { nombre: Buffer.alloc(0), cookie: Buffer.alloc(0) };
89
+ let cliente;
90
+ try {
91
+ cliente = net.connect(`/tmp/.X11-unix/X${numero}`);
92
+ } catch (error) {
93
+ inutilizable = true;
94
+ return;
95
+ }
96
+
97
+ cliente.on('error', () => {
98
+ inutilizable = true;
99
+ socket = null;
100
+ ventanaRaiz = null;
101
+ });
102
+ cliente.on('close', () => {
103
+ socket = null;
104
+ ventanaRaiz = null;
105
+ consultaEnVuelo = false;
106
+ });
107
+
108
+ cliente.once('connect', () => {
109
+ const largoNombre = auth.nombre.length;
110
+ const largoCookie = auth.cookie.length;
111
+ const inicio = Buffer.alloc(
112
+ 12 + largoNombre + relleno(largoNombre) + largoCookie + relleno(largoCookie)
113
+ );
114
+ inicio.write('l', 0, 'ascii'); // little endian
115
+ inicio.writeUInt16LE(11, 2); // protocolo 11.0
116
+ inicio.writeUInt16LE(0, 4);
117
+ inicio.writeUInt16LE(largoNombre, 6);
118
+ inicio.writeUInt16LE(largoCookie, 8);
119
+ auth.nombre.copy(inicio, 12);
120
+ auth.cookie.copy(inicio, 12 + largoNombre + relleno(largoNombre));
121
+ cliente.write(inicio);
122
+
123
+ cliente.once('data', (respuesta) => {
124
+ if (respuesta[0] !== 1) {
125
+ // 0 = rechazado (cookie mala), 2 = pide mas autenticacion.
126
+ inutilizable = true;
127
+ cliente.destroy();
128
+ return;
129
+ }
130
+ // La ventana raiz esta despues del vendor y de los formatos de pixel, todos de
131
+ // largo variable: hay que saltearlos para llegar a la primera pantalla.
132
+ const largoVendor = respuesta.readUInt16LE(24);
133
+ const cantidadFormatos = respuesta[29];
134
+ const inicioPantallas = 40 + largoVendor + relleno(largoVendor) + 8 * cantidadFormatos;
135
+ if (inicioPantallas + 4 > respuesta.length) {
136
+ inutilizable = true;
137
+ cliente.destroy();
138
+ return;
139
+ }
140
+ ventanaRaiz = respuesta.readUInt32LE(inicioPantallas);
141
+ socket = cliente;
142
+
143
+ cliente.on('data', (paquete) => {
144
+ // Respuesta de QueryPointer: 32 bytes, root_x y root_y en 20 y 22.
145
+ if (paquete.length >= 32 && paquete[0] === 1) {
146
+ ultimaPosicion = { x: paquete.readInt16LE(20), y: paquete.readInt16LE(22) };
147
+ }
148
+ consultaEnVuelo = false;
149
+ });
150
+ });
151
+ });
152
+ }
153
+
154
+ // Pide la posicion al servidor y devuelve la ULTIMA que contesto: la respuesta llega por el
155
+ // socket, no en el acto. Consultando a 30 Hz eso son 30 ms de atraso, que para decidir si el
156
+ // cursor esta sobre un boton no se nota. Nunca hay mas de una consulta en vuelo: si el
157
+ // servidor se atrasa, no se le encima una cola de pedidos que ya no le sirven a nadie.
158
+ function posicion() {
159
+ if (inutilizable) return null;
160
+ if (!socket) {
161
+ conectar();
162
+ return ultimaPosicion;
163
+ }
164
+ if (!consultaEnVuelo) {
165
+ const pedido = Buffer.alloc(8);
166
+ pedido[0] = OPCODE_QUERY_POINTER;
167
+ pedido.writeUInt16LE(2, 2); // largo en palabras de 4 bytes
168
+ pedido.writeUInt32LE(ventanaRaiz, 4);
169
+ consultaEnVuelo = true;
170
+ try {
171
+ socket.write(pedido);
172
+ } catch (error) {
173
+ consultaEnVuelo = false;
174
+ }
175
+ }
176
+ return ultimaPosicion;
177
+ }
178
+
179
+ function detener() {
180
+ if (socket) socket.destroy();
181
+ socket = null;
182
+ ventanaRaiz = null;
183
+ ultimaPosicion = null;
184
+ consultaEnVuelo = false;
185
+ }
186
+
187
+ module.exports = { posicion, detener };