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.
- package/README.md +40 -0
- package/assets/characters/apto.png +0 -0
- package/assets/characters/claw.png +0 -0
- package/assets/characters/clawd.png +0 -0
- package/assets/characters/kiro.png +0 -0
- package/assets/generators/generate-icons.js +86 -0
- package/assets/generators/icon-128.png +0 -0
- package/assets/generators/icon-16.png +0 -0
- package/assets/generators/icon-256.png +0 -0
- package/assets/generators/icon-32.png +0 -0
- package/assets/generators/icon-64.png +0 -0
- package/assets/generators/icon-generator.html +221 -0
- package/assets/icon.icns +0 -0
- package/assets/icon.ico +0 -0
- package/assets/icon.png +0 -0
- package/bin/cli.js +16 -0
- package/index.html +26 -0
- package/main.js +358 -0
- package/modules/http-server.cjs +584 -0
- package/modules/http-utils.cjs +110 -0
- package/modules/multi-window-manager.cjs +927 -0
- package/modules/state-manager.cjs +168 -0
- package/modules/tray-manager.cjs +660 -0
- package/modules/validators.cjs +180 -0
- package/modules/ws-client.cjs +313 -0
- package/package.json +112 -0
- package/preload.js +22 -0
- package/renderer.js +84 -0
- package/shared/config.cjs +64 -0
- package/shared/constants.cjs +8 -0
- package/shared/data/constants.json +86 -0
- package/stats.html +521 -0
- package/styles.css +90 -0
|
@@ -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 };
|