@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glassnote/client",
3
- "version": "2.4.10",
3
+ "version": "2.4.12",
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": {
@@ -30,12 +30,14 @@
30
30
  "autoupdate.js",
31
31
  "autoupdate_renderer.js",
32
32
  "rendererPaths.js",
33
+ "linuxCursor.js",
33
34
  "npmMode.js",
34
35
  "npmUpdate.js",
35
36
  "versionReport.js",
36
37
  "localserver.js",
37
38
  "logUtilities.js",
38
39
  "userData.js",
40
+ "pairing.js",
39
41
  "INSTALACION.md",
40
42
  "README.md"
41
43
  ],
@@ -54,7 +56,9 @@
54
56
  "prepublishOnly": "node scripts/check-publish.js",
55
57
  "start-electron": "electron .",
56
58
  "test-update": "node scripts/test-update.js",
57
- "test": "node scripts/test-update.js"
59
+ "test-pairing": "node scripts/test-pairing.js",
60
+ "test-cli": "node scripts/test-cli.js",
61
+ "test": "node scripts/test-cli.js && node scripts/test-pairing.js && node scripts/test-update.js"
58
62
  },
59
63
  "keywords": [
60
64
  "glassnote",
@@ -70,7 +74,7 @@
70
74
  "url": "git+ssh://git@intermarkec/intermarkec/glassnote-electron.git"
71
75
  },
72
76
  "dependencies": {
73
- "@glassnote/renderer": "2.0.9",
77
+ "@glassnote/renderer": "2.0.11",
74
78
  "cors": "^2.8.5",
75
79
  "electron": "^29.4.6",
76
80
  "express": "^4.18.2",
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
+ };
package/rendererPaths.js CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
+ const userData = require('./userData');
13
14
 
14
15
  // El baseline es la dependencia @glassnote/renderer, que npm resuelve sola. Pero si hay
15
16
  // un clon del renderer al lado —el flujo de quien lo desarrolla, con `npm run
@@ -59,4 +60,59 @@ function compareRendererVersions(a, b) {
59
60
  return 0;
60
61
  }
61
62
 
62
- module.exports = { baselineRendererDir, rendererVersionFrom, compareRendererVersions };
63
+ // Cual de los dos renderers se usa de verdad, con el mismo criterio para el que lo carga
64
+ // y para el que solo quiere mostrar la version. Si fueran dos calculos, el numero del menu
65
+ // terminaria mintiendo el dia que uno de los dos cambie.
66
+ //
67
+ // - userData: lo que bajo el auto-update de S3. Es el canal EN CALIENTE: una version
68
+ // nueva llega ahi sin republicar el cliente ni tocar el binario.
69
+ // - baseline: el renderer que viaja con la instalacion, lo que ve un equipo recien
70
+ // instalado antes de su primer update en caliente.
71
+ //
72
+ // Gana la version mas alta venga de donde venga; si empatan gana userData. Pero una
73
+ // fuente sin HTML no se puede cargar, asi que en ese caso se cae a la otra: la version
74
+ // que se informa es la que de verdad esta corriendo, no la que gano la comparacion.
75
+ function resolverRenderer() {
76
+ const fuentes = [
77
+ {
78
+ nombre: 'userData',
79
+ dir: path.join(userData.getUserDataDir(), 'renderer'),
80
+ htmls: ['glassnote.html'], // userData siempre usa glassnote.html
81
+ },
82
+ {
83
+ nombre: 'baseline',
84
+ dir: baselineRendererDir(),
85
+ htmls: ['glassnote.html', 'index.html'],
86
+ },
87
+ ].map((fuente) => {
88
+ let htmlPath = null;
89
+ for (const archivo of fuente.htmls) {
90
+ const posible = path.join(fuente.dir, archivo);
91
+ if (fs.existsSync(posible)) {
92
+ htmlPath = posible;
93
+ break;
94
+ }
95
+ }
96
+ return { ...fuente, version: rendererVersionFrom(fuente.dir), htmlPath };
97
+ });
98
+
99
+ const [enCaliente, baseline] = fuentes;
100
+ const gana = compareRendererVersions(enCaliente.version, baseline.version) < 0 ? baseline : enCaliente;
101
+ const otra = gana === enCaliente ? baseline : enCaliente;
102
+
103
+ return { elegida: gana.htmlPath ? gana : otra.htmlPath ? otra : null, fuentes };
104
+ }
105
+
106
+ // La version del renderer que esta corriendo, o null si no hay ninguno cargable.
107
+ function rendererVersionActiva() {
108
+ const { elegida } = resolverRenderer();
109
+ return elegida ? elegida.version : null;
110
+ }
111
+
112
+ module.exports = {
113
+ baselineRendererDir,
114
+ rendererVersionFrom,
115
+ compareRendererVersions,
116
+ resolverRenderer,
117
+ rendererVersionActiva,
118
+ };