@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/main.js CHANGED
@@ -13,14 +13,28 @@ const userData = require('./userData');
13
13
  const { ipcMain } = require('electron');
14
14
  const path = require('path');
15
15
  const fs = require('fs');
16
+ const { execFile } = require('child_process');
16
17
  const AutoStart = require('./autoStart');
17
18
  const AutoUpdate = require('./autoupdate');
18
19
  const NpmUpdate = require('./npmUpdate');
19
20
  const VersionReport = require('./versionReport');
20
21
  const npmMode = require('./npmMode');
21
22
  const AutoUpdateRenderer = require('./autoupdate_renderer');
22
- const { baselineRendererDir, rendererVersionFrom: getRendererVersionFromPath } = require('./rendererPaths');
23
+ const { resolverRenderer } = require('./rendererPaths');
24
+ const cursorX11 = require('./linuxCursor');
23
25
  const { createTailUtility } = require('./logUtilities');
26
+ const { crashReporter } = require('electron');
27
+
28
+ // Sin esto un crash nativo —del proceso principal o de un hijo— no deja absolutamente
29
+ // nada: ni log, ni volcado, y desde afuera se ve igual que un cierre voluntario. No se
30
+ // sube a ningun lado. Los minidumps NO caen junto a los logs: van a la carpeta que
31
+ // Electron llama crashDumps, que se arma con el nombre del paquete y queda en otro arbol
32
+ // (<AppData>/@glassnote/client/Crashpad). Por eso el arranque la escribe en el log.
33
+ try {
34
+ crashReporter.start({ uploadToServer: false });
35
+ } catch (error) {
36
+ console.error('No se pudo iniciar el crashReporter:', error.message);
37
+ }
24
38
 
25
39
  const logRenderer = packageJson.logRenderer !== undefined ? packageJson.logRenderer : true;
26
40
 
@@ -33,7 +47,12 @@ let mainWindow;
33
47
  let localServer = null;
34
48
  let tray = null;
35
49
  let lockCheckerInterval = null;
36
- let lockCheckerTimeout = null;
50
+ let sondeoCursor = null;
51
+ let ultimoPuntoEnviado = { x: null, y: null };
52
+ let ultimoEnvioMs = 0;
53
+ let cursorFueraDeLaVentana = false;
54
+ let passthroughPedidoPorRenderer = true;
55
+ let ignorandoMouse = true;
37
56
  let isCleaningUp = false;
38
57
 
39
58
 
@@ -53,15 +72,22 @@ function cleanupOldLogs() {
53
72
  const now = Date.now();
54
73
  const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
55
74
 
75
+ const borrados = [];
56
76
  files.forEach((file) => {
57
77
  if (file.endsWith('.log')) {
58
78
  const filePath = path.join(logsDir, file);
59
79
  const stats = fs.statSync(filePath);
60
80
  if (stats.mtimeMs < thirtyDaysAgo) {
61
81
  fs.unlinkSync(filePath);
82
+ borrados.push(file);
62
83
  }
63
84
  }
64
85
  });
86
+ if (borrados.length) {
87
+ console.warn(
88
+ `[logs] borrados ${borrados.length} de mas de 30 dias: ${borrados.join(', ')}`
89
+ );
90
+ }
65
91
  } catch (error) {
66
92
  console.error('Error cleaning up old logs:', error);
67
93
  }
@@ -92,7 +118,67 @@ console.log = function (...args) {
92
118
  // Call original console.log
93
119
  originalConsoleLog.apply(console, args);
94
120
  };
121
+ // console.warn no estaba interceptado y se perdian los 10 avisos del proceso principal,
122
+ // justo los que dicen que algo raro paso pero todavia no es un error: entre ellos el
123
+ // marcador de arranque de aca abajo y el "el archivo actual no es legible" de userData,
124
+ // que es exactamente lo que hace falta para entender un cierre silencioso.
125
+ const originalConsoleWarn = console.warn;
126
+ console.warn = function (...args) {
127
+ const timestamp = new Date().toISOString();
128
+ const message = args
129
+ .map((arg) => (typeof arg === 'object' ? JSON.stringify(arg) : String(arg)))
130
+ .join(' ');
131
+
132
+ const logMessage = `[${timestamp}] WARN: ${message}\n`;
133
+
134
+ try {
135
+ fs.appendFileSync(logFilePath, logMessage, 'utf8');
136
+ } catch (error) {
137
+ originalConsoleWarn.apply(console, ['Error writing to log file:', error]);
138
+ }
139
+
140
+ originalConsoleWarn.apply(console, args);
141
+ };
142
+
143
+ // Marca de cierre limpio. Es lo unico que distingue "la app se cerro sola" de "a la app
144
+ // la mataron", porque un crash o un apagado no llegan a borrarla. Se escribe en su propio
145
+ // archivo y no en .glassnote, que es el del lock: tocar ese al salir seria pedirle a otra
146
+ // instancia que se cierre.
147
+ const marcaCierrePath = path.join(userData.getUserDataDir(), 'cierre-limpio');
148
+
149
+ function huboCierreLimpio() {
150
+ try {
151
+ if (!fs.existsSync(marcaCierrePath)) return null;
152
+ const contenido = fs.readFileSync(marcaCierrePath, 'utf8').trim();
153
+ fs.unlinkSync(marcaCierrePath);
154
+ return contenido || 'sin motivo';
155
+ } catch (error) {
156
+ return null;
157
+ }
158
+ }
159
+
160
+ function marcarCierreLimpio(motivo) {
161
+ try {
162
+ fs.mkdirSync(userData.getUserDataDir(), { recursive: true });
163
+ fs.writeFileSync(marcaCierrePath, String(motivo), 'utf8');
164
+ } catch (error) {
165
+ console.error('No se pudo dejar la marca de cierre limpio:', error.message);
166
+ }
167
+ }
168
+
169
+ const cierreAnterior = huboCierreLimpio();
170
+
95
171
  console.warn('====== APPLICATION START ======');
172
+ console.warn(
173
+ `arranque: v${packageJson.version} ${process.platform} ${process.arch} ` +
174
+ `electron ${process.versions.electron} node ${process.versions.node} pid ${process.pid}`
175
+ );
176
+ console.warn(`volcados de crash en: ${app.getPath('crashDumps')}`);
177
+ console.warn(
178
+ cierreAnterior
179
+ ? `el cierre anterior fue limpio (${cierreAnterior})`
180
+ : `ATENCION: el arranque anterior NO cerro limpio; se murio o lo mataron`
181
+ );
96
182
  let initialLock = Date.now().toString();
97
183
 
98
184
  // Override console.error as well
@@ -117,7 +203,13 @@ console.error = function (...args) {
117
203
  };
118
204
 
119
205
  // Clean up old logs on startup
206
+ // Corria una sola vez, al arrancar, y este cliente esta pensado para quedarse prendido
207
+ // semanas: mientras siguiera vivo los logs de mas de 30 dias no se borraban nunca. Con el
208
+ // log mas hablador eso se nota antes, asi que ademas se repasa una vez por dia. unref()
209
+ // para que este reloj no sea motivo de que el proceso siga en pie.
210
+ const LIMPIEZA_LOGS_MS = 24 * 60 * 60 * 1000;
120
211
  cleanupOldLogs();
212
+ setInterval(cleanupOldLogs, LIMPIEZA_LOGS_MS).unref();
121
213
 
122
214
  // Create tail utility in logs directory for debugging
123
215
  createTailUtility(logsDir);
@@ -173,6 +265,8 @@ app.whenReady().then(() => {
173
265
  const versionReport = new VersionReport();
174
266
  versionReport.start();
175
267
 
268
+ avisarSiFaltaCompositor();
269
+
176
270
  // Create system tray with platform-appropriate icon
177
271
  let iconPath;
178
272
  if (process.platform === 'win32') {
@@ -183,32 +277,34 @@ app.whenReady().then(() => {
183
277
  } else {
184
278
  iconPath = path.join(__dirname, 'images/icon.png');
185
279
  }
186
- tray = new Tray(iconPath);
187
- const contextMenu = Menu.buildFromTemplate([
188
- {
189
- label: `glassnote v${packageJson.version}`,
190
- enabled: false,
191
- },
192
- { type: 'separator' },
193
- {
194
- label: 'Config Menu',
195
- click: () => {
196
- // Enviar mensaje al renderer para mostrar el ConfigMenu con vista Review
197
- if (mainWindow && !mainWindow.isDestroyed()) {
198
- mainWindow.webContents.send('show-config-menu', 'review');
199
- }
280
+ // En Linux el icono de bandeja viaja por D-Bus (StatusNotifierItem) y Electron solo manda
281
+ // el mapa de bits. Los paneles cuyo watcher es ayatana-indicator-application (XFCE con el
282
+ // plugin Indicator, Unity, MATE) exigen ademas IconName y descartan el item completo si
283
+ // falta: el icono no aparece en ningun lado y no hay error. Chromium si escribe el icono a
284
+ // un archivo y manda IconName, pero solo cuando cree estar en GNOME o Pantheon
285
+ // (ShouldWriteIconToFile, status_icon_linux_dbus.cc). Eso se lee una unica vez, dentro del
286
+ // constructor de Tray, asi que se miente para esa linea y se restaura enseguida: al resto
287
+ // de Chromium (dialogos, decoraciones) le sigue constando el escritorio real.
288
+ const realDesktop = process.env.XDG_CURRENT_DESKTOP;
289
+ if (process.platform === 'linux') {
290
+ process.env.XDG_CURRENT_DESKTOP = 'GNOME';
291
+ }
292
+ try {
293
+ tray = new Tray(iconPath);
294
+ } finally {
295
+ if (process.platform === 'linux') {
296
+ if (realDesktop === undefined) {
297
+ delete process.env.XDG_CURRENT_DESKTOP;
298
+ } else {
299
+ process.env.XDG_CURRENT_DESKTOP = realDesktop;
200
300
  }
201
301
  }
202
- ]);
203
- tray.setToolTip(`GlassNote v${packageJson.version}`);
204
- tray.setContextMenu(contextMenu);
302
+ }
303
+ actualizarMenuTray();
205
304
 
206
305
  // Agregar funcionalidad de click izquierdo para mostrar el Config Menu
207
306
  tray.on('click', () => {
208
- // Enviar mensaje al renderer para mostrar el ConfigMenu con vista Review
209
- if (mainWindow && !mainWindow.isDestroyed()) {
210
- mainWindow.webContents.send('show-config-menu', 'review');
211
- }
307
+ abrirConfigMenu('review');
212
308
  });
213
309
 
214
310
  // Platform-specific window configuration
@@ -246,6 +342,39 @@ app.whenReady().then(() => {
246
342
  // es el borde negro que se ve alrededor de los mensajes. Una capa de overlay no
247
343
  // necesita sombra.
248
344
  windowOptions.hasShadow = false;
345
+ } else if (process.platform === 'linux') {
346
+ // En Linux fullscreen es justo lo que NO hay que pedir. Un gestor de ventanas
347
+ // tipico —xfwm4, y no es el unico— trata "pantalla completa" como una app que se
348
+ // adueña del monitor: mientras tiene el foco la deja arriba de todo, y en cuanto se
349
+ // lo lleva otra ventana la BAJA, para que se pueda salir de ella. Medido en XFCE:
350
+ //
351
+ // fullscreen: true -> _NET_WM_STATE_FULLSCREEN, sin ABOVE; al enfocar otra
352
+ // ventana el overlay cae de la posicion 14/14 a la 9/14
353
+ // del tamaño de la pantalla -> _NET_WM_STATE_ABOVE, se queda en 14/14
354
+ //
355
+ // O sea que el alwaysOnTop solo se respeta si la ventana NO es fullscreen: pedir las
356
+ // dos cosas a la vez es pedir que se ignore la que importa.
357
+ //
358
+ // Va al AREA UTIL y no a la pantalla entera porque una ventana normal no puede
359
+ // meterse debajo de los paneles: si se le pide la pantalla completa, el gestor la
360
+ // empuja igual —queda en (0,25) y con 25 px afuera— y encima el tamaño le baja un
361
+ // pixel solo. Pidiendo el area util la geometria sale exacta y estable, que es lo que
362
+ // necesita el renderer para colocar los glasses.
363
+ const areaUtil = screen.getPrimaryDisplay().workArea;
364
+ windowOptions.x = areaUtil.x;
365
+ windowOptions.y = areaUtil.y;
366
+ windowOptions.width = areaUtil.width;
367
+ windowOptions.height = areaUtil.height;
368
+ windowOptions.fullscreen = false;
369
+ // `skipTaskbar` no existe en Linux —Electron solo lo implementa para Windows y macOS—
370
+ // asi que el overlay salia como una ventana normal y el panel le ponia su boton, que
371
+ // para una capa transparente no tiene ningun sentido. Se arregla por el tipo de
372
+ // ventana: TOOLBAR sale con SKIP_TASKBAR y SKIP_PAGER puestos por el gestor.
373
+ //
374
+ // De los tipos que Linux acepta es el unico que sirve: `splash` tambien se esconde
375
+ // pero NO se puede enfocar, y sin foco el ConfigMenu se queda sin teclado;
376
+ // `notification` se enfoca pero igual aparece en la barra.
377
+ windowOptions.type = 'toolbar';
249
378
  } else {
250
379
  // Windows and other platforms
251
380
  windowOptions.width = 400;
@@ -358,20 +487,87 @@ app.whenReady().then(() => {
358
487
  // Cargar el HTML del renderer (priorizar userData, fallback a app)
359
488
  loadRendererHtml(mainWindow, uuid);
360
489
 
361
- // Mouse events control
490
+ // Mouse events control (en Linux el forward lo emula iniciarSondeoCursor)
362
491
  mainWindow.setIgnoreMouseEvents(true, { forward: true });
492
+ iniciarSondeoCursor();
363
493
  ipcMain.on('set-ignore-events-true', () => {
364
- mainWindow.setIgnoreMouseEvents(true, { forward: true });
494
+ pedirPassthrough(true);
365
495
  });
366
496
  ipcMain.on('set-ignore-events-false', () => {
367
- mainWindow.setIgnoreMouseEvents(false, { forward: true });
497
+ pedirPassthrough(false);
368
498
  });
369
499
 
370
500
  // Window visibility control
501
+ //
502
+ // showInactive y no show: un glass es una notificacion, no tiene por que sacarle el
503
+ // teclado a lo que el usuario este haciendo. show() enfoca la ventana, y como el
504
+ // overlay ocupa toda la pantalla eso equivale a interrumpir. El unico que necesita
505
+ // foco es el ConfigMenu, y ese lo pide aparte.
371
506
  ipcMain.on('show-window', () => {
372
507
  if (mainWindow && !mainWindow.isDestroyed()) {
373
- mainWindow.show();
508
+ mainWindow.showInactive();
509
+ }
510
+ });
511
+
512
+ // En X11 la ventana pierde la input shape al volver a mostrarse, y con ella el
513
+ // passthrough: el overlay se queda comiendo los clics de TODA la pantalla aunque las dos
514
+ // mitades del programa crean que esta dejando pasar el mouse. Medido con la ventana X que
515
+ // hay bajo el puntero, que es quien manda de verdad:
516
+ //
517
+ // sin nada en pantalla -> la ventana de abajo (pasa el clic)
518
+ // despues de show() -> el overlay (se lo come)
519
+ //
520
+ // Por eso el glass del splash se portaba bien —la ventana ya estaba mapeada desde el
521
+ // arranque— y cualquier mensaje posterior bloqueaba: cada uno llega con su show(). Se
522
+ // reaplica en el evento, no en el handler del IPC, para cubrir tambien los show() que
523
+ // salen de otro lado.
524
+ // Y la GEOMETRIA se pierde igual que la input shape. El gestor recoloca la ventana por
525
+ // su cuenta DESPUES de mapearla y la deja corrida 25 px hacia el monitor de al lado, asi
526
+ // que el primer glass salia bien y los siguientes no. Medido con xwininfo, que es quien
527
+ // dice la verdad: getBounds() de Electron devuelve lo que se pidio, no lo que quedo.
528
+ //
529
+ // pedido x=0 -> electron dice x=0, X11 dice x=25
530
+ //
531
+ // Pedirla una sola vez al mostrar es una carrera que se pierde la mitad de las veces
532
+ // (medido: 25, 0, 25 en tres glasses seguidos). Por eso se reacciona tambien al evento
533
+ // `move`, que es el gestor avisando que la movio, y se la vuelve a poner. El contador
534
+ // corta el ida y vuelta por si alguna vez el gestor se planta.
535
+ //
536
+ // Se le pide de la barra de arriba hasta el borde de abajo: asi la ventana entra justa
537
+ // en la pantalla, no se sale por la derecha, tapa el panel inferior y le deja al arte
538
+ // una proporcion mucho mas cerca de 16:9 que el area util.
539
+ let correccionesDeGeometria = 0;
540
+
541
+ function fijarGeometria() {
542
+ if (!mainWindow || mainWindow.isDestroyed()) return;
543
+ const pantalla = screen.getPrimaryDisplay();
544
+ mainWindow.setBounds({
545
+ x: 0,
546
+ y: pantalla.workArea.y,
547
+ width: pantalla.bounds.width,
548
+ height: pantalla.bounds.height - pantalla.workArea.y
549
+ });
550
+ }
551
+
552
+ mainWindow.on('move', () => {
553
+ if (process.platform !== 'linux') return;
554
+ if (correccionesDeGeometria >= 8) return;
555
+ correccionesDeGeometria++;
556
+ fijarGeometria();
557
+ });
558
+
559
+ mainWindow.on('show', () => {
560
+ if (process.platform === 'linux') {
561
+ correccionesDeGeometria = 0;
562
+ fijarGeometria();
374
563
  }
564
+ aplicarPassthrough({ forzar: true });
565
+ });
566
+
567
+ // El canal ya estaba habilitado en el preload para renderer -> main, pero del lado de
568
+ // main no lo escuchaba nadie: el menu solo se podia abrir desde la bandeja.
569
+ ipcMain.on('show-config-menu', (event, vista) => {
570
+ abrirConfigMenu(vista);
375
571
  });
376
572
 
377
573
  ipcMain.on('hide-window', () => {
@@ -404,6 +600,9 @@ app.whenReady().then(() => {
404
600
  const registrationWindow = new BrowserWindow({
405
601
  width: 1200,
406
602
  height: 800,
603
+ // Sin esto Electron no pone _NET_WM_ICON y la unica ventana que sale en la barra de
604
+ // tareas de Linux aparece con el icono generico del gestor de ventanas.
605
+ icon: path.join(__dirname, 'images/icon.png'),
407
606
  minWidth: 800,
408
607
  minHeight: 600,
409
608
  show: false,
@@ -434,7 +633,7 @@ app.whenReady().then(() => {
434
633
  });
435
634
 
436
635
  mainWindow.on('ready-to-show', () => {
437
- mainWindow.show();
636
+ mainWindow.showInactive();
438
637
 
439
638
  // Use LocalServer singleton
440
639
  if (!localServer) {
@@ -449,12 +648,30 @@ app.whenReady().then(() => {
449
648
  lockChecker();
450
649
  });
451
650
 
651
+ // Un crash del renderer o de un hijo (GPU, utility) no pasa por ningun handler de los de
652
+ // arriba: no es una excepcion de JavaScript ni una senal, asi que el log se quedaba mudo.
653
+ // `reason` es lo que hay que leer: 'crashed', 'oom', 'killed', 'abnormal-exit',
654
+ // 'launch-failed', 'integrity-failure' o 'clean-exit'.
655
+ app.on('render-process-gone', (event, webContents, details) => {
656
+ console.error(
657
+ `====== MURIO EL RENDERER: ${details.reason} (exitCode ${details.exitCode}) ======`
658
+ );
659
+ });
660
+
661
+ app.on('child-process-gone', (event, details) => {
662
+ console.error(
663
+ `====== MURIO UN PROCESO HIJO: ${details.type}` +
664
+ `${details.name ? ' (' + details.name + ')' : ''} ` +
665
+ `${details.reason} (exitCode ${details.exitCode}) ======`
666
+ );
667
+ });
668
+
452
669
  app.on('window-all-closed', () => {
453
670
  // Don't quit when all windows are closed if we have a tray
454
671
  if (tray && process.platform !== 'darwin') {
455
672
  // Keep the app running with tray
456
673
  } else {
457
- cleanupAndQuit();
674
+ cleanupAndQuit('se cerraron todas las ventanas');
458
675
  }
459
676
  });
460
677
 
@@ -498,23 +715,178 @@ function lockChecker() {
498
715
  lockCheckerInterval = setInterval(() => {
499
716
  // Leer el lock directamente del archivo, no del cache
500
717
  const currentData = userData.getLockFromFile();
501
- // Si currentData es undefined, significa que el archivo .glassnote fue borrado o corrompido
502
- // También verificar si el valor del lock cambió
503
- if (currentData === undefined || currentData.toString() != initialLock.toString()) {
504
- cleanupAndQuit();
718
+
719
+ // Esta es la via por la que la app se cerraba sin dejar rastro, y las dos causas no
720
+ // son la misma cosa: que el archivo no este es un problema del disco o de algo que
721
+ // lo borro, y que el lock haya cambiado significa que arranco otra instancia y esta
722
+ // le cede el lugar. Se distinguen en el log porque llevan a sitios muy distintos.
723
+ if (currentData === undefined) {
724
+ cleanupAndQuit('el lock no se pudo leer: .glassnote no esta o no tiene lock');
725
+ return;
726
+ }
727
+ if (currentData.toString() != initialLock.toString()) {
728
+ cleanupAndQuit(
729
+ `el lock cambio (mio ${initialLock}, en el archivo ${currentData}): arranco otra instancia`
730
+ );
505
731
  }
506
732
  }, 10000); // Check every 10 seconds
507
733
  }
508
734
 
509
- function cleanupAndQuit() {
735
+ // El overlay deja pasar el mouse y solo se vuelve clickeable cuando el cursor esta encima
736
+ // de algo interactivo. Quien decide eso es el renderer: escucha `mousemove` en el document,
737
+ // compara la posicion contra los rectangulos de los elementos que registro, y pide apagar o
738
+ // encender el passthrough. Para decidir, entonces, necesita ver el mouse.
739
+ //
740
+ // En Windows y macOS se lo da `forward: true`. En Linux esa opcion NO EXISTE: Electron le
741
+ // pone al window una input shape de 1x1 px en X11 y el flag ni se mira (SetIgnoreMouseEvents
742
+ // en native_window_views.cc solo lo usa bajo IS_WIN). Sin un solo evento de mouse la
743
+ // posicion que guarda el renderer se queda en null para siempre, ningun elemento resulta
744
+ // nunca "bajo el cursor", y por eso en Linux no responde un boton.
745
+ //
746
+ // Se emula: se lee la posicion global del cursor y se le inyecta al renderer un mouseMove
747
+ // sintetico, que es exactamente lo que forwardea Windows. El hit-test sigue donde estan las
748
+ // zonas —en el renderer—; desde aca solo se le cuenta donde esta el mouse.
749
+ const SONDEO_CURSOR_MS = 30;
750
+ // Cada cuanto se repite la posicion aunque el cursor no se haya movido.
751
+ const REEMISION_QUIETO_MS = 400;
752
+
753
+ // El passthrough efectivo sale de dos cosas: lo que pidio el renderer y si el cursor esta
754
+ // siquiera sobre la ventana.
755
+ //
756
+ // Lo segundo no es un detalle: cuando el overlay esta clickeable y el cursor se va —a otro
757
+ // monitor, por ejemplo— el renderer no recibe un mousemove mas, se queda con la ultima
758
+ // posicion (encima del boton) y nunca devuelve el passthrough. El overlay se queda comiendo
759
+ // TODOS los clics de esa pantalla.
760
+ //
761
+ // No es solo de X11. Medido en Windows con dos monitores: al salir la ventana NO llega
762
+ // ningun mouseleave —se probo, y el evento recien aparece cuando el cursor vuelve—, asi que
763
+ // el overlay queda clickeable todo el tiempo que estes en la otra pantalla. Ahi se cura
764
+ // solo al volver, porque con la ventana clickeable los movimientos reales si le llegan;
765
+ // en Linux quedaba trabado hasta reiniciar. Con un solo monitor no se dispara: el overlay
766
+ // ocupa toda la pantalla y el cursor no tiene por donde salirse.
767
+ //
768
+ // Y avisarselo por un mouseMove sintetico no se puede: Chromium descarta los eventos
769
+ // cuyas coordenadas caen fuera de la ventana. Asi que el "esta afuera" lo resuelve el
770
+ // main, sin tocar lo que el renderer cree: cuando el cursor vuelve a entrar se reaplica
771
+ // su decision tal cual, y desde ahi los eventos reales —que si llegan con la ventana
772
+ // clickeable— lo ponen al dia.
773
+ function aplicarPassthrough({ forzar = false } = {}) {
774
+ if (!mainWindow || mainWindow.isDestroyed()) return;
775
+ const ignorar = passthroughPedidoPorRenderer || cursorFueraDeLaVentana;
776
+ if (ignorar === ignorandoMouse && !forzar) return;
777
+ ignorandoMouse = ignorar;
778
+ mainWindow.setIgnoreMouseEvents(ignorar, { forward: true });
779
+ }
780
+
781
+ function pedirPassthrough(ignore) {
782
+ passthroughPedidoPorRenderer = ignore;
783
+ aplicarPassthrough();
784
+ }
785
+
786
+ function iniciarSondeoCursor() {
787
+ if (sondeoCursor) return;
788
+ const enLinux = process.platform === 'linux';
789
+ sondeoCursor = setInterval(() => {
790
+ if (!mainWindow || mainWindow.isDestroyed() || !mainWindow.isVisible()) return;
791
+
792
+ // En X11 screen.getCursorScreenPoint() no sirve: con la ventana ignorando el mouse
793
+ // Chromium se queda sin eventos y devuelve congelada la ultima posicion que vio
794
+ // (el porque, medido, esta en linuxCursor.js). Se le pregunta al servidor X. En
795
+ // Windows no hace falta: ahi la posicion sale del SO y viene siempre al dia.
796
+ const cursor = (enLinux ? cursorX11.posicion() : null) || screen.getCursorScreenPoint();
797
+ const area = mainWindow.getBounds();
798
+ const x = cursor.x - area.x;
799
+ const y = cursor.y - area.y;
800
+ const dentro = x >= 0 && y >= 0 && x < area.width && y < area.height;
801
+
802
+ if (!dentro !== cursorFueraDeLaVentana) {
803
+ cursorFueraDeLaVentana = !dentro;
804
+ aplicarPassthrough();
805
+ }
806
+ if (!dentro) return;
807
+
808
+ // De aca para abajo es solo de Linux. En Windows el `forward: true` de
809
+ // setIgnoreMouseEvents ya entrega los movimientos reales con la ventana ignorando
810
+ // el mouse —medido: el boton recibe mouseover y se puede clickear—, asi que
811
+ // inyectarlos seria duplicarlos.
812
+ if (!enLinux) return;
813
+
814
+ // Con el overlay ya clickeable los eventos reales llegan solos: inyectar seria
815
+ // duplicarlos.
816
+ if (!ignorandoMouse) return;
817
+
818
+ // Repetir el mismo punto en cada tick es ruido, pero callarse del todo mientras el
819
+ // cursor esta quieto rompe el otro caso: el menu se abre DEBAJO de un cursor que no
820
+ // se movio, no hay mousemove que lo cuente, y ese boton nace muerto. Se reemite cada
821
+ // tanto para que la UI que aparece sola se entere igual.
822
+ const ahora = Date.now();
823
+ const mismoPunto = x === ultimoPuntoEnviado.x && y === ultimoPuntoEnviado.y;
824
+ if (mismoPunto && ahora - ultimoEnvioMs < REEMISION_QUIETO_MS) return;
825
+ ultimoPuntoEnviado = { x, y };
826
+ ultimoEnvioMs = ahora;
827
+ mainWindow.webContents.sendInputEvent({ type: 'mouseMove', x, y });
828
+ }, SONDEO_CURSOR_MS);
829
+ }
830
+
831
+ function detenerSondeoCursor() {
832
+ cursorX11.detener();
833
+ if (!sondeoCursor) return;
834
+ clearInterval(sondeoCursor);
835
+ sondeoCursor = null;
836
+ ultimoPuntoEnviado = { x: null, y: null };
837
+ ultimoEnvioMs = 0;
838
+ cursorFueraDeLaVentana = false;
839
+ }
840
+
841
+ // Una ventana transparente en X11 necesita un compositor: sin el, el servidor X no sabe
842
+ // mezclar el ARGB con lo que hay detras y la ventana se pinta OPACA. En un overlay a
843
+ // pantalla completa eso no es un detalle estetico, es la pantalla entera tapada de blanco.
844
+ // La app no puede arreglarlo desde adentro —es una pieza del escritorio, no suya—, pero
845
+ // callarselo es peor: el equipo queda inservible y no hay una sola linea que lo explique.
846
+ //
847
+ // Solo se comprueba en XFCE, que es el unico escritorio comun que viene con el compositor
848
+ // apagable (y apagado en varias instalaciones). GNOME, KDE y Cinnamon componen siempre.
849
+ function avisarSiFaltaCompositor() {
850
+ if (process.platform !== 'linux') return;
851
+ if ((process.env.XDG_SESSION_TYPE || '') === 'wayland') return; // Wayland siempre compone
852
+ if (!(process.env.XDG_CURRENT_DESKTOP || '').toUpperCase().includes('XFCE')) return;
853
+
854
+ execFile('xfconf-query', ['-c', 'xfwm4', '-p', '/general/use_compositing'], (error, stdout) => {
855
+ if (error) return; // sin xfconf-query no hay nada que comprobar
856
+ if (stdout.trim() !== 'false') return;
857
+
858
+ const arreglo = 'xfconf-query -c xfwm4 -p /general/use_compositing -s true';
859
+ console.warn('[transparencia] XFCE tiene el compositor APAGADO: la ventana de glassnote se');
860
+ console.warn('[transparencia] dibuja opaca y tapa la pantalla. Se enciende con:');
861
+ console.warn(`[transparencia] ${arreglo}`);
862
+ console.warn('[transparencia] o en Ajustes del gestor de ventanas > Compositor.');
863
+
864
+ try {
865
+ new Notification({
866
+ title: 'GlassNote: falta el compositor',
867
+ body: `Sin compositor la ventana se ve opaca y tapa la pantalla. Encendelo con:\n${arreglo}`,
868
+ }).show();
869
+ } catch (error) {
870
+ /* un escritorio sin notificaciones no puede tumbar el arranque */
871
+ }
872
+ });
873
+ }
874
+
875
+ function cleanupAndQuit(motivo) {
510
876
  // Prevent multiple cleanup calls
511
877
  if (isCleaningUp) {
512
878
  return;
513
879
  }
514
880
  isCleaningUp = true;
881
+
882
+ // Cerrarse sin decir por que es lo que hacia imposible investigar los reinicios: en el
883
+ // log quedaba un silencio y despues el arranque siguiente, sin nada en el medio.
884
+ console.warn(`====== CIERRE VOLUNTARIO: ${motivo || 'sin motivo'} ======`);
885
+ marcarCierreLimpio(motivo || 'sin motivo');
515
886
 
516
887
 
517
888
  // Clear intervals and timeouts
889
+ detenerSondeoCursor();
518
890
  if (lockCheckerInterval) {
519
891
  clearInterval(lockCheckerInterval);
520
892
  lockCheckerInterval = null;
@@ -554,22 +926,22 @@ function cleanupAndQuit() {
554
926
 
555
927
  // Handle process signals for clean shutdown
556
928
  process.on('SIGINT', () => {
557
- cleanupAndQuit();
929
+ cleanupAndQuit('SIGINT');
558
930
  });
559
931
 
560
932
  process.on('SIGTERM', () => {
561
- cleanupAndQuit();
933
+ cleanupAndQuit('SIGTERM');
562
934
  });
563
935
 
564
936
  // Handle uncaught exceptions to prevent zombie processes
565
937
  process.on('uncaughtException', (error) => {
566
938
  console.error('Uncaught exception:', error);
567
- cleanupAndQuit();
939
+ cleanupAndQuit('excepcion no capturada');
568
940
  });
569
941
 
570
942
  process.on('unhandledRejection', (reason, promise) => {
571
943
  console.error('Unhandled promise rejection:', reason);
572
- cleanupAndQuit();
944
+ cleanupAndQuit('promesa rechazada sin manejar');
573
945
  });
574
946
 
575
947
  // Start lock checker after everything is initialized
@@ -577,143 +949,88 @@ process.on('unhandledRejection', (reason, promise) => {
577
949
 
578
950
 
579
951
  /**
580
- * Carga el HTML del renderer desde la fuente con versión SUPERIOR.
581
- *
582
- * Dos fuentes, y la division importa:
583
- * - userData: lo que bajo el auto-update de S3. Es el canal EN CALIENTE: una version
584
- * nueva del renderer llega ahi sin republicar el cliente ni tocar el binario.
585
- * - baseline: el renderer que viaja con la instalacion. Es lo que ve una instalacion
586
- * nueva antes de su primer update en caliente.
587
- *
588
- * Gana la version mas alta, sin importar de donde venga; empate, gana userData.
952
+ * Carga el HTML del renderer. Cual de las dos fuentes gana lo decide resolverRenderer()
953
+ * en rendererPaths.js, que es tambien de donde sale la version que muestra el menu.
589
954
  */
590
- // baselineRendererDir vive en rendererPaths.js: lo comparte con el updater del renderer.
955
+ /**
956
+ * Abre el ConfigMenu. Es lo unico de la app que usa el teclado, y desde que los glasses
957
+ * se muestran con showInactive() la ventana ya no viene enfocada de arriba: hay que
958
+ * mostrarla y pedirle el foco a mano antes de avisarle al renderer.
959
+ */
960
+ function abrirConfigMenu(vista) {
961
+ if (!mainWindow || mainWindow.isDestroyed()) return;
962
+ mainWindow.show();
963
+ mainWindow.focus();
964
+ mainWindow.webContents.send('show-config-menu', vista || 'review');
965
+ }
591
966
 
592
967
  function loadRendererHtml(window, uuid) {
593
- // Definir las dos fuentes a considerar con archivos HTML alternativos
594
- const sources = [
595
- {
596
- name: 'userData',
597
- dir: path.join(userData.getUserDataDir(), 'renderer'),
598
- htmlFiles: ['glassnote.html'], // userData siempre usa glassnote.html
599
- type: 'auto-update'
600
- },
601
- {
602
- name: 'baseline',
603
- dir: baselineRendererDir(),
604
- htmlFiles: ['glassnote.html', 'index.html'], // Intenta glassnote.html primero, luego index.html
605
- type: 'paquete'
606
- }
607
- ];
608
-
609
-
610
- // Obtener versiones y verificar archivos HTML para ambas fuentes
611
- const sourcesWithInfo = sources.map(source => {
612
- const version = getRendererVersionFromPath(source.dir);
613
-
614
- // Buscar el primer archivo HTML que exista
615
- let htmlPath = null;
616
- let htmlFile = null;
617
- for (const file of source.htmlFiles) {
618
- const potentialPath = path.join(source.dir, file);
619
- if (fs.existsSync(potentialPath)) {
620
- htmlPath = potentialPath;
621
- htmlFile = file;
622
- break;
623
- }
624
- }
625
-
626
- const htmlExists = htmlPath !== null;
627
-
628
- return {
629
- ...source,
630
- version,
631
- htmlPath,
632
- htmlFile,
633
- htmlExists
634
- };
635
- });
636
-
637
- // Log de información encontrada
638
- sourcesWithInfo.forEach(source => {
639
- if (source.htmlExists) {
640
- } else {
641
- console.log(` ${source.name}: versión ${source.version}, SIN ARCHIVO HTML`);
968
+ const { elegida, fuentes } = resolverRenderer();
969
+
970
+ for (const fuente of fuentes) {
971
+ if (!fuente.htmlPath) {
972
+ console.log(` ${fuente.nombre}: versión ${fuente.version}, SIN ARCHIVO HTML`);
642
973
  }
643
- });
644
-
645
- // Comparar versiones para determinar cuál es superior
646
- const versionComparison = compareVersions(
647
- sourcesWithInfo[0].version,
648
- sourcesWithInfo[1].version
649
- );
650
-
651
- // Determinar fuente con versión superior
652
- let superiorSource;
653
- if (versionComparison > 0) {
654
- // userData tiene versión superior
655
- superiorSource = sourcesWithInfo[0];
656
- } else if (versionComparison < 0) {
657
- // glassnote-renderer/dist tiene versión superior
658
- superiorSource = sourcesWithInfo[1];
659
- } else {
660
- // Versiones iguales, priorizar userData (auto-update)
661
- superiorSource = sourcesWithInfo[0];
662
974
  }
663
975
 
664
- // Intentar cargar desde la fuente con versión superior
665
- if (superiorSource.htmlExists) {
666
- window.loadFile(superiorSource.htmlPath, {
976
+ if (elegida) {
977
+ window.loadFile(elegida.htmlPath, {
667
978
  query: { uuid: uuid, version: packageJson.version }
668
979
  });
980
+ // El menu de la bandeja muestra la version del renderer, y esta es la linea que la
981
+ // decide: rearmarlo aca es lo que hace que despues de un update en caliente el numero
982
+ // del menu sea el que se acaba de cargar y no el del arranque.
983
+ actualizarMenuTray();
669
984
  return;
670
985
  }
671
986
 
672
- // Si la fuente con versión superior no tiene HTML, intentar la otra fuente
673
- const otherSource = superiorSource.name === 'userData' ? sourcesWithInfo[1] : sourcesWithInfo[0];
674
-
675
- if (otherSource.htmlExists) {
676
-
677
- window.loadFile(otherSource.htmlPath, {
678
- query: { uuid: uuid, version: packageJson.version }
679
- });
680
- return;
987
+ console.error('ERROR: No se encontró archivo HTML del renderer en ninguna fuente');
988
+ for (const fuente of fuentes) {
989
+ console.error(`${fuente.nombre} (${fuente.dir}) buscó:`, fuente.htmls.join(', '));
681
990
  }
682
991
 
683
- // Si ninguna fuente tiene HTML, error
684
- console.error('ERROR: No se encontró archivo HTML del renderer en ninguna fuente');
685
- console.error('userData buscó:', sourcesWithInfo[0].htmlFiles.join(', '));
686
- console.error(`baseline (${sourcesWithInfo[1].dir}) buscó:`, sourcesWithInfo[1].htmlFiles.join(', '));
687
-
688
992
  // Error fatal - no hay renderer disponible
689
993
  console.error('ERROR FATAL: No se puede cargar el renderer');
690
- // Intentar cargar una página de error o cerrar la aplicación
691
994
  window.loadURL('data:text/html,<h1>Error: No se pudo cargar el renderer</h1>');
692
995
  }
693
996
 
694
997
  /**
695
- * Compara dos versiones semánticas (formato: major.minor.patch)
696
- * Retorna 1 si version1 > version2
697
- * Retorna 0 si version1 == version2
698
- * Retorna -1 si version1 < version2
998
+ * El menu de la bandeja. Se rearma entero en vez de editarse porque Electron no deja
999
+ * cambiar la etiqueta de un item ya construido en Linux.
1000
+ *
1001
+ * Lleva las dos versiones a proposito: el cliente y el renderer se actualizan por vias
1002
+ * distintas —npm una, el zip de S3 la otra— y se desincronizan seguido. Con un solo
1003
+ * numero a la vista no habia forma de saber, mirando el equipo, cual de las dos partes
1004
+ * era la vieja.
699
1005
  */
700
- function compareVersions(version1, version2) {
701
- const v1Parts = version1.split('.').map((part) => parseInt(part) || 0);
702
- const v2Parts = version2.split('.').map((part) => parseInt(part) || 0);
703
-
704
- // Comparar partes principales (major, minor, patch)
705
- for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) {
706
- const v1Part = v1Parts[i] || 0;
707
- const v2Part = v2Parts[i] || 0;
708
-
709
- if (v1Part > v2Part) return 1;
710
- if (v1Part < v2Part) return -1;
711
- }
712
-
713
- return 0; // Las versiones son iguales
1006
+ function construirMenuTray(versionRenderer) {
1007
+ return Menu.buildFromTemplate([
1008
+ {
1009
+ label: `glassnote v${packageJson.version}`,
1010
+ enabled: false,
1011
+ },
1012
+ {
1013
+ label: versionRenderer ? `renderer v${versionRenderer}` : 'renderer: no cargado',
1014
+ enabled: false,
1015
+ },
1016
+ { type: 'separator' },
1017
+ {
1018
+ label: 'Config Menu',
1019
+ click: () => {
1020
+ abrirConfigMenu('review');
1021
+ }
1022
+ }
1023
+ ]);
714
1024
  }
715
1025
 
716
- // getRendererVersionFromPath = rendererVersionFrom de rendererPaths.js (ver el require de arriba).
1026
+ function actualizarMenuTray() {
1027
+ if (!tray || tray.isDestroyed()) return;
1028
+ const versionRenderer = (resolverRenderer().elegida || {}).version;
1029
+ tray.setToolTip(
1030
+ `GlassNote v${packageJson.version}` + (versionRenderer ? ` · renderer v${versionRenderer}` : '')
1031
+ );
1032
+ tray.setContextMenu(construirMenuTray(versionRenderer));
1033
+ }
717
1034
 
718
1035
  /**
719
1036
  * Recarga el main window después de una actualización del renderer
@@ -726,6 +1043,12 @@ function reloadMainWindow() {
726
1043
  localServer.updateMainWindow(mainWindow);
727
1044
  }
728
1045
 
1046
+ // El renderer que vuelve a cargar arranca creyendo que el overlay deja pasar el
1047
+ // mouse. Si habia quedado clickeable, nadie se lo iba a devolver: el renderer solo
1048
+ // avisa cuando CAMBIA de estado, y para el recien nacido no cambio nada. Se vuelve
1049
+ // al estado inicial a mano para que las dos mitades arranquen de acuerdo.
1050
+ pedirPassthrough(true);
1051
+
729
1052
  loadRendererHtml(mainWindow, uuid);
730
1053
  }
731
1054
  }