vibemon 1.5.0

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.
@@ -0,0 +1,660 @@
1
+ /**
2
+ * System tray management for Vibe Monitor
3
+ */
4
+
5
+ const { Tray, Menu, nativeImage, BrowserWindow, ipcMain } = require('electron');
6
+ const { createCanvas } = require('canvas');
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const {
10
+ STATE_COLORS, CHARACTER_CONFIG, DEFAULT_CHARACTER,
11
+ HTTP_PORT, LOCK_MODES, ALWAYS_ON_TOP_MODES,
12
+ VALID_STATES, CHARACTER_NAMES, TRAY_ICON_SIZE,
13
+ STATS_CACHE_PATH
14
+ } = require('../shared/config.cjs');
15
+
16
+ const COLOR_EYE = '#000000';
17
+
18
+ // Tray icon cache for performance
19
+ const trayIconCache = new Map();
20
+
21
+ /**
22
+ * Create tray icon with state-based background color using canvas
23
+ */
24
+ function createTrayIcon(state, character = 'clawd') {
25
+ const cacheKey = `${state}-${character}`;
26
+
27
+ // Return cached icon if available
28
+ if (trayIconCache.has(cacheKey)) {
29
+ return trayIconCache.get(cacheKey);
30
+ }
31
+
32
+ const size = TRAY_ICON_SIZE;
33
+ const canvas = createCanvas(size, size);
34
+ const ctx = canvas.getContext('2d');
35
+
36
+ const bgColor = STATE_COLORS[state] || STATE_COLORS.idle;
37
+ const charConfig = CHARACTER_CONFIG[character] || CHARACTER_CONFIG[DEFAULT_CHARACTER];
38
+ const charColor = charConfig.color;
39
+ const charName = charConfig.name;
40
+
41
+ // Helper to draw filled rectangle
42
+ function rect(x, y, w, h, color) {
43
+ ctx.fillStyle = color;
44
+ ctx.fillRect(x, y, w, h);
45
+ }
46
+
47
+ // Clear canvas (transparent)
48
+ ctx.clearRect(0, 0, size, size);
49
+
50
+ // Draw rounded background
51
+ const radius = 4;
52
+ ctx.fillStyle = bgColor;
53
+ ctx.beginPath();
54
+ ctx.roundRect(0, 0, size, size, radius);
55
+ ctx.fill();
56
+
57
+ if (charName === 'kiro') {
58
+ // Draw ghost character for kiro
59
+ rect(6, 4, 10, 2, charColor); // Rounded top
60
+ rect(5, 6, 12, 8, charColor); // Main body
61
+ rect(5, 14, 4, 3, charColor); // Left wave
62
+ rect(9, 15, 4, 2, charColor); // Middle wave
63
+ rect(13, 14, 4, 3, charColor); // Right wave
64
+ rect(7, 8, 2, 2, COLOR_EYE); // Left eye
65
+ rect(13, 8, 2, 2, COLOR_EYE); // Right eye
66
+ } else if (charName === 'claw') {
67
+ // Draw claw character (red with antennae)
68
+ rect(8, 2, 2, 4, charColor); // Left antenna
69
+ rect(12, 2, 2, 4, charColor); // Right antenna
70
+ rect(5, 6, 12, 10, charColor); // Body
71
+ rect(6, 16, 3, 3, charColor); // Left leg
72
+ rect(13, 16, 3, 3, charColor); // Right leg
73
+ rect(7, 10, 2, 2, '#40E0D0'); // Left eye (cyan)
74
+ rect(13, 10, 2, 2, '#40E0D0'); // Right eye (cyan)
75
+ } else {
76
+ // Draw clawd character (default)
77
+ rect(4, 6, 14, 8, charColor); // Body
78
+ rect(2, 8, 2, 3, charColor); // Left arm
79
+ rect(18, 8, 2, 3, charColor); // Right arm
80
+ rect(5, 14, 2, 4, charColor); // Left outer leg
81
+ rect(8, 14, 2, 4, charColor); // Left inner leg
82
+ rect(12, 14, 2, 4, charColor); // Right inner leg
83
+ rect(15, 14, 2, 4, charColor); // Right outer leg
84
+ rect(6, 9, 2, 2, COLOR_EYE); // Left eye
85
+ rect(14, 9, 2, 2, COLOR_EYE); // Right eye
86
+ }
87
+
88
+ // Convert canvas to PNG buffer and create nativeImage
89
+ const pngBuffer = canvas.toBuffer('image/png');
90
+ const icon = nativeImage.createFromBuffer(pngBuffer);
91
+
92
+ // Cache the icon for future use
93
+ trayIconCache.set(cacheKey, icon);
94
+
95
+ return icon;
96
+ }
97
+
98
+ class TrayManager {
99
+ constructor(windowManager, app, wsClient = null) {
100
+ this.tray = null;
101
+ this.windowManager = windowManager;
102
+ this.app = app;
103
+ this.wsClient = wsClient;
104
+ this.statsWindow = null;
105
+ this.tokenWindow = null;
106
+
107
+ // Set up IPC handler for token setting
108
+ this.setupTokenIpc();
109
+ }
110
+
111
+ /**
112
+ * Set WebSocket client reference (can be set after construction)
113
+ * @param {WsClient} wsClient
114
+ */
115
+ setWsClient(wsClient) {
116
+ this.wsClient = wsClient;
117
+ }
118
+
119
+ /**
120
+ * Set up IPC handlers for token window
121
+ */
122
+ setupTokenIpc() {
123
+ ipcMain.on('set-token', (event, token) => {
124
+ if (this.wsClient) {
125
+ this.wsClient.setToken(token);
126
+ this.updateMenu();
127
+ }
128
+ if (this.tokenWindow && !this.tokenWindow.isDestroyed()) {
129
+ this.tokenWindow.close();
130
+ }
131
+ });
132
+
133
+ ipcMain.on('cancel-token', () => {
134
+ if (this.tokenWindow && !this.tokenWindow.isDestroyed()) {
135
+ this.tokenWindow.close();
136
+ }
137
+ });
138
+
139
+ ipcMain.on('get-current-token', (event) => {
140
+ const token = this.wsClient ? this.wsClient.getToken() : null;
141
+ event.reply('current-token', token || '');
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Open token input window
147
+ */
148
+ openTokenWindow() {
149
+ if (this.tokenWindow && !this.tokenWindow.isDestroyed()) {
150
+ this.tokenWindow.focus();
151
+ return;
152
+ }
153
+
154
+ this.tokenWindow = new BrowserWindow({
155
+ width: 400,
156
+ height: 180,
157
+ frame: true,
158
+ resizable: false,
159
+ minimizable: false,
160
+ maximizable: false,
161
+ alwaysOnTop: true,
162
+ skipTaskbar: true,
163
+ title: 'Set WebSocket Token',
164
+ webPreferences: {
165
+ nodeIntegration: true,
166
+ contextIsolation: false
167
+ }
168
+ });
169
+
170
+ const html = `
171
+ <!DOCTYPE html>
172
+ <html>
173
+ <head>
174
+ <meta charset="UTF-8">
175
+ <style>
176
+ body {
177
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
178
+ margin: 0;
179
+ padding: 20px;
180
+ background: #1e1e1e;
181
+ color: #fff;
182
+ }
183
+ label {
184
+ display: block;
185
+ margin-bottom: 8px;
186
+ font-size: 14px;
187
+ }
188
+ input {
189
+ width: 100%;
190
+ padding: 10px;
191
+ border: 1px solid #444;
192
+ border-radius: 4px;
193
+ background: #2d2d2d;
194
+ color: #fff;
195
+ font-size: 14px;
196
+ box-sizing: border-box;
197
+ margin-bottom: 16px;
198
+ }
199
+ input:focus {
200
+ outline: none;
201
+ border-color: #007acc;
202
+ }
203
+ .buttons {
204
+ display: flex;
205
+ justify-content: flex-end;
206
+ gap: 10px;
207
+ }
208
+ button {
209
+ padding: 8px 16px;
210
+ border: none;
211
+ border-radius: 4px;
212
+ cursor: pointer;
213
+ font-size: 14px;
214
+ }
215
+ .cancel {
216
+ background: #444;
217
+ color: #fff;
218
+ }
219
+ .save {
220
+ background: #007acc;
221
+ color: #fff;
222
+ }
223
+ .clear {
224
+ background: #6c3d3d;
225
+ color: #fff;
226
+ margin-right: auto;
227
+ }
228
+ button:hover {
229
+ opacity: 0.9;
230
+ }
231
+ </style>
232
+ </head>
233
+ <body>
234
+ <label for="token">WebSocket Token:</label>
235
+ <input type="text" id="token" placeholder="Enter your token">
236
+ <div class="buttons">
237
+ <button class="clear" onclick="clearToken()">Clear</button>
238
+ <button class="cancel" onclick="cancel()">Cancel</button>
239
+ <button class="save" onclick="save()">Save</button>
240
+ </div>
241
+ <script>
242
+ const { ipcRenderer } = require('electron');
243
+ const input = document.getElementById('token');
244
+
245
+ ipcRenderer.send('get-current-token');
246
+ ipcRenderer.on('current-token', (event, token) => {
247
+ input.value = token;
248
+ input.select();
249
+ });
250
+
251
+ function save() {
252
+ ipcRenderer.send('set-token', input.value.trim());
253
+ }
254
+
255
+ function cancel() {
256
+ ipcRenderer.send('cancel-token');
257
+ }
258
+
259
+ function clearToken() {
260
+ input.value = '';
261
+ ipcRenderer.send('set-token', '');
262
+ }
263
+
264
+ input.addEventListener('keydown', (e) => {
265
+ if (e.key === 'Enter') save();
266
+ if (e.key === 'Escape') cancel();
267
+ });
268
+ </script>
269
+ </body>
270
+ </html>`;
271
+
272
+ this.tokenWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
273
+
274
+ this.tokenWindow.on('closed', () => {
275
+ this.tokenWindow = null;
276
+ });
277
+ }
278
+
279
+ openStatsWindow() {
280
+ // If window exists, close it first
281
+ if (this.statsWindow && !this.statsWindow.isDestroyed()) {
282
+ this.statsWindow.close();
283
+ this.statsWindow = null;
284
+ }
285
+
286
+ // Create new stats window (frameless like monitor window)
287
+ this.statsWindow = new BrowserWindow({
288
+ width: 640,
289
+ height: 475,
290
+ frame: false,
291
+ transparent: true,
292
+ resizable: false,
293
+ hasShadow: true,
294
+ alwaysOnTop: false,
295
+ skipTaskbar: false,
296
+ webPreferences: {
297
+ nodeIntegration: false,
298
+ contextIsolation: true
299
+ }
300
+ });
301
+
302
+ this.statsWindow.loadURL(`http://127.0.0.1:${HTTP_PORT}/stats`);
303
+
304
+ this.statsWindow.on('closed', () => {
305
+ this.statsWindow = null;
306
+ });
307
+
308
+ // Close on blur (lose focus)
309
+ this.statsWindow.on('blur', () => {
310
+ if (this.statsWindow && !this.statsWindow.isDestroyed()) {
311
+ this.statsWindow.close();
312
+ }
313
+ });
314
+ }
315
+
316
+ /**
317
+ * Get state from first window or return default state
318
+ * @returns {Object}
319
+ */
320
+ getFirstWindowState() {
321
+ const projectIds = this.windowManager.getProjectIds();
322
+ if (projectIds.length === 0) {
323
+ return { state: 'idle', character: DEFAULT_CHARACTER, project: null };
324
+ }
325
+ const firstProjectId = projectIds[0];
326
+ const state = this.windowManager.getState(firstProjectId);
327
+ return state || { state: 'idle', character: DEFAULT_CHARACTER, project: firstProjectId };
328
+ }
329
+
330
+ createTray() {
331
+ const state = this.getFirstWindowState();
332
+ const icon = createTrayIcon(state.state, state.character);
333
+ this.tray = new Tray(icon);
334
+ this.tray.setToolTip('Vibe Monitor');
335
+ this.updateMenu();
336
+
337
+ // Left-click to show menu (Windows support)
338
+ this.tray.on('click', () => {
339
+ this.tray.popUpContextMenu();
340
+ });
341
+
342
+ return this.tray;
343
+ }
344
+
345
+ updateIcon() {
346
+ if (!this.tray) return;
347
+ const state = this.getFirstWindowState();
348
+ const icon = createTrayIcon(state.state, state.character);
349
+ this.tray.setImage(icon);
350
+ }
351
+
352
+ buildWindowsSubmenu() {
353
+ const projectIds = this.windowManager.getProjectIds();
354
+
355
+ if (projectIds.length === 0) {
356
+ return [{ label: 'No windows', enabled: false }];
357
+ }
358
+
359
+ const items = projectIds.map(projectId => {
360
+ const state = this.windowManager.getState(projectId);
361
+ const currentState = state ? state.state : 'idle';
362
+ const currentCharacter = state ? state.character : DEFAULT_CHARACTER;
363
+ return {
364
+ label: `${projectId} (${currentState})`,
365
+ submenu: [
366
+ { label: 'Show', click: () => this.windowManager.showWindow(projectId) },
367
+ { label: 'Close', click: () => this.windowManager.closeWindow(projectId) },
368
+ { type: 'separator' },
369
+ {
370
+ label: 'State',
371
+ submenu: VALID_STATES.map(s => ({
372
+ label: s,
373
+ type: 'radio',
374
+ checked: currentState === s,
375
+ click: () => {
376
+ // Re-fetch state at click time to avoid stale closure reference
377
+ const currentState = this.windowManager.getState(projectId);
378
+ if (!currentState) return;
379
+ const newState = { ...currentState, state: s };
380
+ this.windowManager.updateState(projectId, newState);
381
+ this.windowManager.sendToWindow(projectId, 'state-update', newState);
382
+ this.windowManager.updateAlwaysOnTopByState(projectId, s);
383
+ this.updateMenu();
384
+ this.updateIcon();
385
+ }
386
+ }))
387
+ },
388
+ {
389
+ label: 'Character',
390
+ submenu: CHARACTER_NAMES.map(c => ({
391
+ label: c,
392
+ type: 'radio',
393
+ checked: currentCharacter === c,
394
+ click: () => {
395
+ // Re-fetch state at click time to avoid stale closure reference
396
+ const currentState = this.windowManager.getState(projectId);
397
+ if (!currentState) return;
398
+ const newState = { ...currentState, character: c };
399
+ this.windowManager.updateState(projectId, newState);
400
+ this.windowManager.sendToWindow(projectId, 'state-update', newState);
401
+ this.updateMenu();
402
+ this.updateIcon();
403
+ }
404
+ }))
405
+ }
406
+ ]
407
+ };
408
+ });
409
+
410
+ items.push({ type: 'separator' });
411
+ items.push({
412
+ label: 'Show All',
413
+ enabled: projectIds.length > 0,
414
+ click: () => this.windowManager.showAllWindows()
415
+ });
416
+ items.push({
417
+ label: 'Close All',
418
+ enabled: projectIds.length > 0,
419
+ click: () => this.windowManager.closeAllWindows()
420
+ });
421
+
422
+ return items;
423
+ }
424
+
425
+ buildProjectLockSubmenu() {
426
+ const items = [];
427
+ const lockMode = this.windowManager.getLockMode();
428
+ const lockedProject = this.windowManager.getLockedProject();
429
+ const projectList = this.windowManager.getProjectList();
430
+
431
+ // Lock Mode selection
432
+ items.push({
433
+ label: 'Lock Mode',
434
+ submenu: Object.entries(LOCK_MODES).map(([mode, label]) => ({
435
+ label: label,
436
+ type: 'radio',
437
+ checked: lockMode === mode,
438
+ click: () => {
439
+ this.windowManager.setLockMode(mode);
440
+ this.updateMenu();
441
+ }
442
+ }))
443
+ });
444
+
445
+ items.push({ type: 'separator' });
446
+
447
+ if (projectList.length === 0) {
448
+ items.push({
449
+ label: 'No projects',
450
+ enabled: false
451
+ });
452
+ } else {
453
+ // List all projects sorted by name
454
+ const sortedProjects = [...projectList].sort((a, b) => a.localeCompare(b));
455
+ sortedProjects.forEach(project => {
456
+ const isLocked = project === lockedProject;
457
+ items.push({
458
+ label: project,
459
+ type: 'radio',
460
+ checked: isLocked,
461
+ click: () => {
462
+ this.windowManager.lockProject(project);
463
+ this.updateMenu();
464
+ this.updateIcon();
465
+ }
466
+ });
467
+ });
468
+
469
+ items.push({ type: 'separator' });
470
+ }
471
+
472
+ // Unlock option
473
+ items.push({
474
+ label: 'Unlock',
475
+ enabled: lockedProject !== null,
476
+ click: () => {
477
+ this.windowManager.unlockProject();
478
+ this.updateMenu();
479
+ }
480
+ });
481
+
482
+ return items;
483
+ }
484
+
485
+ buildAlwaysOnTopSubmenu() {
486
+ const currentMode = this.windowManager.getAlwaysOnTopMode();
487
+
488
+ return Object.entries(ALWAYS_ON_TOP_MODES).map(([mode, label]) => ({
489
+ label: label,
490
+ type: 'radio',
491
+ checked: currentMode === mode,
492
+ click: () => {
493
+ this.windowManager.setAlwaysOnTopMode(mode);
494
+ this.updateMenu();
495
+ }
496
+ }));
497
+ }
498
+
499
+ buildWebSocketStatusMenu() {
500
+ if (!this.wsClient) {
501
+ return [];
502
+ }
503
+
504
+ const status = this.wsClient.getStatus();
505
+ const token = this.wsClient.getToken();
506
+ const hasToken = Boolean(token);
507
+ let statusLabel;
508
+ let statusIcon;
509
+
510
+ switch (status) {
511
+ case 'connected':
512
+ statusIcon = '●';
513
+ statusLabel = 'WebSocket: Connected';
514
+ break;
515
+ case 'connecting':
516
+ statusIcon = '○';
517
+ statusLabel = 'WebSocket: Connecting...';
518
+ break;
519
+ case 'disconnected':
520
+ statusIcon = '○';
521
+ statusLabel = 'WebSocket: Disconnected';
522
+ break;
523
+ case 'not-configured':
524
+ default:
525
+ statusIcon = '○';
526
+ statusLabel = 'WebSocket: Not configured';
527
+ }
528
+
529
+ return [
530
+ {
531
+ label: `${statusIcon} ${statusLabel}`,
532
+ enabled: false
533
+ },
534
+ {
535
+ label: hasToken ? 'Token: Set ✓' : 'Token: Not set',
536
+ enabled: false
537
+ },
538
+ {
539
+ label: 'Set Token...',
540
+ click: () => this.openTokenWindow()
541
+ }
542
+ ];
543
+ }
544
+
545
+ buildMenuTemplate() {
546
+ const projectIds = this.windowManager.getProjectIds();
547
+ const windowCount = projectIds.length;
548
+ const state = this.getFirstWindowState();
549
+
550
+ // Build status display based on window count
551
+ let statusLabel;
552
+ if (windowCount === 0) {
553
+ statusLabel = 'No active windows';
554
+ } else if (windowCount === 1) {
555
+ statusLabel = `${state.project || 'Unknown'}: ${state.state}`;
556
+ } else {
557
+ statusLabel = `${windowCount} windows active`;
558
+ }
559
+
560
+ return [
561
+ {
562
+ label: statusLabel,
563
+ enabled: false
564
+ },
565
+ { type: 'separator' },
566
+ {
567
+ label: 'Windows',
568
+ submenu: this.buildWindowsSubmenu()
569
+ },
570
+ { type: 'separator' },
571
+ {
572
+ label: 'Always on Top',
573
+ submenu: this.buildAlwaysOnTopSubmenu()
574
+ },
575
+ {
576
+ label: 'Rearrange',
577
+ enabled: windowCount > 1 && this.windowManager.isMultiMode(),
578
+ click: () => {
579
+ this.windowManager.arrangeWindowsByName();
580
+ }
581
+ },
582
+ {
583
+ label: 'Multi-Window Mode',
584
+ type: 'checkbox',
585
+ checked: this.windowManager.isMultiMode(),
586
+ click: () => {
587
+ const newMode = this.windowManager.isMultiMode() ? 'single' : 'multi';
588
+ this.windowManager.setWindowMode(newMode);
589
+ this.updateMenu();
590
+ }
591
+ },
592
+ ...(this.windowManager.isMultiMode() ? [] : [{
593
+ label: 'Project Lock',
594
+ submenu: this.buildProjectLockSubmenu()
595
+ }]),
596
+ { type: 'separator' },
597
+ {
598
+ label: 'Claude Stats',
599
+ enabled: fs.existsSync(STATS_CACHE_PATH),
600
+ click: () => this.openStatsWindow()
601
+ },
602
+ { type: 'separator' },
603
+ ...this.buildWebSocketStatusMenu(),
604
+ {
605
+ label: `HTTP Server: localhost:${HTTP_PORT}`,
606
+ enabled: false
607
+ },
608
+ {
609
+ label: `Version: ${this.app.getVersion()}`,
610
+ enabled: false
611
+ },
612
+ { type: 'separator' },
613
+ {
614
+ label: 'Quit',
615
+ click: () => {
616
+ this.app.quit();
617
+ }
618
+ }
619
+ ];
620
+ }
621
+
622
+ updateMenu() {
623
+ if (!this.tray) return;
624
+ const contextMenu = Menu.buildFromTemplate(this.buildMenuTemplate());
625
+ this.tray.setContextMenu(contextMenu);
626
+ }
627
+
628
+ showContextMenu(sender) {
629
+ const contextMenu = Menu.buildFromTemplate(this.buildMenuTemplate());
630
+ const win = BrowserWindow.fromWebContents(sender);
631
+ if (win && !win.isDestroyed()) {
632
+ contextMenu.popup({ window: win });
633
+ } else {
634
+ // Fallback: popup without specific window
635
+ contextMenu.popup();
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Cleanup resources on app quit
641
+ */
642
+ cleanup() {
643
+ // Clear tray icon cache to free memory
644
+ trayIconCache.clear();
645
+ if (this.statsWindow && !this.statsWindow.isDestroyed()) {
646
+ this.statsWindow.close();
647
+ this.statsWindow = null;
648
+ }
649
+ if (this.tokenWindow && !this.tokenWindow.isDestroyed()) {
650
+ this.tokenWindow.close();
651
+ this.tokenWindow = null;
652
+ }
653
+ if (this.tray) {
654
+ this.tray.destroy();
655
+ this.tray = null;
656
+ }
657
+ }
658
+ }
659
+
660
+ module.exports = { TrayManager, createTrayIcon };