refactor: extract leaderboard and notification handlers from main/index.ts

This commit is contained in:
Raza Rauf
2026-01-21 23:04:30 +05:00
parent 233863aa7f
commit 8ef8bba615
6 changed files with 1683 additions and 781 deletions

View File

@@ -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<string, Function>;
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');
});
});
});

View File

@@ -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<typeof import('child_process')>();
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<string, Function>;
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);
});
});
});

View File

@@ -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<typeof import('child_process').spawn>; 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)

View File

@@ -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);
}

View File

@@ -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<MaestroSettings>;
}
// ==========================================================================
// 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<LeaderboardSubmitResponse> => {
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<AuthStatusResponse> => {
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<ResendConfirmationResponse> => {
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<LeaderboardGetResponse> => {
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<LongestRunsGetResponse> => {
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<LeaderboardSyncResponse> => {
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',
};
}
}
);
}

View File

@@ -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<number, ActiveTtsProcess>();
/** 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<TtsResponse> {
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<void> {
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<NotificationShowResponse> => {
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<TtsResponse> => {
// Add to queue and return a promise that resolves when this TTS completes
return new Promise<TtsResponse>((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<TtsResponse> => {
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;
}