@glassnote/client 2.4.9 → 2.4.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glassnote/client",
3
- "version": "2.4.9",
3
+ "version": "2.4.11",
4
4
  "description": "GlassNote — cliente de escritorio de notas superpuestas. Se instala con npx, sin instaladores nativos ni firmas.",
5
5
  "main": "main.js",
6
6
  "publishConfig": {
@@ -32,9 +32,11 @@
32
32
  "rendererPaths.js",
33
33
  "npmMode.js",
34
34
  "npmUpdate.js",
35
+ "versionReport.js",
35
36
  "localserver.js",
36
37
  "logUtilities.js",
37
38
  "userData.js",
39
+ "pairing.js",
38
40
  "INSTALACION.md",
39
41
  "README.md"
40
42
  ],
@@ -53,7 +55,9 @@
53
55
  "prepublishOnly": "node scripts/check-publish.js",
54
56
  "start-electron": "electron .",
55
57
  "test-update": "node scripts/test-update.js",
56
- "test": "node scripts/test-update.js"
58
+ "test-pairing": "node scripts/test-pairing.js",
59
+ "test-cli": "node scripts/test-cli.js",
60
+ "test": "node scripts/test-cli.js && node scripts/test-pairing.js && node scripts/test-update.js"
57
61
  },
58
62
  "keywords": [
59
63
  "glassnote",
package/pairing.js ADDED
@@ -0,0 +1,338 @@
1
+ // Emparejamiento desatendido: el equipo se da de alta solo con la API key de la
2
+ // organizacion, sin que nadie mire la pantalla.
3
+ //
4
+ // El emparejamiento de siempre es a dos manos: el cliente muestra un keycode y una
5
+ // persona lo escribe en la web. Para un equipo esta bien. Para trescientos —una cadena
6
+ // que abre locales, un parque que se reinstala entero— no es un flujo, es una tarde
7
+ // entera y un error de tipeo cada veinte equipos. Aca el instalador ya trae la API key
8
+ // de la organizacion, asi que el propio cliente pide su alta a la API y se guarda el
9
+ // token con el que se conecta. La instalacion termina con el equipo ya en el tablero.
10
+ //
11
+ // Vive fuera de bin/glassnote.js a proposito: decidir QUE se le pide a la API es una
12
+ // funcion pura y se puede comprobar en cualquier maquina; hacerlo de verdad, no.
13
+ 'use strict';
14
+
15
+ const os = require('os');
16
+ const http = require('http');
17
+ const https = require('https');
18
+ const { URL } = require('url');
19
+
20
+ const API_URL_POR_DEFECTO = 'https://glassnoteapi.intermark.ec';
21
+ const TIMEOUT_MS = 20000;
22
+
23
+ // Un solo sitio donde vive la correspondencia bandera <-> variable de entorno. Las
24
+ // variables existen porque un despliegue por GPO o por MDM muchas veces no puede pasar
25
+ // argumentos, pero si puede poner entorno.
26
+ const OPCIONES = [
27
+ { clave: 'orgId', banderas: ['--org-id', '--organization-id'], env: 'GLASSNOTE_ORG_ID' },
28
+ { clave: 'apiKey', banderas: ['--api-key', '--apikey'], env: 'GLASSNOTE_API_KEY' },
29
+ { clave: 'deviceName', banderas: ['--device-name', '--name'], env: 'GLASSNOTE_DEVICE_NAME' },
30
+ { clave: 'userName', banderas: ['--user-name', '--owner-name', '--owner'], env: 'GLASSNOTE_USER_NAME' },
31
+ { clave: 'apiUrl', banderas: ['--api-url'], env: 'GLASSNOTE_API_URL' },
32
+ { clave: 'wsUrl', banderas: ['--ws-url', '--server'], env: 'GLASSNOTE_WS_URL' },
33
+ ];
34
+
35
+ // Acepta `--org-id 55` y `--org-id=55`: los dos aparecen en los scripts de despliegue que
36
+ // escribe la gente, y fallar por la forma del signo igual es una perdida de tiempo.
37
+ function leerOpciones(args, env) {
38
+ const argv = Array.isArray(args) ? args : [];
39
+ const entorno = env || {};
40
+ const opciones = {};
41
+
42
+ for (const { clave, banderas, env: nombreEnv } of OPCIONES) {
43
+ let valor;
44
+ for (let i = 0; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ const conIgual = banderas.find((b) => a.startsWith(`${b}=`));
47
+ if (conIgual) {
48
+ valor = a.slice(conIgual.length + 1);
49
+ continue;
50
+ }
51
+ if (banderas.includes(a) && i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
52
+ valor = argv[i + 1];
53
+ }
54
+ }
55
+ if (valor === undefined && entorno[nombreEnv]) valor = entorno[nombreEnv];
56
+ if (valor !== undefined) opciones[clave] = String(valor).trim();
57
+ }
58
+
59
+ return opciones;
60
+ }
61
+
62
+ // ¿Pidieron emparejar? Basta con que venga la API key: sin ella no hay nada que hacer, y
63
+ // con ella el resto tiene valores por defecto razonables.
64
+ function pidenEmparejar(opciones) {
65
+ return !!(opciones && opciones.apiKey);
66
+ }
67
+
68
+ // El nombre por defecto es el del equipo, no uno generico. En un despliegue masivo el
69
+ // mismo comando corre en todas las maquinas: si el nombre no sale del propio equipo, el
70
+ // tablero termina con trescientos "Unnamed Device" y hay que renombrarlos a mano, que es
71
+ // justo el trabajo que esto viene a evitar.
72
+ function nombrePorDefecto() {
73
+ try {
74
+ return os.hostname();
75
+ } catch (error) {
76
+ return '';
77
+ }
78
+ }
79
+
80
+ function usuarioPorDefecto() {
81
+ try {
82
+ return os.userInfo().username;
83
+ } catch (error) {
84
+ return '';
85
+ }
86
+ }
87
+
88
+ function ipsLocales() {
89
+ const ips = [];
90
+ try {
91
+ const interfaces = os.networkInterfaces();
92
+ for (const nombre of Object.keys(interfaces)) {
93
+ for (const iface of interfaces[nombre] || []) {
94
+ if (!iface.internal && iface.address) ips.push(iface.address);
95
+ }
96
+ }
97
+ } catch (error) {
98
+ /* sin red que enumerar: se manda vacio */
99
+ }
100
+ return ips;
101
+ }
102
+
103
+ // Lo que se le manda a POST /devices/enroll. Separado del envio para poder comprobarlo.
104
+ function cuerpoDeAlta(opciones, uuid) {
105
+ return {
106
+ organizationId: opciones.orgId,
107
+ uuid,
108
+ name: opciones.deviceName || nombrePorDefecto() || uuid,
109
+ ownerName: opciones.userName || usuarioPorDefecto() || undefined,
110
+ os: process.platform,
111
+ localIps: JSON.stringify(ipsLocales()),
112
+ };
113
+ }
114
+
115
+ function urlDeAlta(opciones) {
116
+ const base = (opciones.apiUrl || API_URL_POR_DEFECTO).replace(/\/+$/, '');
117
+ return `${base}/devices/enroll`;
118
+ }
119
+
120
+ function postJson(url, headers, cuerpo) {
121
+ return new Promise((resolve, reject) => {
122
+ let destino;
123
+ try {
124
+ destino = new URL(url);
125
+ } catch (error) {
126
+ return reject(new Error(`la URL de la API no es valida: ${url}`));
127
+ }
128
+
129
+ const datos = Buffer.from(JSON.stringify(cuerpo), 'utf8');
130
+ const transporte = destino.protocol === 'http:' ? http : https;
131
+ const req = transporte.request(
132
+ destino,
133
+ {
134
+ method: 'POST',
135
+ headers: Object.assign(
136
+ {
137
+ 'content-type': 'application/json',
138
+ 'content-length': datos.length,
139
+ },
140
+ headers
141
+ ),
142
+ },
143
+ (res) => {
144
+ const trozos = [];
145
+ res.on('data', (t) => trozos.push(t));
146
+ res.on('end', () => {
147
+ const texto = Buffer.concat(trozos).toString('utf8');
148
+ let json = null;
149
+ try {
150
+ json = JSON.parse(texto);
151
+ } catch (error) {
152
+ /* la API contesto algo que no es JSON; se conserva el texto */
153
+ }
154
+ resolve({ status: res.statusCode, json, texto });
155
+ });
156
+ }
157
+ );
158
+
159
+ req.setTimeout(TIMEOUT_MS, () => {
160
+ req.destroy(new Error(`la API no contesto en ${TIMEOUT_MS / 1000} segundos`));
161
+ });
162
+ req.on('error', reject);
163
+ req.end(datos);
164
+ });
165
+ }
166
+
167
+ // La API contesta el error dentro del JSON y `message` puede ser texto o lista (las
168
+ // validaciones de Nest vienen en lista). Un instalador que dice "fallo con 403" y nada
169
+ // mas obliga a abrir el codigo; se saca el motivo de donde este.
170
+ function motivoDelError(respuesta) {
171
+ const m = respuesta.json && respuesta.json.message;
172
+ let motivo = '';
173
+ if (Array.isArray(m)) motivo = m.join('; ');
174
+ else if (typeof m === 'string') motivo = m;
175
+
176
+ // Cuando el guard rechaza la clave, Nest contesta su "Forbidden resource" generico,
177
+ // que no le dice nada a quien esta desplegando. El unico motivo posible aca es la
178
+ // clave, asi que se dice cual es.
179
+ if (respuesta.status === 401 || (respuesta.status === 403 && /forbidden resource/i.test(motivo))) {
180
+ return 'la API key no es valida o ya expiro';
181
+ }
182
+
183
+ if (motivo) return motivo;
184
+ if (respuesta.texto) return respuesta.texto.slice(0, 300);
185
+ return `la API contesto ${respuesta.status}`;
186
+ }
187
+
188
+ /**
189
+ * Da de alta el equipo y deja escrito en el archivo de usuario todo lo que la app
190
+ * necesita para conectarse sola en el primer arranque: el servidor y su token.
191
+ *
192
+ * `userData` entra por parametro y no por require: este archivo se carga desde el CLI,
193
+ * que corre tambien donde el arbol de dependencias esta a medias.
194
+ */
195
+ async function emparejar(opciones, userData) {
196
+ if (!opciones.apiKey) throw new Error('falta la API key de la organizacion (--api-key)');
197
+ if (!opciones.orgId) throw new Error('falta el id de la organizacion (--org-id)');
198
+
199
+ const uuid = userData.deviceUUID();
200
+ const respuesta = await postJson(
201
+ urlDeAlta(opciones),
202
+ { 'x-api-key': opciones.apiKey },
203
+ cuerpoDeAlta(opciones, uuid)
204
+ );
205
+
206
+ if (respuesta.status !== 200 && respuesta.status !== 201) {
207
+ throw new Error(motivoDelError(respuesta));
208
+ }
209
+
210
+ const datos = respuesta.json || {};
211
+ if (!datos.refreshToken) {
212
+ throw new Error('la API acepto el alta pero no devolvio token');
213
+ }
214
+
215
+ const server = servidorEfectivo(opciones, datos.server);
216
+ guardarAlta(userData, datos, server);
217
+
218
+ return {
219
+ uuid,
220
+ deviceId: datos.deviceId,
221
+ organizationId: datos.organizationId,
222
+ name: datos.name,
223
+ server,
224
+ servidorAnunciado: datos.server || '',
225
+ servidorForzado: !!opciones.wsUrl,
226
+ mismoOrigenQueLaApi: mismoOrigen(urlDeAlta(opciones), server),
227
+ yaEstaba: !!datos.alreadyEnrolled,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * A que websocket se conecta el equipo.
233
+ *
234
+ * Normalmente lo decide la API, no el instalador: el token que acaba de emitir solo vale
235
+ * en SU websocket, asi que apuntar a otro sitio solo consigue un equipo dado de alta que
236
+ * no conecta. Por eso para hablar con otro servidor lo que se cambia es `--api-url`, y el
237
+ * websocket viene de vuelta solo.
238
+ *
239
+ * El override existe para el unico caso que eso no cubre: que la API anuncie una
240
+ * direccion a la que el equipo no llega —una IP de LAN detras de un NAT, un proxy inverso
241
+ * publicado con otro nombre, DNS partido—. Ahi el alta sale bien y los trescientos
242
+ * equipos guardan una URL muerta, sin ninguna senal en el momento de instalar. Lo
243
+ * correcto es arreglar WEBSOCKET_URL en la API; esto es la salida cuando no se puede.
244
+ */
245
+ function servidorEfectivo(opciones, servidorDeLaApi) {
246
+ const forzado = (opciones.wsUrl || '').trim();
247
+ if (forzado) {
248
+ if (!/^wss?:\/\//i.test(forzado)) {
249
+ throw new Error(`--ws-url tiene que empezar por ws:// o wss:// (llego "${forzado}")`);
250
+ }
251
+ return forzado;
252
+ }
253
+ if (!servidorDeLaApi) {
254
+ throw new Error(
255
+ 'la API no devolvio websocket (le falta WEBSOCKET_URL); se puede forzar con --ws-url'
256
+ );
257
+ }
258
+ return servidorDeLaApi;
259
+ }
260
+
261
+ // El cliente saca el origen HTTP de la API a partir de esta misma URL de websocket
262
+ // (cambiandole el esquema), asi que cuando el host no coincide con el de --api-url hay
263
+ // algo raro y conviene decirlo. No es un error: un despliegue puede separarlos a
264
+ // proposito.
265
+ function mismoOrigen(urlApi, urlWebsocket) {
266
+ try {
267
+ return new URL(urlApi).host === new URL(urlWebsocket).host;
268
+ } catch (error) {
269
+ return true; // si no se puede comparar, no se avisa de nada
270
+ }
271
+ }
272
+
273
+ // Se escribe exactamente lo mismo que dejaba el emparejamiento por keycode, y por las
274
+ // mismas claves: para la app las dos altas son indistinguibles, asi que no hay un segundo
275
+ // camino de conexion que mantener.
276
+ function guardarAlta(userData, datos, servidor) {
277
+ // El servidor va aparte del resto de la respuesta porque puede venir forzado desde la
278
+ // linea de comandos. Y tiene que ser EL MISMO en las tres claves: la app busca el
279
+ // token por la URL con la que se conecta, asi que guardarlo bajo otra lo deja
280
+ // arrancando y sin sesion, que es un fallo mudo.
281
+ const server = servidor || datos.server;
282
+
283
+ const servers = userData.get('servers');
284
+ const lista = Array.isArray(servers) ? servers.slice() : [];
285
+ if (!lista.includes(server)) lista.push(server);
286
+ userData.set('servers', lista);
287
+
288
+ userData.set('refreshTokens', server, datos.refreshToken);
289
+ if (datos.refreshTokenHash) {
290
+ userData.set('refreshTokenHashes', server, datos.refreshTokenHash);
291
+ }
292
+
293
+ // El token de acceso viejo pertenece al alta anterior: si se queda, la app intenta
294
+ // conectarse con el antes que con el refresh nuevo y le rebota.
295
+ const accessTokens = userData.get('accessTokens');
296
+ if (accessTokens && typeof accessTokens === 'object' && accessTokens[server]) {
297
+ userData.remove('accessTokens', server);
298
+ }
299
+ }
300
+
301
+ // Todas las banderas de emparejamiento llevan valor. El CLI lo necesita para no
302
+ // confundir ese valor con un argumento suelto: `install --org-id 55` no instala la
303
+ // version "55".
304
+ const BANDERAS_CON_VALOR = OPCIONES.reduce((todas, o) => todas.concat(o.banderas), []);
305
+
306
+ // El primer argumento suelto, saltando banderas y los valores que se llevan. Sin esto,
307
+ // `glassnote install --api-key abc` intentaba instalar la version "abc".
308
+ function argumentoPosicional(args) {
309
+ const argv = Array.isArray(args) ? args : [];
310
+ for (let i = 0; i < argv.length; i++) {
311
+ const a = argv[i];
312
+ if (a.startsWith('-')) {
313
+ if (BANDERAS_CON_VALOR.includes(a)) i++; // el valor va aparte, se salta
314
+ continue;
315
+ }
316
+ return a;
317
+ }
318
+ return undefined;
319
+ }
320
+
321
+ module.exports = {
322
+ API_URL_POR_DEFECTO,
323
+ BANDERAS_CON_VALOR,
324
+ argumentoPosicional,
325
+ OPCIONES,
326
+ leerOpciones,
327
+ pidenEmparejar,
328
+ cuerpoDeAlta,
329
+ urlDeAlta,
330
+ motivoDelError,
331
+ guardarAlta,
332
+ servidorEfectivo,
333
+ mismoOrigen,
334
+ emparejar,
335
+ nombrePorDefecto,
336
+ usuarioPorDefecto,
337
+ ipsLocales,
338
+ };
@@ -0,0 +1,156 @@
1
+ // El cliente le dice a la API qué versión corre, y se lleva de vuelta la que debería
2
+ // correr. Un solo viaje para las dos cosas.
3
+ //
4
+ // Por qué hace falta: hasta ahora la API no sabía la versión de ningún equipo. Un cliente
5
+ // que no se actualiza se ve exactamente igual que uno al día, y por eso la
6
+ // auto-actualización pudo estar rota en Windows durante meses sin que nadie lo notara.
7
+ //
8
+ // Y de paso cierra `desiredVersion`, que estaba muerto: la API lo tiene en la base y lo
9
+ // empuja por websocket, pero el renderer nunca lo guardó, así que fijar la versión de un
10
+ // equipo no hacía nada. Viniendo en la respuesta de este reporte, se cierra el circuito
11
+ // sin tocar el renderer —cuyo código, además, no vive en este repo—.
12
+ const http = require('http');
13
+ const https = require('https');
14
+
15
+ const userData = require('./userData');
16
+ const packageJson = require('./package.json');
17
+
18
+ const DELAY_MS = 15000; // después del arranque, sin competir con la carga del renderer
19
+ const INTERVALO_MS = 6 * 60 * 60 * 1000;
20
+ const TIMEOUT_MS = 10000;
21
+
22
+ class VersionReport {
23
+ constructor() {
24
+ this.timer = null;
25
+ }
26
+
27
+ start() {
28
+ setTimeout(() => this.reportar(), DELAY_MS);
29
+ this.timer = setInterval(() => this.reportar(), INTERVALO_MS);
30
+ }
31
+
32
+ stop() {
33
+ if (this.timer) {
34
+ clearInterval(this.timer);
35
+ this.timer = null;
36
+ }
37
+ }
38
+
39
+ // Los servidores se guardan como URLs de websocket (ws://, wss://). La API HTTP vive
40
+ // en el mismo origen, así que basta con quedarse con el origen y cambiar el esquema.
41
+ origenes() {
42
+ let servers = [];
43
+ try {
44
+ servers = userData.get('servers') || [];
45
+ } catch (error) {
46
+ return [];
47
+ }
48
+ if (!Array.isArray(servers)) return [];
49
+
50
+ const vistos = new Set();
51
+ for (const server of servers) {
52
+ if (typeof server !== 'string' || !server.trim()) continue;
53
+ try {
54
+ const url = new URL(server.trim());
55
+ const esquema = url.protocol === 'wss:' || url.protocol === 'https:' ? 'https:' : 'http:';
56
+ vistos.add(`${esquema}//${url.host}`);
57
+ } catch (error) {
58
+ /* una entrada mal formada no puede tumbar al resto */
59
+ }
60
+ }
61
+ return Array.from(vistos);
62
+ }
63
+
64
+ post(origen, cuerpo) {
65
+ return new Promise((resolve) => {
66
+ let url;
67
+ try {
68
+ url = new URL('/devices/report-version', origen);
69
+ } catch (error) {
70
+ return resolve(null);
71
+ }
72
+ const datos = Buffer.from(JSON.stringify(cuerpo));
73
+ const cliente = url.protocol === 'https:' ? https : http;
74
+ const req = cliente.request(
75
+ url,
76
+ {
77
+ method: 'POST',
78
+ headers: {
79
+ 'content-type': 'application/json',
80
+ 'content-length': datos.length,
81
+ },
82
+ },
83
+ (res) => {
84
+ let body = '';
85
+ res.setEncoding('utf8');
86
+ res.on('data', (chunk) => (body += chunk));
87
+ res.on('end', () => {
88
+ if (res.statusCode < 200 || res.statusCode >= 300) {
89
+ console.warn(`[version] ${origen} respondió ${res.statusCode}`);
90
+ return resolve(null);
91
+ }
92
+ try {
93
+ resolve(JSON.parse(body));
94
+ } catch (error) {
95
+ resolve(null);
96
+ }
97
+ });
98
+ }
99
+ );
100
+ req.setTimeout(TIMEOUT_MS, () => {
101
+ req.destroy();
102
+ resolve(null);
103
+ });
104
+ // Un equipo sin red, o con la API caída, es lo normal en este parque: se
105
+ // registra en el log y se reintenta en el ciclo siguiente. Nunca rompe la app.
106
+ req.on('error', (error) => {
107
+ console.warn(`[version] no se pudo reportar a ${origen}: ${error.message}`);
108
+ resolve(null);
109
+ });
110
+ req.end(datos);
111
+ });
112
+ }
113
+
114
+ async reportar() {
115
+ let uuid;
116
+ try {
117
+ uuid = userData.getUUID();
118
+ } catch (error) {
119
+ return;
120
+ }
121
+ const origenes = this.origenes();
122
+ if (origenes.length === 0) return;
123
+
124
+ const cuerpo = { uuid, version: packageJson.version };
125
+ for (const origen of origenes) {
126
+ const respuesta = await this.post(origen, cuerpo);
127
+ if (respuesta && typeof respuesta.desiredVersion === 'string') {
128
+ this.guardarDesiredVersion(respuesta.desiredVersion);
129
+ // Con el primer servidor que conteste alcanza: todos miran la misma base.
130
+ return;
131
+ }
132
+ }
133
+ }
134
+
135
+ // Se escribe solo si cambió: userData reescribe el archivo entero en cada set.
136
+ guardarDesiredVersion(valor) {
137
+ const limpio = valor.trim();
138
+ // Acotado a lo que el updater sabe interpretar. Cualquier otra cosa se ignora en
139
+ // vez de guardarse: un valor raro acá deja al equipo sin actualizarse y sin decir
140
+ // por qué, que es exactamente el fallo del que venimos.
141
+ const aceptable = limpio === 'lastest' || limpio === 'latest' || /^\d+\.\d+\.\d+/.test(limpio);
142
+ if (!aceptable) {
143
+ console.warn(`[version] desiredVersion ignorado por ilegible: ${JSON.stringify(limpio)}`);
144
+ return;
145
+ }
146
+ try {
147
+ if (userData.get('desiredVersion') === limpio) return;
148
+ console.log(`[version] desiredVersion: ${limpio}`);
149
+ userData.set('desiredVersion', limpio);
150
+ } catch (error) {
151
+ console.error('[version] no se pudo guardar desiredVersion:', error.message);
152
+ }
153
+ }
154
+ }
155
+
156
+ module.exports = VersionReport;