mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
refactor: extract leaderboard and notification handlers from main/index.ts
This commit is contained in:
540
src/__tests__/main/ipc/handlers/leaderboard.test.ts
Normal file
540
src/__tests__/main/ipc/handlers/leaderboard.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
212
src/__tests__/main/ipc/handlers/notifications.test.ts
Normal file
212
src/__tests__/main/ipc/handlers/notifications.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
548
src/main/ipc/handlers/leaderboard.ts
Normal file
548
src/main/ipc/handlers/leaderboard.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
363
src/main/ipc/handlers/notifications.ts
Normal file
363
src/main/ipc/handlers/notifications.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user