From 8ef8bba61546d4e7adf474fc06600879c35a07d6 Mon Sep 17 00:00:00 2001 From: Raza Rauf Date: Wed, 21 Jan 2026 23:04:30 +0500 Subject: [PATCH] refactor: extract leaderboard and notification handlers from main/index.ts --- .../main/ipc/handlers/leaderboard.test.ts | 540 ++++++++++++ .../main/ipc/handlers/notifications.test.ts | 212 +++++ src/main/index.ts | 789 +----------------- src/main/ipc/handlers/index.ts | 12 + src/main/ipc/handlers/leaderboard.ts | 548 ++++++++++++ src/main/ipc/handlers/notifications.ts | 363 ++++++++ 6 files changed, 1683 insertions(+), 781 deletions(-) create mode 100644 src/__tests__/main/ipc/handlers/leaderboard.test.ts create mode 100644 src/__tests__/main/ipc/handlers/notifications.test.ts create mode 100644 src/main/ipc/handlers/leaderboard.ts create mode 100644 src/main/ipc/handlers/notifications.ts diff --git a/src/__tests__/main/ipc/handlers/leaderboard.test.ts b/src/__tests__/main/ipc/handlers/leaderboard.test.ts new file mode 100644 index 00000000..86cfee34 --- /dev/null +++ b/src/__tests__/main/ipc/handlers/leaderboard.test.ts @@ -0,0 +1,540 @@ +/** + * Tests for leaderboard IPC handlers + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ipcMain } from 'electron'; + +// Mock electron +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + }, +})); + +// Mock logger +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock global fetch +const mockFetch = vi.fn(); +global.fetch = mockFetch; + +import { registerLeaderboardHandlers } from '../../../../main/ipc/handlers/leaderboard'; + +describe('Leaderboard IPC Handlers', () => { + const mockApp = { + getVersion: vi.fn().mockReturnValue('1.0.0'), + }; + + const mockSettingsStore = { + get: vi.fn(), + set: vi.fn(), + }; + + let handlers: Map; + + beforeEach(() => { + vi.clearAllMocks(); + handlers = new Map(); + + // Capture registered handlers + vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Function) => { + handlers.set(channel, handler); + }); + + registerLeaderboardHandlers({ + app: mockApp as any, + settingsStore: mockSettingsStore as any, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('handler registration', () => { + it('should register all leaderboard handlers', () => { + expect(handlers.has('leaderboard:getInstallationId')).toBe(true); + expect(handlers.has('leaderboard:submit')).toBe(true); + expect(handlers.has('leaderboard:pollAuthStatus')).toBe(true); + expect(handlers.has('leaderboard:resendConfirmation')).toBe(true); + expect(handlers.has('leaderboard:get')).toBe(true); + expect(handlers.has('leaderboard:getLongestRuns')).toBe(true); + expect(handlers.has('leaderboard:sync')).toBe(true); + }); + }); + + describe('leaderboard:getInstallationId', () => { + it('should return installation ID from store', async () => { + mockSettingsStore.get.mockReturnValue('test-installation-id'); + + const handler = handlers.get('leaderboard:getInstallationId')!; + const result = await handler({}); + + expect(result).toBe('test-installation-id'); + expect(mockSettingsStore.get).toHaveBeenCalledWith('installationId'); + }); + + it('should return null if no installation ID exists', async () => { + mockSettingsStore.get.mockReturnValue(undefined); + + const handler = handlers.get('leaderboard:getInstallationId')!; + const result = await handler({}); + + expect(result).toBeNull(); + }); + }); + + describe('leaderboard:submit', () => { + const mockSubmitData = { + email: 'test@example.com', + displayName: 'Test User', + badgeLevel: 1, + badgeName: 'Bronze', + cumulativeTimeMs: 10000, + totalRuns: 5, + }; + + it('should submit leaderboard entry successfully', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + success: true, + message: 'Submission received', + ranking: { + cumulative: { rank: 10, total: 100, previousRank: 11, improved: true }, + longestRun: null, + }, + }), + }); + + const handler = handlers.get('leaderboard:submit')!; + const result = await handler({}, mockSubmitData); + + expect(result.success).toBe(true); + expect(result.message).toBe('Submission received'); + expect(result.ranking).toBeDefined(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://runmaestro.ai/api/m4estr0/submit', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }) + ); + }); + + it('should handle 401 auth required response', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + json: () => + Promise.resolve({ + message: 'Authentication required', + error: 'Auth token required', + }), + }); + + const handler = handlers.get('leaderboard:submit')!; + const result = await handler({}, mockSubmitData); + + expect(result.success).toBe(false); + expect(result.authTokenRequired).toBe(true); + }); + + it('should handle server errors', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + json: () => + Promise.resolve({ + message: 'Server error', + }), + }); + + const handler = handlers.get('leaderboard:submit')!; + const result = await handler({}, mockSubmitData); + + expect(result.success).toBe(false); + expect(result.error).toContain('Server error'); + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const handler = handlers.get('leaderboard:submit')!; + const result = await handler({}, mockSubmitData); + + expect(result.success).toBe(false); + expect(result.message).toBe('Failed to connect to leaderboard server'); + expect(result.error).toBe('Network error'); + }); + + it('should auto-inject installation ID if not provided', async () => { + mockSettingsStore.get.mockReturnValue('auto-injected-id'); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true, message: 'OK' }), + }); + + const handler = handlers.get('leaderboard:submit')!; + await handler({}, mockSubmitData); + + const fetchCall = mockFetch.mock.calls[0]; + const body = JSON.parse(fetchCall[1].body); + expect(body.installId).toBe('auto-injected-id'); + }); + }); + + describe('leaderboard:pollAuthStatus', () => { + it('should return confirmed status with auth token', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + status: 'confirmed', + authToken: 'new-auth-token', + }), + }); + + const handler = handlers.get('leaderboard:pollAuthStatus')!; + const result = await handler({}, 'client-token'); + + expect(result.status).toBe('confirmed'); + expect(result.authToken).toBe('new-auth-token'); + }); + + it('should return pending status', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + status: 'pending', + }), + }); + + const handler = handlers.get('leaderboard:pollAuthStatus')!; + const result = await handler({}, 'client-token'); + + expect(result.status).toBe('pending'); + }); + + it('should handle server errors', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + json: () => + Promise.resolve({ + message: 'Server error', + }), + }); + + const handler = handlers.get('leaderboard:pollAuthStatus')!; + const result = await handler({}, 'client-token'); + + expect(result.status).toBe('error'); + expect(result.error).toContain('Server error'); + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const handler = handlers.get('leaderboard:pollAuthStatus')!; + const result = await handler({}, 'client-token'); + + expect(result.status).toBe('error'); + expect(result.error).toBe('Network error'); + }); + }); + + describe('leaderboard:resendConfirmation', () => { + it('should successfully resend confirmation', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + success: true, + message: 'Email sent', + }), + }); + + const handler = handlers.get('leaderboard:resendConfirmation')!; + const result = await handler({}, { email: 'test@example.com', clientToken: 'token' }); + + expect(result.success).toBe(true); + expect(result.message).toBe('Email sent'); + }); + + it('should handle failure', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + json: () => + Promise.resolve({ + success: false, + error: 'Invalid email', + }), + }); + + const handler = handlers.get('leaderboard:resendConfirmation')!; + const result = await handler({}, { email: 'test@example.com', clientToken: 'token' }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid email'); + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const handler = handlers.get('leaderboard:resendConfirmation')!; + const result = await handler({}, { email: 'test@example.com', clientToken: 'token' }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network error'); + }); + }); + + describe('leaderboard:get', () => { + it('should fetch leaderboard entries', async () => { + const mockEntries = [ + { rank: 1, displayName: 'User 1', badgeLevel: 5, cumulativeTimeMs: 100000 }, + { rank: 2, displayName: 'User 2', badgeLevel: 4, cumulativeTimeMs: 90000 }, + ]; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ entries: mockEntries }), + }); + + const handler = handlers.get('leaderboard:get')!; + const result = await handler({}, { limit: 10 }); + + expect(result.success).toBe(true); + expect(result.entries).toEqual(mockEntries); + expect(mockFetch).toHaveBeenCalledWith( + 'https://runmaestro.ai/api/leaderboard?limit=10', + expect.any(Object) + ); + }); + + it('should use default limit of 50', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ entries: [] }), + }); + + const handler = handlers.get('leaderboard:get')!; + await handler({}); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://runmaestro.ai/api/leaderboard?limit=50', + expect.any(Object) + ); + }); + + it('should handle server errors', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + }); + + const handler = handlers.get('leaderboard:get')!; + const result = await handler({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('Server error'); + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const handler = handlers.get('leaderboard:get')!; + const result = await handler({}); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network error'); + }); + }); + + describe('leaderboard:getLongestRuns', () => { + it('should fetch longest runs leaderboard', async () => { + const mockEntries = [ + { rank: 1, displayName: 'User 1', longestRunMs: 50000, runDate: '2024-01-01' }, + ]; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ entries: mockEntries }), + }); + + const handler = handlers.get('leaderboard:getLongestRuns')!; + const result = await handler({}, { limit: 10 }); + + expect(result.success).toBe(true); + expect(result.entries).toEqual(mockEntries); + expect(mockFetch).toHaveBeenCalledWith( + 'https://runmaestro.ai/api/longest-runs?limit=10', + expect.any(Object) + ); + }); + + it('should use default limit of 50', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ entries: [] }), + }); + + const handler = handlers.get('leaderboard:getLongestRuns')!; + await handler({}); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://runmaestro.ai/api/longest-runs?limit=50', + expect.any(Object) + ); + }); + + it('should handle server errors', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + }); + + const handler = handlers.get('leaderboard:getLongestRuns')!; + const result = await handler({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('Server error'); + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const handler = handlers.get('leaderboard:getLongestRuns')!; + const result = await handler({}); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network error'); + }); + }); + + describe('leaderboard:sync', () => { + it('should sync user stats successfully', async () => { + const mockData = { + displayName: 'Test User', + badgeLevel: 3, + badgeName: 'Gold', + cumulativeTimeMs: 50000, + totalRuns: 25, + longestRunMs: 5000, + longestRunDate: '2024-01-01', + keyboardLevel: 2, + coveragePercent: 75, + ranking: { + cumulative: { rank: 5, total: 100 }, + longestRun: { rank: 10, total: 100 }, + }, + }; + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ success: true, found: true, data: mockData }), + }); + + const handler = handlers.get('leaderboard:sync')!; + const result = await handler({}, { email: 'test@example.com', authToken: 'token' }); + + expect(result.success).toBe(true); + expect(result.found).toBe(true); + expect(result.data).toEqual(mockData); + }); + + it('should handle user not found', async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + success: true, + found: false, + message: 'No existing registration', + }), + }); + + const handler = handlers.get('leaderboard:sync')!; + const result = await handler({}, { email: 'test@example.com', authToken: 'token' }); + + expect(result.success).toBe(true); + expect(result.found).toBe(false); + }); + + it('should handle invalid token (401)', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + json: () => + Promise.resolve({ + error: 'Invalid token', + }), + }); + + const handler = handlers.get('leaderboard:sync')!; + const result = await handler({}, { email: 'test@example.com', authToken: 'bad-token' }); + + expect(result.success).toBe(false); + expect(result.errorCode).toBe('INVALID_TOKEN'); + }); + + it('should handle email not confirmed (403)', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 403, + json: () => + Promise.resolve({ + error: 'Email not confirmed', + }), + }); + + const handler = handlers.get('leaderboard:sync')!; + const result = await handler({}, { email: 'test@example.com', authToken: 'token' }); + + expect(result.success).toBe(false); + expect(result.errorCode).toBe('EMAIL_NOT_CONFIRMED'); + }); + + it('should handle missing fields (400)', async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 400, + json: () => + Promise.resolve({ + error: 'Missing email', + }), + }); + + const handler = handlers.get('leaderboard:sync')!; + const result = await handler({}, { email: '', authToken: 'token' }); + + expect(result.success).toBe(false); + expect(result.errorCode).toBe('MISSING_FIELDS'); + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const handler = handlers.get('leaderboard:sync')!; + const result = await handler({}, { email: 'test@example.com', authToken: 'token' }); + + expect(result.success).toBe(false); + expect(result.found).toBe(false); + expect(result.error).toBe('Network error'); + }); + }); +}); diff --git a/src/__tests__/main/ipc/handlers/notifications.test.ts b/src/__tests__/main/ipc/handlers/notifications.test.ts new file mode 100644 index 00000000..f0d24379 --- /dev/null +++ b/src/__tests__/main/ipc/handlers/notifications.test.ts @@ -0,0 +1,212 @@ +/** + * Tests for notification IPC handlers + * + * Note: TTS-related tests are simplified due to the complexity of mocking + * child_process spawn with all the event listeners and stdin handling. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ipcMain } from 'electron'; + +// Create hoisted mocks for more reliable mocking +const mocks = vi.hoisted(() => ({ + mockNotificationShow: vi.fn(), + mockNotificationIsSupported: vi.fn().mockReturnValue(true), +})); + +// Mock electron with a proper class for Notification +vi.mock('electron', () => { + // Create a proper class for Notification + class MockNotification { + constructor(_options: { title: string; body: string; silent?: boolean }) { + // Store options if needed for assertions + } + show() { + mocks.mockNotificationShow(); + } + static isSupported() { + return mocks.mockNotificationIsSupported(); + } + } + + return { + ipcMain: { + handle: vi.fn(), + }, + Notification: MockNotification, + BrowserWindow: { + getAllWindows: vi.fn().mockReturnValue([]), + }, + }; +}); + +// Mock logger +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock child_process - must include default export +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + + const mockProcess = { + stdin: { + write: vi.fn((_data: string, _encoding: string, cb?: () => void) => { + if (cb) cb(); + }), + end: vi.fn(), + on: vi.fn(), + }, + stderr: { + on: vi.fn(), + }, + on: vi.fn(), + kill: vi.fn(), + }; + + const mockSpawn = vi.fn(() => mockProcess); + + return { + ...actual, + default: { + ...actual, + spawn: mockSpawn, + }, + spawn: mockSpawn, + }; +}); + +import { + registerNotificationsHandlers, + resetTtsState, + getTtsQueueLength, + getActiveTtsCount, + clearTtsQueue, +} from '../../../../main/ipc/handlers/notifications'; + +describe('Notification IPC Handlers', () => { + let handlers: Map; + + beforeEach(() => { + vi.clearAllMocks(); + resetTtsState(); + handlers = new Map(); + + // Reset mocks + mocks.mockNotificationIsSupported.mockReturnValue(true); + mocks.mockNotificationShow.mockClear(); + + // Capture registered handlers + vi.mocked(ipcMain.handle).mockImplementation((channel: string, handler: Function) => { + handlers.set(channel, handler); + }); + + registerNotificationsHandlers(); + }); + + afterEach(() => { + vi.clearAllMocks(); + resetTtsState(); + }); + + describe('handler registration', () => { + it('should register all notification handlers', () => { + expect(handlers.has('notification:show')).toBe(true); + expect(handlers.has('notification:speak')).toBe(true); + expect(handlers.has('notification:stopSpeak')).toBe(true); + }); + }); + + describe('notification:show', () => { + it('should show OS notification when supported', async () => { + mocks.mockNotificationIsSupported.mockReturnValue(true); + + const handler = handlers.get('notification:show')!; + const result = await handler({}, 'Test Title', 'Test Body'); + + expect(result.success).toBe(true); + expect(mocks.mockNotificationShow).toHaveBeenCalled(); + }); + + it('should return error when notifications not supported', async () => { + mocks.mockNotificationIsSupported.mockReturnValue(false); + + const handler = handlers.get('notification:show')!; + const result = await handler({}, 'Test Title', 'Test Body'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Notifications not supported'); + }); + + it('should handle empty strings', async () => { + const handler = handlers.get('notification:show')!; + const result = await handler({}, '', ''); + + expect(result.success).toBe(true); + expect(mocks.mockNotificationShow).toHaveBeenCalled(); + }); + + it('should handle special characters', async () => { + const handler = handlers.get('notification:show')!; + const result = await handler({}, 'Title with "quotes"', "Body with 'apostrophes' & symbols"); + + expect(result.success).toBe(true); + }); + + it('should handle unicode', async () => { + const handler = handlers.get('notification:show')!; + const result = await handler({}, '通知タイトル', '通知本文 🎉'); + + expect(result.success).toBe(true); + }); + + it('should handle exceptions gracefully', async () => { + // Make mockNotificationShow throw an error + mocks.mockNotificationShow.mockImplementation(() => { + throw new Error('Notification failed'); + }); + + const handler = handlers.get('notification:show')!; + const result = await handler({}, 'Test Title', 'Test Body'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Error: Notification failed'); + }); + }); + + describe('notification:stopSpeak', () => { + it('should return error when no active TTS process', async () => { + const handler = handlers.get('notification:stopSpeak')!; + const result = await handler({}, 999); + + expect(result.success).toBe(false); + expect(result.error).toBe('No active TTS process with that ID'); + }); + }); + + describe('TTS state utilities', () => { + it('should track TTS queue length', () => { + expect(getTtsQueueLength()).toBe(0); + }); + + it('should track active TTS count', () => { + expect(getActiveTtsCount()).toBe(0); + }); + + it('should clear TTS queue', () => { + clearTtsQueue(); + expect(getTtsQueueLength()).toBe(0); + }); + + it('should reset TTS state', () => { + resetTtsState(); + expect(getTtsQueueLength()).toBe(0); + expect(getActiveTtsCount()).toBe(0); + }); + }); +}); diff --git a/src/main/index.ts b/src/main/index.ts index 3949c996..d67b08d2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -47,6 +47,8 @@ import { registerFilesystemHandlers, registerAttachmentsHandlers, registerWebHandlers, + registerLeaderboardHandlers, + registerNotificationsHandlers, setupLoggerEventForwarding, cleanupAllGroomingSessions, getActiveGroomingSessionCount, @@ -1148,792 +1150,17 @@ function setupIpcHandlers() { } ); - // Notification operations - ipcMain.handle('notification:show', async (_event, title: string, body: string) => { - try { - const { Notification } = await import('electron'); - if (Notification.isSupported()) { - const notification = new Notification({ - title, - body, - silent: true, // Don't play system sound - we have our own audio feedback option - }); - notification.show(); - logger.debug('Showed OS notification', 'Notification', { title, body }); - return { success: true }; - } else { - logger.warn('OS notifications not supported on this platform', 'Notification'); - return { success: false, error: 'Notifications not supported' }; - } - } catch (error) { - logger.error('Error showing notification', 'Notification', error); - return { success: false, error: String(error) }; - } - }); - - // Track active TTS processes by ID for stopping - const activeTtsProcesses = new Map< - number, - { process: ReturnType; command: string } - >(); - let ttsProcessIdCounter = 0; - - // TTS queue to prevent audio overlap - enforces minimum delay between TTS calls - const TTS_MIN_DELAY_MS = 15000; // 15 seconds between TTS calls - let lastTtsEndTime = 0; - const ttsQueue: Array<{ - text: string; - command?: string; - resolve: (result: { success: boolean; ttsId?: number; error?: string }) => void; - }> = []; - let isTtsProcessing = false; - - // Process the next item in the TTS queue - const processNextTts = async () => { - if (isTtsProcessing || ttsQueue.length === 0) return; - - isTtsProcessing = true; - const item = ttsQueue.shift()!; - - // Calculate delay needed to maintain minimum gap - const now = Date.now(); - const timeSinceLastTts = now - lastTtsEndTime; - const delayNeeded = Math.max(0, TTS_MIN_DELAY_MS - timeSinceLastTts); - - if (delayNeeded > 0) { - logger.debug(`TTS queue waiting ${delayNeeded}ms before next speech`, 'TTS'); - await new Promise((resolve) => setTimeout(resolve, delayNeeded)); - } - - // Execute the TTS - const result = await executeTts(item.text, item.command); - item.resolve(result); - - // Record when this TTS ended - lastTtsEndTime = Date.now(); - isTtsProcessing = false; - - // Process next item in queue - processNextTts(); - }; - - // Execute TTS - the actual implementation - // Returns a Promise that resolves when the TTS process completes (not just when it starts) - const executeTts = async ( - text: string, - command?: string - ): Promise<{ success: boolean; ttsId?: number; error?: string }> => { - console.log('[TTS Main] executeTts called, text length:', text?.length, 'command:', command); - - // Log the incoming request with full details for debugging - logger.info('TTS speak request received', 'TTS', { - command: command || '(default: say)', - textLength: text?.length || 0, - textPreview: text ? (text.length > 200 ? text.substring(0, 200) + '...' : text) : '(no text)', - }); - - try { - const { spawn } = await import('child_process'); - const fullCommand = command || 'say'; // Default to macOS 'say' command - console.log('[TTS Main] Using fullCommand:', fullCommand); - - // Log the full command being executed - logger.info('TTS executing command', 'TTS', { - command: fullCommand, - textLength: text?.length || 0, - }); - - // Spawn the TTS process with shell mode to support pipes and command chaining - const child = spawn(fullCommand, [], { - stdio: ['pipe', 'ignore', 'pipe'], // stdin: pipe, stdout: ignore, stderr: pipe for errors - shell: true, - }); - - // Generate a unique ID for this TTS process - const ttsId = ++ttsProcessIdCounter; - activeTtsProcesses.set(ttsId, { process: child, command: fullCommand }); - - // Return a Promise that resolves when the TTS process completes - return new Promise((resolve) => { - let resolved = false; - let stderrOutput = ''; - - // Write the text to stdin and close it - if (child.stdin) { - // Handle stdin errors (EPIPE if process terminates before write completes) - child.stdin.on('error', (err) => { - const errorCode = (err as NodeJS.ErrnoException).code; - if (errorCode === 'EPIPE') { - logger.debug('TTS stdin EPIPE - process closed before write completed', 'TTS'); - } else { - logger.error('TTS stdin error', 'TTS', { error: String(err), code: errorCode }); - } - }); - console.log('[TTS Main] Writing to stdin:', text); - child.stdin.write(text, 'utf8', (err) => { - if (err) { - console.error('[TTS Main] stdin write error:', err); - } else { - console.log('[TTS Main] stdin write completed, ending stream'); - } - child.stdin!.end(); - }); - } else { - console.error('[TTS Main] No stdin available on child process'); - } - - child.on('error', (err) => { - console.error('[TTS Main] Spawn error:', err); - logger.error('TTS spawn error', 'TTS', { - error: String(err), - command: fullCommand, - textPreview: text - ? text.length > 100 - ? text.substring(0, 100) + '...' - : text - : '(no text)', - }); - activeTtsProcesses.delete(ttsId); - if (!resolved) { - resolved = true; - resolve({ success: false, ttsId, error: String(err) }); - } - }); - - // Capture stderr for debugging - if (child.stderr) { - child.stderr.on('data', (data) => { - stderrOutput += data.toString(); - }); - } - - child.on('close', (code, signal) => { - console.log('[TTS Main] Process exited with code:', code, 'signal:', signal); - // Always log close event for debugging production issues - logger.info('TTS process closed', 'TTS', { - ttsId, - exitCode: code, - signal, - stderr: stderrOutput || '(none)', - command: fullCommand, - }); - if (code !== 0 && stderrOutput) { - console.error('[TTS Main] stderr:', stderrOutput); - logger.error('TTS process error output', 'TTS', { - exitCode: code, - stderr: stderrOutput, - command: fullCommand, - }); - } - activeTtsProcesses.delete(ttsId); - // Notify renderer that TTS has completed - BrowserWindow.getAllWindows().forEach((win) => { - win.webContents.send('tts:completed', ttsId); - }); - - // Resolve the promise now that TTS has completed - if (!resolved) { - resolved = true; - resolve({ success: code === 0, ttsId }); - } - }); - - console.log('[TTS Main] Process spawned successfully with ID:', ttsId); - logger.info('TTS process spawned successfully', 'TTS', { - ttsId, - command: fullCommand, - textLength: text?.length || 0, - }); - }); - } catch (error) { - console.error('[TTS Main] Error starting audio feedback:', error); - logger.error('TTS error starting audio feedback', 'TTS', { - error: String(error), - command: command || '(default: say)', - textPreview: text - ? text.length > 100 - ? text.substring(0, 100) + '...' - : text - : '(no text)', - }); - return { success: false, error: String(error) }; - } - }; - - // Audio feedback using system TTS command - queued to prevent overlap - ipcMain.handle('notification:speak', async (_event, text: string, command?: string) => { - // Add to queue and return a promise that resolves when this TTS completes - return new Promise<{ success: boolean; ttsId?: number; error?: string }>((resolve) => { - ttsQueue.push({ text, command, resolve }); - logger.debug(`TTS queued, queue length: ${ttsQueue.length}`, 'TTS'); - processNextTts(); - }); - }); - - // Stop a running TTS process - ipcMain.handle('notification:stopSpeak', async (_event, ttsId: number) => { - console.log('[TTS Main] notification:stopSpeak called for ID:', ttsId); - - const ttsProcess = activeTtsProcesses.get(ttsId); - if (!ttsProcess) { - console.log('[TTS Main] No active TTS process found with ID:', ttsId); - return { success: false, error: 'No active TTS process with that ID' }; - } - - try { - // Kill the process and all its children - ttsProcess.process.kill('SIGTERM'); - activeTtsProcesses.delete(ttsId); - - logger.info('TTS process stopped', 'TTS', { - ttsId, - command: ttsProcess.command, - }); - - console.log('[TTS Main] TTS process killed successfully'); - return { success: true }; - } catch (error) { - console.error('[TTS Main] Error stopping TTS process:', error); - logger.error('TTS error stopping process', 'TTS', { - ttsId, - error: String(error), - }); - return { success: false, error: String(error) }; - } - }); + // Register notification handlers (extracted to handlers/notifications.ts) + registerNotificationsHandlers(); // Register attachments handlers (extracted to handlers/attachments.ts) registerAttachmentsHandlers({ app }); - // Auto Run operations - extracted to src/main/ipc/handlers/autorun.ts - - // Playbook operations - extracted to src/main/ipc/handlers/playbooks.ts - - // ========================================================================== - // Leaderboard API - // ========================================================================== - - // Get the unique installation ID for this Maestro installation - ipcMain.handle('leaderboard:getInstallationId', async () => { - return store.get('installationId') || null; + // Register leaderboard handlers (extracted to handlers/leaderboard.ts) + registerLeaderboardHandlers({ + app, + settingsStore: store, }); - - // Submit leaderboard entry to runmaestro.ai - ipcMain.handle( - 'leaderboard:submit', - async ( - _event, - data: { - email: string; - displayName: string; - githubUsername?: string; - twitterHandle?: string; - linkedinHandle?: string; - discordUsername?: string; - blueskyHandle?: string; - badgeLevel: number; - badgeName: string; - cumulativeTimeMs: number; - totalRuns: number; - longestRunMs?: number; - longestRunDate?: string; - currentRunMs?: number; // Duration in milliseconds of the run that just completed - theme?: string; - clientToken?: string; // Client-generated token for polling auth status - authToken?: string; // Required for confirmed email addresses - // Delta mode for multi-device aggregation - deltaMs?: number; // Time in milliseconds to ADD to server-side cumulative total - deltaRuns?: number; // Number of runs to ADD to server-side total runs count - // Installation tracking for multi-device differentiation - installationId?: string; // Unique GUID per Maestro installation - clientTotalTimeMs?: number; // Client's self-proclaimed total time (for discrepancy detection) - } - ): Promise<{ - success: boolean; - message: string; - pendingEmailConfirmation?: boolean; - error?: string; - authTokenRequired?: boolean; // True if 401 due to missing token - ranking?: { - cumulative: { - rank: number; - total: number; - previousRank: number | null; - improved: boolean; - }; - longestRun: { - rank: number; - total: number; - previousRank: number | null; - improved: boolean; - } | null; - }; - // Server-side totals for multi-device sync - serverTotals?: { - cumulativeTimeMs: number; - totalRuns: number; - }; - }> => { - try { - // Auto-inject installation ID if not provided - const installationId = data.installationId || store.get('installationId') || undefined; - - logger.info('Submitting leaderboard entry', 'Leaderboard', { - displayName: data.displayName, - email: data.email.substring(0, 3) + '***', - badgeLevel: data.badgeLevel, - hasClientToken: !!data.clientToken, - hasAuthToken: !!data.authToken, - hasInstallationId: !!installationId, - hasClientTotalTime: !!data.clientTotalTimeMs, - }); - - // Prepare submission data with server-expected field names - // Server expects 'installId' not 'installationId' - const submissionData = { - ...data, - installId: installationId, // Map to server field name - }; - - const response = await fetch('https://runmaestro.ai/api/m4estr0/submit', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': `Maestro/${app.getVersion()}`, - }, - body: JSON.stringify(submissionData), - }); - - const result = (await response.json()) as { - success?: boolean; - message?: string; - pendingEmailConfirmation?: boolean; - error?: string; - ranking?: { - cumulative: { - rank: number; - total: number; - previousRank: number | null; - improved: boolean; - }; - longestRun: { - rank: number; - total: number; - previousRank: number | null; - improved: boolean; - } | null; - }; - // Server-side totals for multi-device sync - serverTotals?: { - cumulativeTimeMs: number; - totalRuns: number; - }; - }; - - if (response.ok) { - logger.info('Leaderboard submission successful', 'Leaderboard', { - pendingEmailConfirmation: result.pendingEmailConfirmation, - ranking: result.ranking, - serverTotals: result.serverTotals, - }); - return { - success: true, - message: result.message || 'Submission received', - pendingEmailConfirmation: result.pendingEmailConfirmation, - ranking: result.ranking, - serverTotals: result.serverTotals, - }; - } else if (response.status === 401) { - // Auth token required or invalid - logger.warn('Leaderboard submission requires auth token', 'Leaderboard', { - error: result.error || result.message, - }); - return { - success: false, - message: result.message || 'Authentication required', - error: result.error || 'Auth token required for confirmed email addresses', - authTokenRequired: true, - }; - } else { - logger.warn('Leaderboard submission failed', 'Leaderboard', { - status: response.status, - error: result.error || result.message, - }); - return { - success: false, - message: result.message || 'Submission failed', - error: result.error || `Server error: ${response.status}`, - }; - } - } catch (error) { - logger.error('Error submitting to leaderboard', 'Leaderboard', error); - return { - success: false, - message: 'Failed to connect to leaderboard server', - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - ); - - // Poll for auth token after email confirmation - ipcMain.handle( - 'leaderboard:pollAuthStatus', - async ( - _event, - clientToken: string - ): Promise<{ - status: 'pending' | 'confirmed' | 'expired' | 'error'; - authToken?: string; - message?: string; - error?: string; - }> => { - try { - logger.debug('Polling leaderboard auth status', 'Leaderboard'); - - const response = await fetch( - `https://runmaestro.ai/api/m4estr0/auth-status?clientToken=${encodeURIComponent(clientToken)}`, - { - headers: { - 'User-Agent': `Maestro/${app.getVersion()}`, - }, - } - ); - - const result = (await response.json()) as { - status: 'pending' | 'confirmed' | 'expired'; - authToken?: string; - message?: string; - }; - - if (response.ok) { - if (result.status === 'confirmed' && result.authToken) { - logger.info('Leaderboard auth token received', 'Leaderboard'); - } - return { - status: result.status, - authToken: result.authToken, - message: result.message, - }; - } else { - return { - status: 'error', - error: result.message || `Server error: ${response.status}`, - }; - } - } catch (error) { - logger.error('Error polling leaderboard auth status', 'Leaderboard', error); - return { - status: 'error', - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - ); - - // Resend confirmation email (self-service auth token recovery) - ipcMain.handle( - 'leaderboard:resendConfirmation', - async ( - _event, - data: { - email: string; - clientToken: string; - } - ): Promise<{ - success: boolean; - message?: string; - error?: string; - }> => { - try { - logger.info('Requesting leaderboard confirmation resend', 'Leaderboard', { - email: data.email.substring(0, 3) + '***', - }); - - const response = await fetch('https://runmaestro.ai/api/m4estr0/resend-confirmation', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': `Maestro/${app.getVersion()}`, - }, - body: JSON.stringify({ - email: data.email, - clientToken: data.clientToken, - }), - }); - - const result = (await response.json()) as { - success?: boolean; - message?: string; - error?: string; - }; - - if (response.ok && result.success) { - logger.info('Leaderboard confirmation email resent', 'Leaderboard'); - return { - success: true, - message: result.message || 'Confirmation email sent. Please check your inbox.', - }; - } else { - return { - success: false, - error: result.error || result.message || `Server error: ${response.status}`, - }; - } - } catch (error) { - logger.error('Error resending leaderboard confirmation', 'Leaderboard', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - ); - - // Get leaderboard entries - ipcMain.handle( - 'leaderboard:get', - async ( - _event, - options?: { limit?: number } - ): Promise<{ - success: boolean; - entries?: Array<{ - rank: number; - displayName: string; - githubUsername?: string; - avatarUrl?: string; - badgeLevel: number; - badgeName: string; - cumulativeTimeMs: number; - totalRuns: number; - }>; - error?: string; - }> => { - try { - const limit = options?.limit || 50; - const response = await fetch(`https://runmaestro.ai/api/leaderboard?limit=${limit}`, { - headers: { - 'User-Agent': `Maestro/${app.getVersion()}`, - }, - }); - - if (response.ok) { - const data = (await response.json()) as { entries?: unknown[] }; - return { - success: true, - entries: data.entries as Array<{ - rank: number; - displayName: string; - githubUsername?: string; - avatarUrl?: string; - badgeLevel: number; - badgeName: string; - cumulativeTimeMs: number; - totalRuns: number; - }>, - }; - } else { - return { - success: false, - error: `Server error: ${response.status}`, - }; - } - } catch (error) { - logger.error('Error fetching leaderboard', 'Leaderboard', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - ); - - // Get longest runs leaderboard - ipcMain.handle( - 'leaderboard:getLongestRuns', - async ( - _event, - options?: { limit?: number } - ): Promise<{ - success: boolean; - entries?: Array<{ - rank: number; - displayName: string; - githubUsername?: string; - avatarUrl?: string; - longestRunMs: number; - runDate: string; - }>; - error?: string; - }> => { - try { - const limit = options?.limit || 50; - const response = await fetch(`https://runmaestro.ai/api/longest-runs?limit=${limit}`, { - headers: { - 'User-Agent': `Maestro/${app.getVersion()}`, - }, - }); - - if (response.ok) { - const data = (await response.json()) as { entries?: unknown[] }; - return { - success: true, - entries: data.entries as Array<{ - rank: number; - displayName: string; - githubUsername?: string; - avatarUrl?: string; - longestRunMs: number; - runDate: string; - }>, - }; - } else { - return { - success: false, - error: `Server error: ${response.status}`, - }; - } - } catch (error) { - logger.error('Error fetching longest runs leaderboard', 'Leaderboard', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - ); - - // Sync user stats from server (for new device installations) - ipcMain.handle( - 'leaderboard:sync', - async ( - _event, - data: { - email: string; - authToken: string; - } - ): Promise<{ - success: boolean; - found: boolean; - message?: string; - error?: string; - errorCode?: 'EMAIL_NOT_CONFIRMED' | 'INVALID_TOKEN' | 'MISSING_FIELDS'; - data?: { - displayName: string; - badgeLevel: number; - badgeName: string; - cumulativeTimeMs: number; - totalRuns: number; - longestRunMs: number | null; - longestRunDate: string | null; - keyboardLevel: number | null; - coveragePercent: number | null; - ranking: { - cumulative: { rank: number; total: number }; - longestRun: { rank: number; total: number } | null; - }; - }; - }> => { - try { - logger.info('Syncing leaderboard stats from server', 'Leaderboard', { - email: data.email.substring(0, 3) + '***', - }); - - const response = await fetch('https://runmaestro.ai/api/m4estr0/sync', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': `Maestro/${app.getVersion()}`, - }, - body: JSON.stringify({ - email: data.email, - authToken: data.authToken, - }), - }); - - const result = (await response.json()) as { - success: boolean; - found?: boolean; - message?: string; - error?: string; - errorCode?: string; - data?: { - displayName: string; - badgeLevel: number; - badgeName: string; - cumulativeTimeMs: number; - totalRuns: number; - longestRunMs: number | null; - longestRunDate: string | null; - keyboardLevel: number | null; - coveragePercent: number | null; - ranking: { - cumulative: { rank: number; total: number }; - longestRun: { rank: number; total: number } | null; - }; - }; - }; - - if (response.ok && result.success) { - if (result.found && result.data) { - logger.info('Leaderboard sync successful', 'Leaderboard', { - badgeLevel: result.data.badgeLevel, - cumulativeTimeMs: result.data.cumulativeTimeMs, - }); - return { - success: true, - found: true, - data: result.data, - }; - } else { - logger.info('Leaderboard sync: user not found', 'Leaderboard'); - return { - success: true, - found: false, - message: result.message || 'No existing registration found', - }; - } - } else if (response.status === 401) { - logger.warn('Leaderboard sync: invalid token', 'Leaderboard'); - return { - success: false, - found: false, - error: result.error || 'Invalid authentication token', - errorCode: 'INVALID_TOKEN', - }; - } else if (response.status === 403) { - logger.warn('Leaderboard sync: email not confirmed', 'Leaderboard'); - return { - success: false, - found: false, - error: result.error || 'Email not yet confirmed', - errorCode: 'EMAIL_NOT_CONFIRMED', - }; - } else if (response.status === 400) { - return { - success: false, - found: false, - error: result.error || 'Missing required fields', - errorCode: 'MISSING_FIELDS', - }; - } else { - return { - success: false, - found: false, - error: result.error || `Server error: ${response.status}`, - }; - } - } catch (error) { - logger.error('Error syncing from leaderboard server', 'Leaderboard', error); - return { - success: false, - found: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - } - ); } // Buffer for group chat output (keyed by sessionId) diff --git a/src/main/ipc/handlers/index.ts b/src/main/ipc/handlers/index.ts index 678cc77c..f06b442b 100644 --- a/src/main/ipc/handlers/index.ts +++ b/src/main/ipc/handlers/index.ts @@ -46,6 +46,8 @@ import { registerSshRemoteHandlers, SshRemoteHandlerDependencies } from './ssh-r import { registerFilesystemHandlers } from './filesystem'; import { registerAttachmentsHandlers, AttachmentsHandlerDependencies } from './attachments'; import { registerWebHandlers, WebHandlerDependencies } from './web'; +import { registerLeaderboardHandlers, LeaderboardHandlerDependencies } from './leaderboard'; +import { registerNotificationsHandlers } from './notifications'; import { AgentDetector } from '../../agent-detector'; import { ProcessManager } from '../../process-manager'; import { WebServer } from '../../web-server'; @@ -80,6 +82,9 @@ export { registerAttachmentsHandlers }; export type { AttachmentsHandlerDependencies }; export { registerWebHandlers }; export type { WebHandlerDependencies }; +export { registerLeaderboardHandlers }; +export type { LeaderboardHandlerDependencies }; +export { registerNotificationsHandlers }; export type { AgentsHandlerDependencies }; export type { ProcessHandlerDependencies }; export type { PersistenceHandlerDependencies }; @@ -234,6 +239,13 @@ export function registerAllHandlers(deps: HandlerDependencies): void { registerAttachmentsHandlers({ app: deps.app, }); + // Register leaderboard handlers + registerLeaderboardHandlers({ + app: deps.app, + settingsStore: deps.settingsStore, + }); + // Register notification handlers (OS notifications and TTS) + registerNotificationsHandlers(); // Setup logger event forwarding to renderer setupLoggerEventForwarding(deps.getMainWindow); } diff --git a/src/main/ipc/handlers/leaderboard.ts b/src/main/ipc/handlers/leaderboard.ts new file mode 100644 index 00000000..54477846 --- /dev/null +++ b/src/main/ipc/handlers/leaderboard.ts @@ -0,0 +1,548 @@ +/** + * Leaderboard IPC Handlers + * + * Handles all leaderboard-related IPC operations: + * - Getting installation ID + * - Submitting leaderboard entries + * - Polling auth status after email confirmation + * - Resending confirmation emails + * - Fetching leaderboard data + * - Syncing stats from server + */ + +import { ipcMain, App } from 'electron'; +import Store from 'electron-store'; +import { logger } from '../../utils/logger'; +import type { MaestroSettings } from './persistence'; + +// ========================================================================== +// Constants +// ========================================================================== + +const LEADERBOARD_API_BASE = 'https://runmaestro.ai/api'; +const M4ESTR0_API_BASE = 'https://runmaestro.ai/api/m4estr0'; + +// ========================================================================== +// Types +// ========================================================================== + +/** + * Data submitted when creating/updating a leaderboard entry + */ +export interface LeaderboardSubmitData { + email: string; + displayName: string; + githubUsername?: string; + twitterHandle?: string; + linkedinHandle?: string; + discordUsername?: string; + blueskyHandle?: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; + longestRunMs?: number; + longestRunDate?: string; + currentRunMs?: number; + theme?: string; + clientToken?: string; + authToken?: string; + // Delta mode for multi-device aggregation + deltaMs?: number; + deltaRuns?: number; + // Installation tracking for multi-device differentiation + installationId?: string; + clientTotalTimeMs?: number; +} + +/** + * Response from leaderboard submission + */ +export interface LeaderboardSubmitResponse { + success: boolean; + message: string; + pendingEmailConfirmation?: boolean; + error?: string; + authTokenRequired?: boolean; + ranking?: { + cumulative: { + rank: number; + total: number; + previousRank: number | null; + improved: boolean; + }; + longestRun: { + rank: number; + total: number; + previousRank: number | null; + improved: boolean; + } | null; + }; + serverTotals?: { + cumulativeTimeMs: number; + totalRuns: number; + }; +} + +/** + * Response from polling auth status + */ +export interface AuthStatusResponse { + status: 'pending' | 'confirmed' | 'expired' | 'error'; + authToken?: string; + message?: string; + error?: string; +} + +/** + * Response from resending confirmation + */ +export interface ResendConfirmationResponse { + success: boolean; + message?: string; + error?: string; +} + +/** + * Leaderboard entry for cumulative time rankings + */ +export interface LeaderboardEntry { + rank: number; + displayName: string; + githubUsername?: string; + avatarUrl?: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; +} + +/** + * Leaderboard entry for longest run rankings + */ +export interface LongestRunEntry { + rank: number; + displayName: string; + githubUsername?: string; + avatarUrl?: string; + longestRunMs: number; + runDate: string; +} + +/** + * Response from fetching leaderboard + */ +export interface LeaderboardGetResponse { + success: boolean; + entries?: LeaderboardEntry[]; + error?: string; +} + +/** + * Response from fetching longest runs leaderboard + */ +export interface LongestRunsGetResponse { + success: boolean; + entries?: LongestRunEntry[]; + error?: string; +} + +/** + * Response from syncing stats + */ +export interface LeaderboardSyncResponse { + success: boolean; + found: boolean; + message?: string; + error?: string; + errorCode?: 'EMAIL_NOT_CONFIRMED' | 'INVALID_TOKEN' | 'MISSING_FIELDS'; + data?: { + displayName: string; + badgeLevel: number; + badgeName: string; + cumulativeTimeMs: number; + totalRuns: number; + longestRunMs: number | null; + longestRunDate: string | null; + keyboardLevel: number | null; + coveragePercent: number | null; + ranking: { + cumulative: { rank: number; total: number }; + longestRun: { rank: number; total: number } | null; + }; + }; +} + +/** + * Dependencies required for leaderboard handler registration + */ +export interface LeaderboardHandlerDependencies { + app: App; + settingsStore: Store; +} + +// ========================================================================== +// Handler Registration +// ========================================================================== + +/** + * Register all leaderboard-related IPC handlers + */ +export function registerLeaderboardHandlers(deps: LeaderboardHandlerDependencies): void { + const { app, settingsStore } = deps; + + // Get the unique installation ID for this Maestro installation + ipcMain.handle('leaderboard:getInstallationId', async () => { + return settingsStore.get('installationId') || null; + }); + + // Submit leaderboard entry to runmaestro.ai + ipcMain.handle( + 'leaderboard:submit', + async (_event, data: LeaderboardSubmitData): Promise => { + try { + // Auto-inject installation ID if not provided + const installationId = + data.installationId || settingsStore.get('installationId') || undefined; + + logger.info('Submitting leaderboard entry', 'Leaderboard', { + displayName: data.displayName, + email: data.email.substring(0, 3) + '***', + badgeLevel: data.badgeLevel, + hasClientToken: !!data.clientToken, + hasAuthToken: !!data.authToken, + hasInstallationId: !!installationId, + hasClientTotalTime: !!data.clientTotalTimeMs, + }); + + // Prepare submission data with server-expected field names + // Server expects 'installId' not 'installationId' + const submissionData = { + ...data, + installId: installationId, + }; + + const response = await fetch(`${M4ESTR0_API_BASE}/submit`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': `Maestro/${app.getVersion()}`, + }, + body: JSON.stringify(submissionData), + }); + + const result = (await response.json()) as { + success?: boolean; + message?: string; + pendingEmailConfirmation?: boolean; + error?: string; + ranking?: LeaderboardSubmitResponse['ranking']; + serverTotals?: LeaderboardSubmitResponse['serverTotals']; + }; + + if (response.ok) { + logger.info('Leaderboard submission successful', 'Leaderboard', { + pendingEmailConfirmation: result.pendingEmailConfirmation, + ranking: result.ranking, + serverTotals: result.serverTotals, + }); + return { + success: true, + message: result.message || 'Submission received', + pendingEmailConfirmation: result.pendingEmailConfirmation, + ranking: result.ranking, + serverTotals: result.serverTotals, + }; + } else if (response.status === 401) { + // Auth token required or invalid + logger.warn('Leaderboard submission requires auth token', 'Leaderboard', { + error: result.error || result.message, + }); + return { + success: false, + message: result.message || 'Authentication required', + error: result.error || 'Auth token required for confirmed email addresses', + authTokenRequired: true, + }; + } else { + logger.warn('Leaderboard submission failed', 'Leaderboard', { + status: response.status, + error: result.error || result.message, + }); + return { + success: false, + message: result.message || 'Submission failed', + error: result.error || `Server error: ${response.status}`, + }; + } + } catch (error) { + logger.error('Error submitting to leaderboard', 'Leaderboard', error); + return { + success: false, + message: 'Failed to connect to leaderboard server', + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); + + // Poll for auth token after email confirmation + ipcMain.handle( + 'leaderboard:pollAuthStatus', + async (_event, clientToken: string): Promise => { + try { + logger.debug('Polling leaderboard auth status', 'Leaderboard'); + + const response = await fetch( + `${M4ESTR0_API_BASE}/auth-status?clientToken=${encodeURIComponent(clientToken)}`, + { + headers: { + 'User-Agent': `Maestro/${app.getVersion()}`, + }, + } + ); + + const result = (await response.json()) as { + status: 'pending' | 'confirmed' | 'expired'; + authToken?: string; + message?: string; + }; + + if (response.ok) { + if (result.status === 'confirmed' && result.authToken) { + logger.info('Leaderboard auth token received', 'Leaderboard'); + } + return { + status: result.status, + authToken: result.authToken, + message: result.message, + }; + } else { + return { + status: 'error', + error: result.message || `Server error: ${response.status}`, + }; + } + } catch (error) { + logger.error('Error polling leaderboard auth status', 'Leaderboard', error); + return { + status: 'error', + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); + + // Resend confirmation email (self-service auth token recovery) + ipcMain.handle( + 'leaderboard:resendConfirmation', + async ( + _event, + data: { email: string; clientToken: string } + ): Promise => { + try { + logger.info('Requesting leaderboard confirmation resend', 'Leaderboard', { + email: data.email.substring(0, 3) + '***', + }); + + const response = await fetch(`${M4ESTR0_API_BASE}/resend-confirmation`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': `Maestro/${app.getVersion()}`, + }, + body: JSON.stringify({ + email: data.email, + clientToken: data.clientToken, + }), + }); + + const result = (await response.json()) as { + success?: boolean; + message?: string; + error?: string; + }; + + if (response.ok && result.success) { + logger.info('Leaderboard confirmation email resent', 'Leaderboard'); + return { + success: true, + message: result.message || 'Confirmation email sent. Please check your inbox.', + }; + } else { + return { + success: false, + error: result.error || result.message || `Server error: ${response.status}`, + }; + } + } catch (error) { + logger.error('Error resending leaderboard confirmation', 'Leaderboard', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); + + // Get leaderboard entries + ipcMain.handle( + 'leaderboard:get', + async (_event, options?: { limit?: number }): Promise => { + try { + const limit = options?.limit || 50; + const response = await fetch(`${LEADERBOARD_API_BASE}/leaderboard?limit=${limit}`, { + headers: { + 'User-Agent': `Maestro/${app.getVersion()}`, + }, + }); + + if (response.ok) { + const data = (await response.json()) as { entries?: LeaderboardEntry[] }; + return { + success: true, + entries: data.entries, + }; + } else { + return { + success: false, + error: `Server error: ${response.status}`, + }; + } + } catch (error) { + logger.error('Error fetching leaderboard', 'Leaderboard', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); + + // Get longest runs leaderboard + ipcMain.handle( + 'leaderboard:getLongestRuns', + async (_event, options?: { limit?: number }): Promise => { + try { + const limit = options?.limit || 50; + const response = await fetch(`${LEADERBOARD_API_BASE}/longest-runs?limit=${limit}`, { + headers: { + 'User-Agent': `Maestro/${app.getVersion()}`, + }, + }); + + if (response.ok) { + const data = (await response.json()) as { entries?: LongestRunEntry[] }; + return { + success: true, + entries: data.entries, + }; + } else { + return { + success: false, + error: `Server error: ${response.status}`, + }; + } + } catch (error) { + logger.error('Error fetching longest runs leaderboard', 'Leaderboard', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); + + // Sync user stats from server (for new device installations) + ipcMain.handle( + 'leaderboard:sync', + async ( + _event, + data: { email: string; authToken: string } + ): Promise => { + try { + logger.info('Syncing leaderboard stats from server', 'Leaderboard', { + email: data.email.substring(0, 3) + '***', + }); + + const response = await fetch(`${M4ESTR0_API_BASE}/sync`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': `Maestro/${app.getVersion()}`, + }, + body: JSON.stringify({ + email: data.email, + authToken: data.authToken, + }), + }); + + const result = (await response.json()) as { + success: boolean; + found?: boolean; + message?: string; + error?: string; + errorCode?: string; + data?: LeaderboardSyncResponse['data']; + }; + + if (response.ok && result.success) { + if (result.found && result.data) { + logger.info('Leaderboard sync successful', 'Leaderboard', { + badgeLevel: result.data.badgeLevel, + cumulativeTimeMs: result.data.cumulativeTimeMs, + }); + return { + success: true, + found: true, + data: result.data, + }; + } else { + logger.info('Leaderboard sync: user not found', 'Leaderboard'); + return { + success: true, + found: false, + message: result.message || 'No existing registration found', + }; + } + } else if (response.status === 401) { + logger.warn('Leaderboard sync: invalid token', 'Leaderboard'); + return { + success: false, + found: false, + error: result.error || 'Invalid authentication token', + errorCode: 'INVALID_TOKEN', + }; + } else if (response.status === 403) { + logger.warn('Leaderboard sync: email not confirmed', 'Leaderboard'); + return { + success: false, + found: false, + error: result.error || 'Email not yet confirmed', + errorCode: 'EMAIL_NOT_CONFIRMED', + }; + } else if (response.status === 400) { + return { + success: false, + found: false, + error: result.error || 'Missing required fields', + errorCode: 'MISSING_FIELDS', + }; + } else { + return { + success: false, + found: false, + error: result.error || `Server error: ${response.status}`, + }; + } + } catch (error) { + logger.error('Error syncing from leaderboard server', 'Leaderboard', error); + return { + success: false, + found: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + } + ); +} diff --git a/src/main/ipc/handlers/notifications.ts b/src/main/ipc/handlers/notifications.ts new file mode 100644 index 00000000..aa929b1b --- /dev/null +++ b/src/main/ipc/handlers/notifications.ts @@ -0,0 +1,363 @@ +/** + * Notification IPC Handlers + * + * Handles all notification-related IPC operations: + * - Showing OS notifications + * - Text-to-speech (TTS) functionality with queueing + * - Stopping active TTS processes + */ + +import { ipcMain, Notification, BrowserWindow } from 'electron'; +import { spawn, type ChildProcess } from 'child_process'; +import { logger } from '../../utils/logger'; + +// ========================================================================== +// Constants +// ========================================================================== + +/** Minimum delay between TTS calls to prevent audio overlap */ +const TTS_MIN_DELAY_MS = 15000; + +// ========================================================================== +// Types +// ========================================================================== + +/** + * Response from showing a notification + */ +export interface NotificationShowResponse { + success: boolean; + error?: string; +} + +/** + * Response from TTS operations + */ +export interface TtsResponse { + success: boolean; + ttsId?: number; + error?: string; +} + +/** + * Item in the TTS queue + */ +interface TtsQueueItem { + text: string; + command?: string; + resolve: (result: TtsResponse) => void; +} + +/** + * Active TTS process tracking + */ +interface ActiveTtsProcess { + process: ChildProcess; + command: string; +} + +// ========================================================================== +// Module State +// ========================================================================== + +/** Track active TTS processes by ID for stopping */ +const activeTtsProcesses = new Map(); + +/** Counter for generating unique TTS process IDs */ +let ttsProcessIdCounter = 0; + +/** Timestamp when the last TTS completed */ +let lastTtsEndTime = 0; + +/** Queue of pending TTS requests */ +const ttsQueue: TtsQueueItem[] = []; + +/** Flag indicating if TTS is currently being processed */ +let isTtsProcessing = false; + +// ========================================================================== +// Helper Functions +// ========================================================================== + +/** + * Execute TTS - the actual implementation + * Returns a Promise that resolves when the TTS process completes (not just when it starts) + */ +async function executeTts(text: string, command?: string): Promise { + console.log('[TTS Main] executeTts called, text length:', text?.length, 'command:', command); + + // Log the incoming request with full details for debugging + logger.info('TTS speak request received', 'TTS', { + command: command || '(default: say)', + textLength: text?.length || 0, + textPreview: text ? (text.length > 200 ? text.substring(0, 200) + '...' : text) : '(no text)', + }); + + try { + const fullCommand = command || 'say'; // Default to macOS 'say' command + console.log('[TTS Main] Using fullCommand:', fullCommand); + + // Log the full command being executed + logger.info('TTS executing command', 'TTS', { + command: fullCommand, + textLength: text?.length || 0, + }); + + // Spawn the TTS process with shell mode to support pipes and command chaining + const child = spawn(fullCommand, [], { + stdio: ['pipe', 'ignore', 'pipe'], // stdin: pipe, stdout: ignore, stderr: pipe for errors + shell: true, + }); + + // Generate a unique ID for this TTS process + const ttsId = ++ttsProcessIdCounter; + activeTtsProcesses.set(ttsId, { process: child, command: fullCommand }); + + // Return a Promise that resolves when the TTS process completes + return new Promise((resolve) => { + let resolved = false; + let stderrOutput = ''; + + // Write the text to stdin and close it + if (child.stdin) { + // Handle stdin errors (EPIPE if process terminates before write completes) + child.stdin.on('error', (err) => { + const errorCode = (err as NodeJS.ErrnoException).code; + if (errorCode === 'EPIPE') { + logger.debug('TTS stdin EPIPE - process closed before write completed', 'TTS'); + } else { + logger.error('TTS stdin error', 'TTS', { error: String(err), code: errorCode }); + } + }); + console.log('[TTS Main] Writing to stdin:', text); + child.stdin.write(text, 'utf8', (err) => { + if (err) { + console.error('[TTS Main] stdin write error:', err); + } else { + console.log('[TTS Main] stdin write completed, ending stream'); + } + child.stdin!.end(); + }); + } else { + console.error('[TTS Main] No stdin available on child process'); + } + + child.on('error', (err) => { + console.error('[TTS Main] Spawn error:', err); + logger.error('TTS spawn error', 'TTS', { + error: String(err), + command: fullCommand, + textPreview: text + ? text.length > 100 + ? text.substring(0, 100) + '...' + : text + : '(no text)', + }); + activeTtsProcesses.delete(ttsId); + if (!resolved) { + resolved = true; + resolve({ success: false, ttsId, error: String(err) }); + } + }); + + // Capture stderr for debugging + if (child.stderr) { + child.stderr.on('data', (data) => { + stderrOutput += data.toString(); + }); + } + + child.on('close', (code, signal) => { + console.log('[TTS Main] Process exited with code:', code, 'signal:', signal); + // Always log close event for debugging production issues + logger.info('TTS process closed', 'TTS', { + ttsId, + exitCode: code, + signal, + stderr: stderrOutput || '(none)', + command: fullCommand, + }); + if (code !== 0 && stderrOutput) { + console.error('[TTS Main] stderr:', stderrOutput); + logger.error('TTS process error output', 'TTS', { + exitCode: code, + stderr: stderrOutput, + command: fullCommand, + }); + } + activeTtsProcesses.delete(ttsId); + // Notify renderer that TTS has completed + BrowserWindow.getAllWindows().forEach((win) => { + win.webContents.send('tts:completed', ttsId); + }); + + // Resolve the promise now that TTS has completed + if (!resolved) { + resolved = true; + resolve({ success: code === 0, ttsId }); + } + }); + + console.log('[TTS Main] Process spawned successfully with ID:', ttsId); + logger.info('TTS process spawned successfully', 'TTS', { + ttsId, + command: fullCommand, + textLength: text?.length || 0, + }); + }); + } catch (error) { + console.error('[TTS Main] Error starting audio feedback:', error); + logger.error('TTS error starting audio feedback', 'TTS', { + error: String(error), + command: command || '(default: say)', + textPreview: text ? (text.length > 100 ? text.substring(0, 100) + '...' : text) : '(no text)', + }); + return { success: false, error: String(error) }; + } +} + +/** + * Process the next item in the TTS queue + */ +async function processNextTts(): Promise { + if (isTtsProcessing || ttsQueue.length === 0) return; + + isTtsProcessing = true; + const item = ttsQueue.shift()!; + + // Calculate delay needed to maintain minimum gap + const now = Date.now(); + const timeSinceLastTts = now - lastTtsEndTime; + const delayNeeded = Math.max(0, TTS_MIN_DELAY_MS - timeSinceLastTts); + + if (delayNeeded > 0) { + logger.debug(`TTS queue waiting ${delayNeeded}ms before next speech`, 'TTS'); + await new Promise((resolve) => setTimeout(resolve, delayNeeded)); + } + + // Execute the TTS + const result = await executeTts(item.text, item.command); + item.resolve(result); + + // Record when this TTS ended + lastTtsEndTime = Date.now(); + isTtsProcessing = false; + + // Process next item in queue + processNextTts(); +} + +// ========================================================================== +// Handler Registration +// ========================================================================== + +/** + * Register all notification-related IPC handlers + */ +export function registerNotificationsHandlers(): void { + // Show OS notification + ipcMain.handle( + 'notification:show', + async (_event, title: string, body: string): Promise => { + try { + if (Notification.isSupported()) { + const notification = new Notification({ + title, + body, + silent: true, // Don't play system sound - we have our own audio feedback option + }); + notification.show(); + logger.debug('Showed OS notification', 'Notification', { title, body }); + return { success: true }; + } else { + logger.warn('OS notifications not supported on this platform', 'Notification'); + return { success: false, error: 'Notifications not supported' }; + } + } catch (error) { + logger.error('Error showing notification', 'Notification', error); + return { success: false, error: String(error) }; + } + } + ); + + // Audio feedback using system TTS command - queued to prevent overlap + ipcMain.handle( + 'notification:speak', + async (_event, text: string, command?: string): Promise => { + // Add to queue and return a promise that resolves when this TTS completes + return new Promise((resolve) => { + ttsQueue.push({ text, command, resolve }); + logger.debug(`TTS queued, queue length: ${ttsQueue.length}`, 'TTS'); + processNextTts(); + }); + } + ); + + // Stop a running TTS process + ipcMain.handle('notification:stopSpeak', async (_event, ttsId: number): Promise => { + console.log('[TTS Main] notification:stopSpeak called for ID:', ttsId); + + const ttsProcess = activeTtsProcesses.get(ttsId); + if (!ttsProcess) { + console.log('[TTS Main] No active TTS process found with ID:', ttsId); + return { success: false, error: 'No active TTS process with that ID' }; + } + + try { + // Kill the process and all its children + ttsProcess.process.kill('SIGTERM'); + activeTtsProcesses.delete(ttsId); + + logger.info('TTS process stopped', 'TTS', { + ttsId, + command: ttsProcess.command, + }); + + console.log('[TTS Main] TTS process killed successfully'); + return { success: true }; + } catch (error) { + console.error('[TTS Main] Error stopping TTS process:', error); + logger.error('TTS error stopping process', 'TTS', { + ttsId, + error: String(error), + }); + return { success: false, error: String(error) }; + } + }); +} + +// ========================================================================== +// Exports for Testing +// ========================================================================== + +/** + * Get the current TTS queue length (for testing) + */ +export function getTtsQueueLength(): number { + return ttsQueue.length; +} + +/** + * Get the count of active TTS processes (for testing) + */ +export function getActiveTtsCount(): number { + return activeTtsProcesses.size; +} + +/** + * Clear the TTS queue (for testing) + */ +export function clearTtsQueue(): void { + ttsQueue.length = 0; +} + +/** + * Reset TTS state (for testing) + */ +export function resetTtsState(): void { + ttsQueue.length = 0; + activeTtsProcesses.clear(); + ttsProcessIdCounter = 0; + lastTtsEndTime = 0; + isTtsProcessing = false; +}