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
package/main.js
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VibeMon - Main Process Entry Point
|
|
3
|
+
*
|
|
4
|
+
* This file orchestrates the application by connecting modules:
|
|
5
|
+
* - StateManager: State and timer management (per-project timers)
|
|
6
|
+
* - MultiWindowManager: Multi-window creation and management (one per project)
|
|
7
|
+
* - TrayManager: System tray icon and menu
|
|
8
|
+
* - HttpServer: HTTP API server
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Load environment variables from .env.local or .env
|
|
12
|
+
const path = require('path');
|
|
13
|
+
require('dotenv').config({ path: path.join(__dirname, '.env.local') });
|
|
14
|
+
require('dotenv').config({ path: path.join(__dirname, '.env') });
|
|
15
|
+
|
|
16
|
+
const { app, ipcMain, BrowserWindow, dialog } = require('electron');
|
|
17
|
+
const { exec } = require('child_process');
|
|
18
|
+
|
|
19
|
+
// Modules
|
|
20
|
+
const { StateManager } = require('./modules/state-manager.cjs');
|
|
21
|
+
const { MultiWindowManager } = require('./modules/multi-window-manager.cjs');
|
|
22
|
+
const { TrayManager } = require('./modules/tray-manager.cjs');
|
|
23
|
+
const { HttpServer } = require('./modules/http-server.cjs');
|
|
24
|
+
const { WsClient } = require('./modules/ws-client.cjs');
|
|
25
|
+
const { validateStatusPayload } = require('./modules/validators.cjs');
|
|
26
|
+
const { MAX_WINDOWS } = require('./shared/config.cjs');
|
|
27
|
+
|
|
28
|
+
// Single instance lock - prevent duplicate instances
|
|
29
|
+
const gotTheLock = app.requestSingleInstanceLock();
|
|
30
|
+
|
|
31
|
+
if (!gotTheLock) {
|
|
32
|
+
// Another instance is already running, quit immediately
|
|
33
|
+
console.log('Another instance is already running. Exiting...');
|
|
34
|
+
app.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Initialize managers
|
|
38
|
+
const stateManager = new StateManager();
|
|
39
|
+
const windowManager = new MultiWindowManager();
|
|
40
|
+
let trayManager = null;
|
|
41
|
+
let httpServer = null;
|
|
42
|
+
let wsClient = null;
|
|
43
|
+
|
|
44
|
+
// Handle second instance launch attempt
|
|
45
|
+
app.on('second-instance', () => {
|
|
46
|
+
// Focus the first window if available
|
|
47
|
+
const first = windowManager.getFirstWindow();
|
|
48
|
+
if (first && !first.isDestroyed()) {
|
|
49
|
+
if (first.isMinimized()) first.restore();
|
|
50
|
+
first.show();
|
|
51
|
+
first.focus();
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Set up state manager callbacks
|
|
56
|
+
stateManager.onStateTimeout = (projectId, newState) => {
|
|
57
|
+
// Merge with existing state to preserve project, model, memory, etc.
|
|
58
|
+
const existingState = windowManager.getState(projectId);
|
|
59
|
+
if (!existingState) return; // Window no longer exists
|
|
60
|
+
|
|
61
|
+
const stateData = { ...existingState, state: newState };
|
|
62
|
+
|
|
63
|
+
// updateState returns false if window doesn't exist (handles race condition)
|
|
64
|
+
if (!windowManager.updateState(projectId, stateData)) return;
|
|
65
|
+
|
|
66
|
+
windowManager.sendToWindow(projectId, 'state-update', stateData);
|
|
67
|
+
stateManager.setupStateTimeout(projectId, newState);
|
|
68
|
+
|
|
69
|
+
// Update always on top based on new state and rearrange windows
|
|
70
|
+
windowManager.updateAlwaysOnTopByState(projectId, newState);
|
|
71
|
+
windowManager.rearrangeWindows();
|
|
72
|
+
|
|
73
|
+
if (trayManager) {
|
|
74
|
+
trayManager.updateIcon();
|
|
75
|
+
trayManager.updateMenu();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
stateManager.onWindowCloseTimeout = (projectId) => {
|
|
80
|
+
windowManager.closeWindow(projectId);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Set up window manager callback for when windows are closed
|
|
84
|
+
windowManager.onWindowClosed = (projectId) => {
|
|
85
|
+
stateManager.cleanupProject(projectId);
|
|
86
|
+
if (trayManager) {
|
|
87
|
+
trayManager.updateMenu();
|
|
88
|
+
trayManager.updateIcon();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Handle status update from WebSocket
|
|
94
|
+
* Reuses the same logic as HTTP POST /status
|
|
95
|
+
*/
|
|
96
|
+
function handleWsStatusUpdate(data) {
|
|
97
|
+
// Validate payload
|
|
98
|
+
const validation = validateStatusPayload(data);
|
|
99
|
+
if (!validation.valid) {
|
|
100
|
+
console.error('WebSocket invalid payload:', validation.error);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Validate and normalize state data via stateManager
|
|
105
|
+
const stateValidation = stateManager.validateStateData(data);
|
|
106
|
+
if (!stateValidation.valid) {
|
|
107
|
+
console.error('WebSocket invalid state data:', stateValidation.error);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const stateData = stateValidation.data;
|
|
111
|
+
|
|
112
|
+
// Get projectId from data or use default
|
|
113
|
+
const projectId = stateData.project || 'default';
|
|
114
|
+
|
|
115
|
+
// Create window if not exists
|
|
116
|
+
if (!windowManager.getWindow(projectId)) {
|
|
117
|
+
const result = windowManager.createWindow(projectId);
|
|
118
|
+
|
|
119
|
+
// Blocked by lock in single mode
|
|
120
|
+
if (result.blocked) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// No window created (max limit in multi mode)
|
|
125
|
+
if (!result.window) {
|
|
126
|
+
console.log(`WebSocket: Max windows limit (${MAX_WINDOWS}) reached`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Project was switched in single mode
|
|
131
|
+
if (result.switchedProject) {
|
|
132
|
+
stateManager.cleanupProject(result.switchedProject);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Apply auto-lock after window is successfully created (single mode only)
|
|
137
|
+
windowManager.applyAutoLock(projectId, stateData.state);
|
|
138
|
+
|
|
139
|
+
// Update window state via windowManager (with change detection)
|
|
140
|
+
const updateResult = windowManager.updateState(projectId, stateData);
|
|
141
|
+
|
|
142
|
+
// No change - skip unnecessary updates
|
|
143
|
+
if (!updateResult.updated) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// State changed - full update (alwaysOnTop, rearrange, timeout, tray)
|
|
148
|
+
if (updateResult.stateChanged) {
|
|
149
|
+
windowManager.updateAlwaysOnTopByState(projectId, stateData.state);
|
|
150
|
+
windowManager.rearrangeWindows();
|
|
151
|
+
stateManager.setupStateTimeout(projectId, stateData.state);
|
|
152
|
+
|
|
153
|
+
if (trayManager) {
|
|
154
|
+
trayManager.updateIcon();
|
|
155
|
+
trayManager.updateMenu();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Send update to renderer
|
|
160
|
+
windowManager.sendToWindow(projectId, 'state-update', stateData);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// IPC handlers
|
|
164
|
+
ipcMain.handle('get-version', () => {
|
|
165
|
+
return app.getVersion();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
ipcMain.on('close-window', (event) => {
|
|
169
|
+
const win = BrowserWindow.fromWebContents(event.sender);
|
|
170
|
+
if (win) {
|
|
171
|
+
win.close();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
ipcMain.on('minimize-window', (event) => {
|
|
176
|
+
const win = BrowserWindow.fromWebContents(event.sender);
|
|
177
|
+
if (win) {
|
|
178
|
+
win.minimize();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
ipcMain.on('show-context-menu', (event) => {
|
|
183
|
+
if (trayManager) {
|
|
184
|
+
trayManager.showContextMenu(event.sender);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Focus terminal (iTerm2 or Ghostty on macOS)
|
|
189
|
+
ipcMain.handle('focus-terminal', async (event) => {
|
|
190
|
+
// Only supported on macOS
|
|
191
|
+
if (process.platform !== 'darwin') {
|
|
192
|
+
return { success: false, reason: 'not-macos' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Get project ID from the window that sent the request
|
|
196
|
+
const projectId = windowManager.getProjectIdByWebContents(event.sender);
|
|
197
|
+
if (!projectId) {
|
|
198
|
+
return { success: false, reason: 'no-project' };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Get terminal ID for this project
|
|
202
|
+
const terminalId = windowManager.getTerminalId(projectId);
|
|
203
|
+
if (!terminalId) {
|
|
204
|
+
return { success: false, reason: 'no-terminal-id' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Parse terminal type and ID (format: "iterm2:w0t4p0:UUID" or "ghostty:PID")
|
|
208
|
+
const parts = terminalId.split(':');
|
|
209
|
+
if (parts.length < 2) {
|
|
210
|
+
return { success: false, reason: 'invalid-terminal-id-format' };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const terminalType = parts[0];
|
|
214
|
+
|
|
215
|
+
if (terminalType === 'iterm2') {
|
|
216
|
+
// Extract UUID from terminal ID (format: iterm2:w0t4p0:UUID)
|
|
217
|
+
const uuid = parts.length === 3 ? parts[2] : parts[1];
|
|
218
|
+
if (!uuid) {
|
|
219
|
+
return { success: false, reason: 'invalid-terminal-id' };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Validate UUID format (8-4-4-4-12 hex) to prevent command injection
|
|
223
|
+
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
224
|
+
if (!UUID_REGEX.test(uuid)) {
|
|
225
|
+
return { success: false, reason: 'invalid-uuid-format' };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// AppleScript to activate iTerm2 and select the session
|
|
229
|
+
const script = `
|
|
230
|
+
tell application "iTerm2"
|
|
231
|
+
activate
|
|
232
|
+
repeat with aWindow in windows
|
|
233
|
+
repeat with aTab in tabs of aWindow
|
|
234
|
+
repeat with aSession in sessions of aTab
|
|
235
|
+
if unique ID of aSession is "${uuid}" then
|
|
236
|
+
select aTab
|
|
237
|
+
return "ok"
|
|
238
|
+
end if
|
|
239
|
+
end repeat
|
|
240
|
+
end repeat
|
|
241
|
+
end repeat
|
|
242
|
+
return "not-found"
|
|
243
|
+
end tell
|
|
244
|
+
`;
|
|
245
|
+
|
|
246
|
+
return new Promise((resolve) => {
|
|
247
|
+
exec(`osascript -e '${script.replace(/'/g, "'\\''")}'`, (error, stdout) => {
|
|
248
|
+
if (error) {
|
|
249
|
+
resolve({ success: false, reason: 'applescript-error', error: error.message });
|
|
250
|
+
} else {
|
|
251
|
+
const result = stdout.trim();
|
|
252
|
+
resolve({ success: result === 'ok', reason: result });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
} else if (terminalType === 'ghostty') {
|
|
257
|
+
// Extract PID from terminal ID (format: ghostty:PID)
|
|
258
|
+
const pid = parts[1];
|
|
259
|
+
if (!pid || !/^\d+$/.test(pid)) {
|
|
260
|
+
return { success: false, reason: 'invalid-ghostty-pid' };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// For Ghostty, we can only activate the application
|
|
264
|
+
// Ghostty doesn't expose session/PID information via AppleScript
|
|
265
|
+
// so we can't programmatically switch to a specific tab like iTerm2
|
|
266
|
+
const script = `
|
|
267
|
+
tell application "Ghostty"
|
|
268
|
+
activate
|
|
269
|
+
end tell
|
|
270
|
+
`;
|
|
271
|
+
|
|
272
|
+
return new Promise((resolve) => {
|
|
273
|
+
exec(`osascript -e '${script.replace(/'/g, "'\\''")}'`, (error, _stdout) => {
|
|
274
|
+
if (error) {
|
|
275
|
+
resolve({ success: false, reason: 'applescript-error', error: error.message });
|
|
276
|
+
} else {
|
|
277
|
+
// Successfully activated Ghostty app
|
|
278
|
+
// Note: User will need to manually navigate to the correct tab
|
|
279
|
+
resolve({ success: true, reason: 'activated', note: 'app-activated-only' });
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
} else {
|
|
284
|
+
return { success: false, reason: 'unsupported-terminal-type' };
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// App lifecycle
|
|
289
|
+
app.whenReady().then(() => {
|
|
290
|
+
// Create tray (windows are created on demand via HTTP /status endpoint)
|
|
291
|
+
trayManager = new TrayManager(windowManager, app);
|
|
292
|
+
trayManager.createTray();
|
|
293
|
+
|
|
294
|
+
// Start HTTP server
|
|
295
|
+
httpServer = new HttpServer(stateManager, windowManager, app);
|
|
296
|
+
httpServer.onStateUpdate = (menuOnly) => {
|
|
297
|
+
if (trayManager) {
|
|
298
|
+
if (!menuOnly) {
|
|
299
|
+
trayManager.updateIcon();
|
|
300
|
+
}
|
|
301
|
+
trayManager.updateMenu();
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
httpServer.onError = (err) => {
|
|
305
|
+
if (err.code === 'EADDRINUSE') {
|
|
306
|
+
dialog.showErrorBox(
|
|
307
|
+
'VibeMon - Port Conflict',
|
|
308
|
+
'Port 19280 is already in use.\nAnother instance may be running.\n\nThe app will continue but HTTP API won\'t work.'
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
httpServer.start();
|
|
313
|
+
|
|
314
|
+
// Start WebSocket client (if configured)
|
|
315
|
+
wsClient = new WsClient();
|
|
316
|
+
wsClient.onStatusUpdate = (data) => {
|
|
317
|
+
handleWsStatusUpdate(data);
|
|
318
|
+
};
|
|
319
|
+
wsClient.onConnectionChange = () => {
|
|
320
|
+
if (trayManager) {
|
|
321
|
+
trayManager.updateMenu();
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// Set wsClient reference in trayManager for status display
|
|
326
|
+
trayManager.setWsClient(wsClient);
|
|
327
|
+
|
|
328
|
+
wsClient.connect();
|
|
329
|
+
|
|
330
|
+
app.on('activate', () => {
|
|
331
|
+
const first = windowManager.getFirstWindow();
|
|
332
|
+
if (first && !first.isDestroyed()) {
|
|
333
|
+
first.show();
|
|
334
|
+
first.focus();
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
app.on('window-all-closed', () => {
|
|
340
|
+
// Keep app running in tray on macOS
|
|
341
|
+
if (process.platform !== 'darwin') {
|
|
342
|
+
app.quit();
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
app.on('before-quit', () => {
|
|
347
|
+
stateManager.cleanup();
|
|
348
|
+
windowManager.cleanup();
|
|
349
|
+
if (trayManager) {
|
|
350
|
+
trayManager.cleanup();
|
|
351
|
+
}
|
|
352
|
+
if (httpServer) {
|
|
353
|
+
httpServer.stop();
|
|
354
|
+
}
|
|
355
|
+
if (wsClient) {
|
|
356
|
+
wsClient.cleanup();
|
|
357
|
+
}
|
|
358
|
+
});
|