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/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
+ });