chore: formatting fixes

This commit is contained in:
haritabh-z01 2025-06-28 11:02:07 +05:30
parent dd6af5e879
commit 119a46c339
167 changed files with 4507 additions and 3248 deletions

View file

@ -1,38 +1,46 @@
import dotenv from 'dotenv';
import dotenv from "dotenv";
dotenv.config();
import log from 'electron-log';
import { app } from 'electron';
import path from 'node:path';
import log from "electron-log";
import { app } from "electron";
import path from "node:path";
// Configure electron-log immediately when module is imported
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
// Configure main logger - check for LOG_LEVEL override
const envLogLevel = process.env.LOG_LEVEL as 'error' | 'warn' | 'info' | 'debug' | undefined;
const defaultFileLevel: 'debug' | 'info' = isDev ? 'debug' : 'info';
const defaultConsoleLevel: 'debug' | 'warn' = isDev ? 'debug' : 'warn';
const envLogLevel = process.env.LOG_LEVEL as
| "error"
| "warn"
| "info"
| "debug"
| undefined;
const defaultFileLevel: "debug" | "info" = isDev ? "debug" : "info";
const defaultConsoleLevel: "debug" | "warn" = isDev ? "debug" : "warn";
log.transports.file.level = envLogLevel || defaultFileLevel;
log.transports.console.level = envLogLevel || defaultConsoleLevel;
// Configure file transport
log.transports.file.maxSize = 10 * 1024 * 1024; // 10MB
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}';
log.transports.file.format =
"[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}";
// Set custom log file path
const logPath = isDev
? path.join(app.getPath('userData'), 'logs', 'amical-dev.log')
: path.join(app.getPath('logs'), 'amical.log');
? path.join(app.getPath("userData"), "logs", "amical-dev.log")
: path.join(app.getPath("logs"), "amical.log");
log.transports.file.resolvePathFn = () => logPath;
// Configure console transport for better development experience
if (isDev) {
log.transports.console.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}';
log.transports.console.format =
"[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}";
log.transports.console.useStyles = true;
} else {
log.transports.console.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}';
log.transports.console.format =
"[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] [{scope}] {text}";
log.transports.console.useStyles = false;
}
@ -48,28 +56,28 @@ if (!isDev) {
// -----------------------------------------------
// `LOG_DEBUG_SCOPES` can be a comma-separated list of scope names (main,ai,swift)
// or regex patterns wrapped in slashes (e.g. /ai.*/, /.*/)
const rawDebugScopes = (process.env.LOG_DEBUG_SCOPES ?? '')
.split(',')
const rawDebugScopes = (process.env.LOG_DEBUG_SCOPES ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// Utility: escape regex special chars for exact-match tokens
function escapeRegExp(str: string) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
const debugScopePatterns: RegExp[] = rawDebugScopes.map((token) => {
if (token.startsWith('/') && token.endsWith('/') && token.length > 1) {
if (token.startsWith("/") && token.endsWith("/") && token.length > 1) {
// Regex pattern (strip the leading & trailing slashes)
const pattern = token.slice(1, -1);
try {
return new RegExp(pattern, 'i');
return new RegExp(pattern, "i");
} catch {
// Fall through to exact match if regex is invalid
}
}
// Treat as exact scope name
return new RegExp(`^${escapeRegExp(token)}$`, 'i');
return new RegExp(`^${escapeRegExp(token)}$`, "i");
});
export function isScopeDebug(scope: string): boolean {
@ -80,7 +88,7 @@ export function isScopeDebug(scope: string): boolean {
if (debugScopePatterns.length > 0) {
log.hooks.push((message) => {
// Only filter debug messages
if (message.level !== 'debug') return message;
if (message.level !== "debug") return message;
// Check if this scope should have debug enabled
const scope = message.scope;
@ -102,24 +110,24 @@ function createLoggerForScope(scope: string) {
// Create scoped loggers for different modules
export const logger = {
main: createLoggerForScope('main'),
ipc: createLoggerForScope('ipc'),
renderer: createLoggerForScope('renderer'),
network: createLoggerForScope('network'),
audio: createLoggerForScope('audio'),
ai: createLoggerForScope('ai'),
swift: createLoggerForScope('swift'),
ui: createLoggerForScope('ui'),
db: createLoggerForScope('db'),
updater: createLoggerForScope('updater'),
main: createLoggerForScope("main"),
ipc: createLoggerForScope("ipc"),
renderer: createLoggerForScope("renderer"),
network: createLoggerForScope("network"),
audio: createLoggerForScope("audio"),
ai: createLoggerForScope("ai"),
swift: createLoggerForScope("swift"),
ui: createLoggerForScope("ui"),
db: createLoggerForScope("db"),
updater: createLoggerForScope("updater"),
};
// Log startup information
logger.main.info('Logger initialized', {
logger.main.info("Logger initialized", {
isDev,
fileLogLevel: log.transports.file.level,
consoleLogLevel: log.transports.console.level,
envLogLevel: envLogLevel || 'not set',
envLogLevel: envLogLevel || "not set",
logPath,
version: app.getVersion(),
platform: process.platform,
@ -135,7 +143,11 @@ export function createScopedLogger(scope: string) {
}
// Error handling utilities
export function logError(error: Error, context?: string, metadata?: Record<string, any>) {
export function logError(
error: Error,
context?: string,
metadata?: Record<string, any>,
) {
const errorInfo = {
message: error.message,
stack: error.stack,
@ -144,13 +156,13 @@ export function logError(error: Error, context?: string, metadata?: Record<strin
...metadata,
};
logger?.main.error('Error occurred:', errorInfo);
logger?.main.error("Error occurred:", errorInfo);
}
export function logPerformance(
operation: string,
startTime: number,
metadata?: Record<string, any>
metadata?: Record<string, any>,
) {
const duration = Date.now() - startTime;
logger?.main.info(`Performance: ${operation}`, {
@ -161,7 +173,7 @@ export function logPerformance(
// Development helpers
export function logDebugInfo(component: string, data: any) {
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
if (process.env.NODE_ENV === "development" || !app.isPackaged) {
logger?.main.debug(`[${component}]`, data);
}
}

View file

@ -1,5 +1,5 @@
// Load .env file FIRST before any other imports
import dotenv from 'dotenv';
import dotenv from "dotenv";
dotenv.config();
import {
@ -10,26 +10,29 @@ import {
ipcMain,
screen,
clipboard,
} from 'electron';
import path from 'node:path';
import fsPromises from 'node:fs/promises'; // For reading the audio file (async)
import started from 'electron-squirrel-startup';
import { initializeDatabase } from '../db/config';
import { HelperEvent, KeyEventPayload } from '@amical/types';
import { logger, logError, logPerformance } from './logger';
import { AudioCapture } from '../modules/audio/audio-capture';
import { setupApplicationMenu } from './menu';
import { AiService } from '../modules/ai/ai-service';
import { SwiftIOBridge } from './swift-io-bridge'; // Added import
import { DownloadedModel } from '../constants/models';
import { ModelManagerService } from '../modules/models/model-manager';
import { LocalWhisperClient } from '../modules/ai/local-whisper-client';
import { TranscriptionSession, ChunkData } from '../modules/transcription/transcription-session';
import { ContextualTranscriptionManager } from '../modules/transcription/contextual-transcription-manager';
import { SettingsService } from '../modules/settings';
import { createIPCHandler } from 'electron-trpc-experimental/main';
import { router } from '../trpc/router';
import { AutoUpdaterService } from './services/auto-updater';
} from "electron";
import path from "node:path";
import fsPromises from "node:fs/promises"; // For reading the audio file (async)
import started from "electron-squirrel-startup";
import { initializeDatabase } from "../db/config";
import { HelperEvent, KeyEventPayload } from "@amical/types";
import { logger, logError, logPerformance } from "./logger";
import { AudioCapture } from "../modules/audio/audio-capture";
import { setupApplicationMenu } from "./menu";
import { AiService } from "../modules/ai/ai-service";
import { SwiftIOBridge } from "./swift-io-bridge"; // Added import
import { DownloadedModel } from "../constants/models";
import { ModelManagerService } from "../modules/models/model-manager";
import { LocalWhisperClient } from "../modules/ai/local-whisper-client";
import {
TranscriptionSession,
ChunkData,
} from "../modules/transcription/transcription-session";
import { ContextualTranscriptionManager } from "../modules/transcription/contextual-transcription-manager";
import { SettingsService } from "../modules/settings";
import { createIPCHandler } from "electron-trpc-experimental/main";
import { router } from "../trpc/router";
import { AutoUpdaterService } from "./services/auto-updater";
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
@ -51,17 +54,19 @@ let currentWindowDisplayId: number | null = null; // For tracking current displa
let activeSpaceChangeSubscriptionId: number | null = null; // For display change notifications
// New chunk-based transcription variables
let contextualTranscriptionManager: ContextualTranscriptionManager | null = null;
const activeTranscriptionSessions: Map<string, TranscriptionSession> = new Map();
let contextualTranscriptionManager: ContextualTranscriptionManager | null =
null;
const activeTranscriptionSessions: Map<string, TranscriptionSession> =
new Map();
let autoUpdaterService: AutoUpdaterService | null = null;
// Store is imported from '../lib/store' and is database-backed
// Function to create the local transcription client
const createTranscriptionClient = () => {
logger.ai.info('Using local Whisper inference');
logger.ai.info("Using local Whisper inference");
if (!localWhisperClient) {
throw new Error('Local Whisper client not initialized');
throw new Error("Local Whisper client not initialized");
}
return localWhisperClient;
};
@ -71,25 +76,32 @@ const createTranscriptionClient = () => {
const requestPermissions = async () => {
try {
// Request accessibility permissions
if (process.platform === 'darwin') {
const accessibilityEnabled = systemPreferences.isTrustedAccessibilityClient(false);
if (process.platform === "darwin") {
const accessibilityEnabled =
systemPreferences.isTrustedAccessibilityClient(false);
if (!accessibilityEnabled) {
// On macOS, we need to use a different approach for accessibility permissions
// The user will need to grant accessibility permissions through System Preferences
console.log(
'Please enable accessibility permissions in System Preferences > Security & Privacy > Privacy > Accessibility'
"Please enable accessibility permissions in System Preferences > Security & Privacy > Privacy > Accessibility",
);
}
}
// Request microphone permissions
const microphoneEnabled = systemPreferences.getMediaAccessStatus('microphone');
logger.main.info('Microphone access status:', { status: microphoneEnabled });
if (microphoneEnabled !== 'granted') {
await systemPreferences.askForMediaAccess('microphone');
const microphoneEnabled =
systemPreferences.getMediaAccessStatus("microphone");
logger.main.info("Microphone access status:", {
status: microphoneEnabled,
});
if (microphoneEnabled !== "granted") {
await systemPreferences.askForMediaAccess("microphone");
}
} catch (error) {
logError(error instanceof Error ? error : new Error(String(error)), 'requesting permissions');
logError(
error instanceof Error ? error : new Error(String(error)),
"requesting permissions",
);
}
};
@ -103,11 +115,11 @@ const createOrShowMainWindow = () => {
width: 1200,
height: 800,
frame: false,
titleBarStyle: 'hidden',
titleBarStyle: "hidden",
trafficLightPosition: { x: 20, y: 16 },
useContentSize: true,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
preload: path.join(__dirname, "preload.js"),
nodeIntegration: false,
contextIsolation: true,
},
@ -115,9 +127,11 @@ const createOrShowMainWindow = () => {
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
mainWindow.loadURL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
} else {
mainWindow.loadFile(path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`));
mainWindow.loadFile(
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`),
);
}
mainWindow.on('closed', () => {
mainWindow.on("closed", () => {
mainWindow = null;
if (autoUpdaterService) {
autoUpdaterService.setMainWindow(null);
@ -127,7 +141,9 @@ const createOrShowMainWindow = () => {
// Update tRPC handler to include the main window
createIPCHandler({
router,
windows: [mainWindow, floatingButtonWindow].filter(Boolean) as BrowserWindow[],
windows: [mainWindow, floatingButtonWindow].filter(
Boolean,
) as BrowserWindow[],
});
// Set main window reference for auto-updater
@ -152,7 +168,7 @@ const createFloatingButtonWindow = () => {
focusable: false,
hasShadow: false,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
preload: path.join(__dirname, "preload.js"),
nodeIntegration: false,
contextIsolation: true,
},
@ -163,18 +179,20 @@ const createFloatingButtonWindow = () => {
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
const devUrl = new URL(MAIN_WINDOW_VITE_DEV_SERVER_URL);
devUrl.pathname = 'fab.html';
devUrl.pathname = "fab.html";
floatingButtonWindow.loadURL(devUrl.toString());
} else {
floatingButtonWindow.loadFile(
path.join(__dirname, `../renderer/${WIDGET_WINDOW_VITE_NAME}/fab.html`)
path.join(__dirname, `../renderer/${WIDGET_WINDOW_VITE_NAME}/fab.html`),
);
}
// Set a higher level for macOS to stay on top of fullscreen apps
if (process.platform === 'darwin') {
floatingButtonWindow.setAlwaysOnTop(true, 'floating', 1);
floatingButtonWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
if (process.platform === "darwin") {
floatingButtonWindow.setAlwaysOnTop(true, "floating", 1);
floatingButtonWindow.setVisibleOnAllWorkspaces(true, {
visibleOnFullScreen: true,
});
floatingButtonWindow.setHiddenInMissionControl(true);
}
@ -184,13 +202,18 @@ const createFloatingButtonWindow = () => {
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', async () => {
app.on("ready", async () => {
// Initialize database and run migrations first
try {
await initializeDatabase();
logger.db.info('Database initialized and migrations completed successfully');
logger.db.info(
"Database initialized and migrations completed successfully",
);
} catch (error) {
logError(error instanceof Error ? error : new Error(String(error)), 'initializing database');
logError(
error instanceof Error ? error : new Error(String(error)),
"initializing database",
);
// You might want to handle this error differently, perhaps showing a dialog to the user
}
@ -203,7 +226,7 @@ app.on('ready', async () => {
windows: [floatingButtonWindow!],
});
if (process.platform === 'darwin' && app.dock) {
if (process.platform === "darwin" && app.dock) {
app.dock.show();
}
@ -223,11 +246,13 @@ app.on('ready', async () => {
(globalThis as any).logger = logger;
// Initialize Contextual Transcription Manager
contextualTranscriptionManager = new ContextualTranscriptionManager(modelManagerService);
contextualTranscriptionManager = new ContextualTranscriptionManager(
modelManagerService,
);
// Initialize Auto-Updater Service
autoUpdaterService = new AutoUpdaterService();
// Make auto-updater service available globally for tRPC
(globalThis as any).autoUpdaterService = autoUpdaterService;
@ -249,25 +274,28 @@ app.on('ready', async () => {
const formatterConfig = await settingsService.getFormatterConfig();
if (formatterConfig) {
aiService.configureFormatter(formatterConfig);
logger.ai.info('Formatter configured', {
logger.ai.info("Formatter configured", {
provider: formatterConfig.provider,
enabled: formatterConfig.enabled,
});
}
} catch (formatterError) {
logger.ai.warn('Failed to load formatter configuration:', formatterError);
logger.ai.warn("Failed to load formatter configuration:", formatterError);
}
logger.ai.info('AI Service initialized', {
client: 'Local Whisper',
logger.ai.info("AI Service initialized", {
client: "Local Whisper",
});
} catch (error) {
logError(error instanceof Error ? error : new Error(String(error)), 'initializing AI Service');
logger.ai.warn('Transcription will not work until configuration is fixed');
logError(
error instanceof Error ? error : new Error(String(error)),
"initializing AI Service",
);
logger.ai.warn("Transcription will not work until configuration is fixed");
aiService = null;
}
audioCapture.on('recording-finished', async (filePath: string) => {
audioCapture.on("recording-finished", async (filePath: string) => {
// Ensure AI service is available and up-to-date
if (!aiService) {
try {
@ -280,53 +308,60 @@ app.on('ready', async () => {
const formatterConfig = await settingsService.getFormatterConfig();
if (formatterConfig) {
aiService.configureFormatter(formatterConfig);
logger.ai.info('Formatter reconfigured', {
logger.ai.info("Formatter reconfigured", {
provider: formatterConfig.provider,
enabled: formatterConfig.enabled,
});
}
} catch (formatterError) {
logger.ai.warn('Failed to reload formatter configuration:', formatterError);
logger.ai.warn(
"Failed to reload formatter configuration:",
formatterError,
);
}
logger.ai.info('AI Service reinitialized', {
client: 'Local Whisper',
logger.ai.info("AI Service reinitialized", {
client: "Local Whisper",
});
} catch (error) {
logError(
error instanceof Error ? error : new Error(String(error)),
'reinitializing AI Service'
"reinitializing AI Service",
);
}
}
logger.audio.info('Recording finished', { filePath });
logger.audio.info("Recording finished", { filePath });
if (aiService) {
try {
const startTime = Date.now();
const audioBuffer = await fsPromises.readFile(filePath);
logger.audio.info('Audio file read', {
logger.audio.info("Audio file read", {
size: audioBuffer.length,
sizeKB: Math.round(audioBuffer.length / 1024),
});
const transcription = await aiService.transcribeAudio(audioBuffer);
logPerformance('audio transcription', startTime, {
logPerformance("audio transcription", startTime, {
audioSizeKB: Math.round(audioBuffer.length / 1024),
transcriptionLength: transcription?.length || 0,
});
logger.ai.info('Transcription completed', {
logger.ai.info("Transcription completed", {
resultLength: transcription?.length || 0,
hasResult: !!transcription,
});
// Copy transcription to clipboard
if (transcription && typeof transcription === 'string') {
logger.main.info('Transcription pasted to active application');
if (transcription && typeof transcription === "string") {
logger.main.info("Transcription pasted to active application");
// Attempt to paste into the active application
swiftIOBridgeClientInstance!.call('pasteText', { transcript: transcription });
swiftIOBridgeClientInstance!.call("pasteText", {
transcript: transcription,
});
} else {
logger.main.warn('Transcription result was empty or not a string, not copying');
logger.main.warn(
"Transcription result was empty or not a string, not copying",
);
}
// Optionally, delete the audio file after processing
@ -335,21 +370,21 @@ app.on('ready', async () => {
} catch (error) {
logError(
error instanceof Error ? error : new Error(String(error)),
'transcription or file handling'
"transcription or file handling",
);
}
} else {
logger.ai.warn('AI Service not available, cannot transcribe audio');
logger.ai.warn("AI Service not available, cannot transcribe audio");
}
});
audioCapture.on('recording-error', (error: Error) => {
console.error('Main: Received recording error from AudioCapture:', error);
audioCapture.on("recording-error", (error: Error) => {
console.error("Main: Received recording error from AudioCapture:", error);
});
// Handle individual audio chunks for real-time transcription
audioCapture.on('chunk-ready', async (chunkData: ChunkData) => {
logger.audio.info('Received chunk for transcription', {
audioCapture.on("chunk-ready", async (chunkData: ChunkData) => {
logger.audio.info("Received chunk for transcription", {
sessionId: chunkData.sessionId,
chunkId: chunkData.chunkId,
audioDataSize: chunkData.audioData.length,
@ -358,18 +393,27 @@ app.on('ready', async () => {
try {
// Get or create transcription session for this recording session
let transcriptionSession = activeTranscriptionSessions.get(chunkData.sessionId);
let transcriptionSession = activeTranscriptionSessions.get(
chunkData.sessionId,
);
if (!transcriptionSession) {
// Create new transcription session
const transcriptionClient = contextualTranscriptionManager!.createDefaultClient();
const transcriptionClient =
contextualTranscriptionManager!.createDefaultClient();
transcriptionSession = new TranscriptionSession(chunkData.sessionId, transcriptionClient);
activeTranscriptionSessions.set(chunkData.sessionId, transcriptionSession);
transcriptionSession = new TranscriptionSession(
chunkData.sessionId,
transcriptionClient,
);
activeTranscriptionSessions.set(
chunkData.sessionId,
transcriptionSession,
);
// Set up session event handlers
transcriptionSession.on('chunk-completed', (result) => {
logger.ai.info('Chunk transcription completed', {
transcriptionSession.on("chunk-completed", (result) => {
logger.ai.info("Chunk transcription completed", {
sessionId: chunkData.sessionId,
chunkId: result.chunkId,
textLength: result.text.length,
@ -377,8 +421,8 @@ app.on('ready', async () => {
});
});
transcriptionSession.on('session-completed', (sessionResult) => {
logger.ai.info('Transcription session completed', {
transcriptionSession.on("session-completed", (sessionResult) => {
logger.ai.info("Transcription session completed", {
sessionId: sessionResult.sessionId,
finalTextLength: sessionResult.finalText.length,
totalChunks: sessionResult.chunkResults.length,
@ -386,21 +430,29 @@ app.on('ready', async () => {
});
// Paste the final result to active application
if (sessionResult.finalText && sessionResult.finalText.trim().length > 0) {
logger.main.info('Final transcription pasted to active application', {
textLength: sessionResult.finalText.length,
if (
sessionResult.finalText &&
sessionResult.finalText.trim().length > 0
) {
logger.main.info(
"Final transcription pasted to active application",
{
textLength: sessionResult.finalText.length,
},
);
swiftIOBridgeClientInstance!.call("pasteText", {
transcript: sessionResult.finalText,
});
swiftIOBridgeClientInstance!.call('pasteText', { transcript: sessionResult.finalText });
} else {
logger.main.warn('Final transcription was empty, not pasting');
logger.main.warn("Final transcription was empty, not pasting");
}
// Clean up completed session
activeTranscriptionSessions.delete(chunkData.sessionId);
});
transcriptionSession.on('chunk-error', (errorInfo) => {
logger.ai.error('Chunk transcription error', {
transcriptionSession.on("chunk-error", (errorInfo) => {
logger.ai.error("Chunk transcription error", {
sessionId: chunkData.sessionId,
chunkId: errorInfo.chunkId,
error: errorInfo.error,
@ -408,13 +460,15 @@ app.on('ready', async () => {
// Continue processing other chunks even if one fails
});
logger.ai.info('Created new transcription session', { sessionId: chunkData.sessionId });
logger.ai.info("Created new transcription session", {
sessionId: chunkData.sessionId,
});
}
// Add chunk to session for processing
transcriptionSession.addChunk(chunkData);
} catch (error) {
logger.ai.error('Error handling chunk-ready event', {
logger.ai.error("Error handling chunk-ready event", {
sessionId: chunkData.sessionId,
chunkId: chunkData.chunkId,
error: error instanceof Error ? error.message : String(error),
@ -423,42 +477,47 @@ app.on('ready', async () => {
});
// Handle audio data chunks from renderer
ipcMain.handle('audio-data-chunk', (event, chunk: ArrayBuffer, isFinalChunk: boolean) => {
if (chunk instanceof ArrayBuffer) {
console.log(
`Main: IPC received audio-data-chunk (ArrayBuffer) of size: ${chunk.byteLength} bytes. isFinalChunk: ${isFinalChunk}`
);
const buffer = Buffer.from(chunk);
if (buffer.length === 0) {
console.warn('Main: Received an empty audio chunk after conversion.');
ipcMain.handle(
"audio-data-chunk",
(event, chunk: ArrayBuffer, isFinalChunk: boolean) => {
if (chunk instanceof ArrayBuffer) {
console.log(
`Main: IPC received audio-data-chunk (ArrayBuffer) of size: ${chunk.byteLength} bytes. isFinalChunk: ${isFinalChunk}`,
);
const buffer = Buffer.from(chunk);
if (buffer.length === 0) {
console.warn("Main: Received an empty audio chunk after conversion.");
}
// The AudioCapture class will now need to handle buffering and the isFinalChunk flag
audioCapture?.handleAudioChunk(buffer, isFinalChunk);
} else {
console.error(
"Main: Received audio chunk, but it is not an ArrayBuffer. Type:",
typeof chunk,
);
throw new Error("Invalid audio chunk type received.");
}
// The AudioCapture class will now need to handle buffering and the isFinalChunk flag
audioCapture?.handleAudioChunk(buffer, isFinalChunk);
} else {
console.error(
'Main: Received audio chunk, but it is not an ArrayBuffer. Type:',
typeof chunk
);
throw new Error('Invalid audio chunk type received.');
}
});
},
);
ipcMain.handle('recording-starting', async () => {
console.log('Main: Received recording-starting event.');
ipcMain.handle("recording-starting", async () => {
console.log("Main: Received recording-starting event.");
// Preload the transcription model for fast processing
try {
if (contextualTranscriptionManager) {
if (!contextualTranscriptionManager.isModelLoaded()) {
logger.ai.info('Preloading transcription model for recording session');
logger.ai.info(
"Preloading transcription model for recording session",
);
await contextualTranscriptionManager.preloadModel();
logger.ai.info('Transcription model preloaded successfully');
logger.ai.info("Transcription model preloaded successfully");
} else {
logger.ai.info('Transcription model already loaded');
logger.ai.info("Transcription model already loaded");
}
}
} catch (error) {
logger.ai.error('Error preloading transcription model', {
logger.ai.error("Error preloading transcription model", {
error: error instanceof Error ? error.message : String(error),
});
}
@ -468,52 +527,56 @@ app.on('ready', async () => {
//const accessibilityContext = await swiftIOBridgeClientInstance!.call('getAccessibilityContext', { editableOnly: true });
//console.log('Main: Accessibility context captured:', JSON.stringify(accessibilityContext, null, 2));
} catch (error) {
console.error('Main: Error getting accessibility context:', error);
console.error("Main: Error getting accessibility context:", error);
}
await swiftIOBridgeClientInstance!.call('muteSystemAudio', {});
await swiftIOBridgeClientInstance!.call("muteSystemAudio", {});
});
ipcMain.handle('recording-stopping', async () => {
console.log('Main: Received recording-stopping event.');
await swiftIOBridgeClientInstance!.call('restoreSystemAudio', {});
ipcMain.handle("recording-stopping", async () => {
console.log("Main: Received recording-stopping event.");
await swiftIOBridgeClientInstance!.call("restoreSystemAudio", {});
});
// Initialize the SwiftIOBridgeClient
swiftIOBridgeClientInstance = new SwiftIOBridge();
swiftIOBridgeClientInstance.on('helperEvent', (event: HelperEvent) => {
logger.swift.debug('Received helperEvent from SwiftIOBridge', { event });
swiftIOBridgeClientInstance.on("helperEvent", (event: HelperEvent) => {
logger.swift.debug("Received helperEvent from SwiftIOBridge", { event });
switch (event.type) {
case 'flagsChanged': {
case "flagsChanged": {
const payload = event.payload;
logger.swift.debug('Received flagsChanged event', {
logger.swift.debug("Received flagsChanged event", {
fnKeyPressed: payload?.fnKeyPressed,
});
// Use flagsChanged for more reliable Fn key state tracking
if (payload?.fnKeyPressed !== undefined) {
logger.swift.info('Setting recording state', { state: payload.fnKeyPressed });
floatingButtonWindow!.webContents.send('recording-state-changed', payload.fnKeyPressed);
logger.swift.info("Setting recording state", {
state: payload.fnKeyPressed,
});
floatingButtonWindow!.webContents.send(
"recording-state-changed",
payload.fnKeyPressed,
);
}
break;
}
case 'keyDown': {
case "keyDown": {
const payload = event.payload;
// console.log(`Main: Received keyDown for key: ${payload?.key}.`);
// Keep keyDown handling as fallback, but flagsChanged should be primary
if (payload?.key?.toLowerCase() === 'fn') {
if (payload?.key?.toLowerCase() === "fn") {
// console.log('Main: Fn keyDown detected (fallback)');
// Don't send recording-state-changed here as flagsChanged should handle it
}
break;
}
case 'keyUp': {
case "keyUp": {
const payload = event.payload;
// console.log(`Main: Received keyUp for key: ${payload?.key}.`);
// Keep keyUp handling as fallback, but flagsChanged should be primary
if (payload?.key?.toLowerCase() === 'fn') {
if (payload?.key?.toLowerCase() === "fn") {
// console.log('Main: Fn keyUp detected (fallback)');
// Don't send recording-state-changed here as flagsChanged should handle it
}
@ -526,13 +589,16 @@ app.on('ready', async () => {
}
});
swiftIOBridgeClientInstance.on('error', (error) => {
logError(error instanceof Error ? error : new Error(String(error)), 'SwiftIOBridge error');
swiftIOBridgeClientInstance.on("error", (error) => {
logError(
error instanceof Error ? error : new Error(String(error)),
"SwiftIOBridge error",
);
// Potentially notify the user or attempt to restart
});
swiftIOBridgeClientInstance.on('close', (code) => {
logger.swift.warn('Swift helper process closed', { code });
swiftIOBridgeClientInstance.on("close", (code) => {
logger.swift.warn("Swift helper process closed", { code });
// Handle unexpected close, maybe attempt restart
});
@ -542,64 +608,84 @@ app.on('ready', async () => {
}
});
if (process.platform === 'darwin') {
if (process.platform === "darwin") {
try {
console.log('Main: Setting up display change notifications');
console.log("Main: Setting up display change notifications");
activeSpaceChangeSubscriptionId = systemPreferences.subscribeWorkspaceNotification(
'NSWorkspaceActiveDisplayDidChangeNotification',
() => {
if (floatingButtonWindow && !floatingButtonWindow.isDestroyed()) {
try {
const cursorPoint = screen.getCursorScreenPoint();
const displayForCursor = screen.getDisplayNearestPoint(cursorPoint);
if (currentWindowDisplayId !== displayForCursor.id) {
console.log(
`[Main Process] Moving floating window to display ID: ${displayForCursor.id}`
activeSpaceChangeSubscriptionId =
systemPreferences.subscribeWorkspaceNotification(
"NSWorkspaceActiveDisplayDidChangeNotification",
() => {
if (floatingButtonWindow && !floatingButtonWindow.isDestroyed()) {
try {
const cursorPoint = screen.getCursorScreenPoint();
const displayForCursor =
screen.getDisplayNearestPoint(cursorPoint);
if (currentWindowDisplayId !== displayForCursor.id) {
console.log(
`[Main Process] Moving floating window to display ID: ${displayForCursor.id}`,
);
floatingButtonWindow.setBounds(displayForCursor.workArea);
currentWindowDisplayId = displayForCursor.id;
}
} catch (error) {
console.warn(
"[Main Process] Error handling display change:",
error,
);
floatingButtonWindow.setBounds(displayForCursor.workArea);
currentWindowDisplayId = displayForCursor.id;
}
} catch (error) {
console.warn('[Main Process] Error handling display change:', error);
}
}
}
);
},
);
if (activeSpaceChangeSubscriptionId !== undefined && activeSpaceChangeSubscriptionId >= 0) {
console.log(`Main: Successfully subscribed to display change notifications`);
if (
activeSpaceChangeSubscriptionId !== undefined &&
activeSpaceChangeSubscriptionId >= 0
) {
console.log(
`Main: Successfully subscribed to display change notifications`,
);
} else {
console.error('Main: Failed to subscribe to display change notifications');
console.error(
"Main: Failed to subscribe to display change notifications",
);
}
} catch (e) {
console.error('Main: Error during subscription to display notifications:', e);
console.error(
"Main: Error during subscription to display notifications:",
e,
);
activeSpaceChangeSubscriptionId = null;
}
} else {
console.log('Main: Display change tracking is a macOS-only feature');
console.log("Main: Display change tracking is a macOS-only feature");
}
});
// Clean up intervals and subscriptions
app.on('will-quit', () => {
app.on("will-quit", () => {
// globalShortcut.unregisterAll();
globalShortcut.unregisterAll();
if (swiftIOBridgeClientInstance) {
console.log('Main: Stopping Swift helper...');
console.log("Main: Stopping Swift helper...");
swiftIOBridgeClientInstance.stopHelper();
}
if (modelManagerService) {
console.log('Main: Cleaning up model downloads...');
console.log("Main: Cleaning up model downloads...");
modelManagerService.cleanup();
}
if (contextualTranscriptionManager) {
console.log('Main: Cleaning up transcription models...');
console.log("Main: Cleaning up transcription models...");
contextualTranscriptionManager.dispose();
}
if (process.platform === 'darwin' && activeSpaceChangeSubscriptionId !== null) {
systemPreferences.unsubscribeWorkspaceNotification(activeSpaceChangeSubscriptionId);
console.log('Main: Unsubscribed from display change notifications');
if (
process.platform === "darwin" &&
activeSpaceChangeSubscriptionId !== null
) {
systemPreferences.unsubscribeWorkspaceNotification(
activeSpaceChangeSubscriptionId,
);
console.log("Main: Unsubscribed from display change notifications");
activeSpaceChangeSubscriptionId = null;
}
});
@ -607,13 +693,13 @@ app.on('will-quit', () => {
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
app.on('activate', () => {
app.on("activate", () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
@ -637,19 +723,25 @@ app.on('activate', () => {
// Function to log the accessibility tree (added)
async function logAccessibilityTree() {
if (swiftIOBridgeClientInstance && swiftIOBridgeClientInstance.isHelperRunning()) {
if (
swiftIOBridgeClientInstance &&
swiftIOBridgeClientInstance.isHelperRunning()
) {
try {
// console.log('Main: Requesting full accessibility tree...');
// Call with empty params for the whole tree, as per schema for GetAccessibilityTreeDetailsParams
const result = await swiftIOBridgeClientInstance.call('getAccessibilityTreeDetails', {});
const result = await swiftIOBridgeClientInstance.call(
"getAccessibilityTreeDetails",
{},
);
// Using JSON.stringify to see the whole structure since it's 'any' for now
// console.log('Main: Accessibility tree received:', JSON.stringify(result, null, 2));
} catch (error) {
console.error('Main: Error calling getAccessibilityTreeDetails:', error);
console.error("Main: Error calling getAccessibilityTreeDetails:", error);
}
} else {
console.warn(
'Main: SwiftIOBridge not ready or helper not running, cannot log accessibility tree.'
"Main: SwiftIOBridge not ready or helper not running, cannot log accessibility tree.",
);
}
}

View file

@ -1,130 +1,143 @@
import { app, Menu, MenuItemConstructorOptions, BrowserWindow } from 'electron';
import { app, Menu, MenuItemConstructorOptions, BrowserWindow } from "electron";
// Forward declaration or import of the function type if it's complex
// For simplicity, we assume createOrShowSettingsWindow is a () => void function
export const setupApplicationMenu = (
createOrShowSettingsWindow: () => void,
checkForUpdates?: () => void
checkForUpdates?: () => void,
) => {
const menuTemplate: MenuItemConstructorOptions[] = [
// { role: 'appMenu' } for macOS
...(process.platform === 'darwin'
...(process.platform === "darwin"
? ([
{
label: app.name,
submenu: [
{ role: 'about' as const },
{ type: 'separator' as const },
...(checkForUpdates ? [{
label: 'Check for Updates...',
click: () => checkForUpdates(),
} as MenuItemConstructorOptions, { type: 'separator' as const }] : []),
{ role: "about" as const },
{ type: "separator" as const },
...(checkForUpdates
? [
{
label: "Check for Updates...",
click: () => checkForUpdates(),
} as MenuItemConstructorOptions,
{ type: "separator" as const },
]
: []),
{
label: 'Settings',
accelerator: 'CmdOrCtrl+,',
label: "Settings",
accelerator: "CmdOrCtrl+,",
click: () => createOrShowSettingsWindow(),
},
{ type: 'separator' as const },
{ role: 'services' as const },
{ type: 'separator' as const },
{ role: 'hide' as const },
{ role: 'hideOthers' as const },
{ role: 'unhide' as const },
{ type: 'separator' as const },
{ role: 'quit' as const },
{ type: "separator" as const },
{ role: "services" as const },
{ type: "separator" as const },
{ role: "hide" as const },
{ role: "hideOthers" as const },
{ role: "unhide" as const },
{ type: "separator" as const },
{ role: "quit" as const },
],
},
] as MenuItemConstructorOptions[])
: []),
// { role: 'fileMenu' } for Windows/Linux
...(process.platform !== 'darwin'
...(process.platform !== "darwin"
? ([
{
label: 'File',
label: "File",
submenu: [
{
label: 'Settings',
accelerator: 'CmdOrCtrl+,',
label: "Settings",
accelerator: "CmdOrCtrl+,",
click: () => createOrShowSettingsWindow(),
},
{ type: 'separator' as const },
{ role: 'quit' as const },
{ type: "separator" as const },
{ role: "quit" as const },
],
},
] as MenuItemConstructorOptions[])
: []),
// { role: 'editMenu' }
{
label: 'Edit',
label: "Edit",
submenu: [
{ role: 'undo' as const },
{ role: 'redo' as const },
{ type: 'separator' as const },
{ role: 'cut' as const },
{ role: 'copy' as const },
{ role: 'paste' as const },
...(process.platform === 'darwin'
{ role: "undo" as const },
{ role: "redo" as const },
{ type: "separator" as const },
{ role: "cut" as const },
{ role: "copy" as const },
{ role: "paste" as const },
...(process.platform === "darwin"
? [
{ role: 'pasteAndMatchStyle' as const },
{ role: 'delete' as const },
{ role: 'selectAll' as const },
{ type: 'separator' as const },
{ role: "pasteAndMatchStyle" as const },
{ role: "delete" as const },
{ role: "selectAll" as const },
{ type: "separator" as const },
{
label: 'Speech',
submenu: [{ role: 'startSpeaking' as const }, { role: 'stopSpeaking' as const }],
label: "Speech",
submenu: [
{ role: "startSpeaking" as const },
{ role: "stopSpeaking" as const },
],
},
]
: [
{ role: 'delete' as const },
{ type: 'separator' as const },
{ role: 'selectAll' as const },
{ role: "delete" as const },
{ type: "separator" as const },
{ role: "selectAll" as const },
]),
],
},
// { role: 'viewMenu' }
{
label: 'View',
label: "View",
submenu: [
{ role: 'reload' as const },
{ role: 'forceReload' as const },
{ role: 'toggleDevTools' as const },
{ type: 'separator' as const },
{ role: 'resetZoom' as const },
{ role: 'zoomIn' as const },
{ role: 'zoomOut' as const },
{ type: 'separator' as const },
{ role: 'togglefullscreen' as const },
{ role: "reload" as const },
{ role: "forceReload" as const },
{ role: "toggleDevTools" as const },
{ type: "separator" as const },
{ role: "resetZoom" as const },
{ role: "zoomIn" as const },
{ role: "zoomOut" as const },
{ type: "separator" as const },
{ role: "togglefullscreen" as const },
],
},
// { role: 'windowMenu' }
{
label: 'Window',
label: "Window",
submenu: [
{ role: 'minimize' as const },
{ role: 'zoom' as const },
...(process.platform === 'darwin'
{ role: "minimize" as const },
{ role: "zoom" as const },
...(process.platform === "darwin"
? [
{ type: 'separator' as const },
{ role: 'front' as const },
{ type: 'separator' as const },
{ role: 'window' as const },
{ type: "separator" as const },
{ role: "front" as const },
{ type: "separator" as const },
{ role: "window" as const },
]
: [{ role: 'close' as const }]),
: [{ role: "close" as const }]),
],
},
{
role: 'help' as const,
role: "help" as const,
submenu: [
...(checkForUpdates ? [{
label: 'Check for Updates...',
click: () => checkForUpdates(),
} as MenuItemConstructorOptions, { type: 'separator' as const }] : []),
...(checkForUpdates
? [
{
label: "Check for Updates...",
click: () => checkForUpdates(),
} as MenuItemConstructorOptions,
{ type: "separator" as const },
]
: []),
{
label: 'Learn More',
label: "Learn More",
click: async () => {
const { shell } = await import('electron');
shell.openExternal('https://electronjs.org');
const { shell } = await import("electron");
shell.openExternal("https://electronjs.org");
},
},
],

View file

@ -1,12 +1,12 @@
// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
import log from 'electron-log/renderer';
import { exposeElectronTRPC } from 'electron-trpc-experimental/preload';
import type { ElectronAPI } from '../types/electron-api';
import type { FormatterConfig } from '../modules/formatter';
import type { Transcription, NewTranscription } from '../db/schema';
import { contextBridge, ipcRenderer, IpcRendererEvent } from "electron";
import log from "electron-log/renderer";
import { exposeElectronTRPC } from "electron-trpc-experimental/preload";
import type { ElectronAPI } from "../types/electron-api";
import type { FormatterConfig } from "../modules/formatter";
import type { Transcription, NewTranscription } from "../db/schema";
interface ShortcutData {
shortcut: string;
@ -14,39 +14,47 @@ interface ShortcutData {
}
const api: ElectronAPI = {
onRecordingStarting: async () => await ipcRenderer.invoke('recording-starting'),
onRecordingStopping: async () => await ipcRenderer.invoke('recording-stopping'),
sendAudioChunk: (chunk: ArrayBuffer, isFinalChunk: boolean = false): Promise<void> =>
ipcRenderer.invoke('audio-data-chunk', chunk, isFinalChunk),
onRecordingStarting: async () =>
await ipcRenderer.invoke("recording-starting"),
onRecordingStopping: async () =>
await ipcRenderer.invoke("recording-stopping"),
sendAudioChunk: (
chunk: ArrayBuffer,
isFinalChunk: boolean = false,
): Promise<void> =>
ipcRenderer.invoke("audio-data-chunk", chunk, isFinalChunk),
onRecordingStateChanged: (callback: (newState: boolean) => void) => {
const handler = (_event: IpcRendererEvent, newState: boolean) => callback(newState);
ipcRenderer.on('recording-state-changed', handler);
const handler = (_event: IpcRendererEvent, newState: boolean) =>
callback(newState);
ipcRenderer.on("recording-state-changed", handler);
return () => {
ipcRenderer.removeListener('recording-state-changed', handler);
ipcRenderer.removeListener("recording-state-changed", handler);
};
},
// Switched to invoke/handle for request-response
onGlobalShortcut: (callback: (data: ShortcutData) => void) => {
const handler = (_event: IpcRendererEvent, data: ShortcutData) => callback(data);
ipcRenderer.on('global-shortcut-event', handler);
const handler = (_event: IpcRendererEvent, data: ShortcutData) =>
callback(data);
ipcRenderer.on("global-shortcut-event", handler);
// Optional: Return a cleanup function to remove the listener
return () => {
ipcRenderer.removeListener('global-shortcut-event', handler);
ipcRenderer.removeListener("global-shortcut-event", handler);
};
},
onKeyEvent: (callback: (keyEvent: unknown) => void) => {
const handler = (_event: IpcRendererEvent, keyEvent: unknown) => callback(keyEvent);
ipcRenderer.on('key-event', handler);
const handler = (_event: IpcRendererEvent, keyEvent: unknown) =>
callback(keyEvent);
ipcRenderer.on("key-event", handler);
return () => {
ipcRenderer.removeListener('key-event', handler);
ipcRenderer.removeListener("key-event", handler);
};
},
onForceStopMediaRecorder: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on('force-stop-mediarecorder', handler);
ipcRenderer.on("force-stop-mediarecorder", handler);
return () => {
ipcRenderer.removeListener('force-stop-mediarecorder', handler);
ipcRenderer.removeListener("force-stop-mediarecorder", handler);
};
},
// If you want a way to remove all listeners for this event from renderer:
@ -55,52 +63,63 @@ const api: ElectronAPI = {
// }
// Model Management API
getAvailableModels: () => ipcRenderer.invoke('get-available-models'),
getDownloadedModels: () => ipcRenderer.invoke('get-downloaded-models'),
isModelDownloaded: (modelId: string) => ipcRenderer.invoke('is-model-downloaded', modelId),
getDownloadProgress: (modelId: string) => ipcRenderer.invoke('get-download-progress', modelId),
getActiveDownloads: () => ipcRenderer.invoke('get-active-downloads'),
downloadModel: (modelId: string) => ipcRenderer.invoke('download-model', modelId),
cancelDownload: (modelId: string) => ipcRenderer.invoke('cancel-download', modelId),
deleteModel: (modelId: string) => ipcRenderer.invoke('delete-model', modelId),
getModelsDirectory: () => ipcRenderer.invoke('get-models-directory'),
getAvailableModels: () => ipcRenderer.invoke("get-available-models"),
getDownloadedModels: () => ipcRenderer.invoke("get-downloaded-models"),
isModelDownloaded: (modelId: string) =>
ipcRenderer.invoke("is-model-downloaded", modelId),
getDownloadProgress: (modelId: string) =>
ipcRenderer.invoke("get-download-progress", modelId),
getActiveDownloads: () => ipcRenderer.invoke("get-active-downloads"),
downloadModel: (modelId: string) =>
ipcRenderer.invoke("download-model", modelId),
cancelDownload: (modelId: string) =>
ipcRenderer.invoke("cancel-download", modelId),
deleteModel: (modelId: string) => ipcRenderer.invoke("delete-model", modelId),
getModelsDirectory: () => ipcRenderer.invoke("get-models-directory"),
// Local Whisper API
isLocalWhisperAvailable: () => ipcRenderer.invoke('is-local-whisper-available'),
getLocalWhisperModels: () => ipcRenderer.invoke('get-local-whisper-models'),
getSelectedModel: () => ipcRenderer.invoke('get-selected-model'),
setSelectedModel: (modelId: string) => ipcRenderer.invoke('set-selected-model', modelId),
isLocalWhisperAvailable: () =>
ipcRenderer.invoke("is-local-whisper-available"),
getLocalWhisperModels: () => ipcRenderer.invoke("get-local-whisper-models"),
getSelectedModel: () => ipcRenderer.invoke("get-selected-model"),
setSelectedModel: (modelId: string) =>
ipcRenderer.invoke("set-selected-model", modelId),
setWhisperExecutablePath: (path: string) =>
ipcRenderer.invoke('set-whisper-executable-path', path),
ipcRenderer.invoke("set-whisper-executable-path", path),
// Formatter Configuration API
getFormatterConfig: () => ipcRenderer.invoke('get-formatter-config'),
getFormatterConfig: () => ipcRenderer.invoke("get-formatter-config"),
setFormatterConfig: (config: FormatterConfig) =>
ipcRenderer.invoke('set-formatter-config', config),
ipcRenderer.invoke("set-formatter-config", config),
// Transcription Database API
getTranscriptions: (options?: {
limit?: number;
offset?: number;
sortBy?: 'timestamp' | 'createdAt';
sortOrder?: 'asc' | 'desc';
sortBy?: "timestamp" | "createdAt";
sortOrder?: "asc" | "desc";
search?: string;
}) => ipcRenderer.invoke('get-transcriptions', options),
getTranscriptionById: (id: number) => ipcRenderer.invoke('get-transcription-by-id', id),
createTranscription: (data: Omit<NewTranscription, 'id' | 'createdAt' | 'updatedAt'>) =>
ipcRenderer.invoke('create-transcription', data),
updateTranscription: (id: number, data: Partial<Omit<Transcription, 'id' | 'createdAt'>>) =>
ipcRenderer.invoke('update-transcription', id, data),
deleteTranscription: (id: number) => ipcRenderer.invoke('delete-transcription', id),
}) => ipcRenderer.invoke("get-transcriptions", options),
getTranscriptionById: (id: number) =>
ipcRenderer.invoke("get-transcription-by-id", id),
createTranscription: (
data: Omit<NewTranscription, "id" | "createdAt" | "updatedAt">,
) => ipcRenderer.invoke("create-transcription", data),
updateTranscription: (
id: number,
data: Partial<Omit<Transcription, "id" | "createdAt">>,
) => ipcRenderer.invoke("update-transcription", id, data),
deleteTranscription: (id: number) =>
ipcRenderer.invoke("delete-transcription", id),
getTranscriptionsCount: (search?: string) =>
ipcRenderer.invoke('get-transcriptions-count', search),
ipcRenderer.invoke("get-transcriptions-count", search),
searchTranscriptions: (searchTerm: string, limit?: number) =>
ipcRenderer.invoke('search-transcriptions', searchTerm, limit),
ipcRenderer.invoke("search-transcriptions", searchTerm, limit),
// Vocabulary Database API
on: (channel: string, callback: (...args: any[]) => void) => {
const handler = (_event: IpcRendererEvent, ...args: any[]) => callback(...args);
const handler = (_event: IpcRendererEvent, ...args: any[]) =>
callback(...args);
ipcRenderer.on(channel, handler);
// Store the handler mapping for proper cleanup
if (!(window as any).__electronEventHandlers) {
@ -109,7 +128,9 @@ const api: ElectronAPI = {
if (!(window as any).__electronEventHandlers.has(channel)) {
(window as any).__electronEventHandlers.set(channel, []);
}
(window as any).__electronEventHandlers.get(channel).push({ original: callback, handler });
(window as any).__electronEventHandlers
.get(channel)
.push({ original: callback, handler });
},
off: (channel: string, callback: (...args: any[]) => void) => {
if (
@ -136,9 +157,9 @@ const api: ElectronAPI = {
},
};
contextBridge.exposeInMainWorld('electronAPI', api);
contextBridge.exposeInMainWorld("electronAPI", api);
// Expose tRPC for electron-trpc-experimental
process.once('loaded', async () => {
process.once("loaded", async () => {
exposeElectronTRPC();
});

View file

@ -1,7 +1,7 @@
import { autoUpdater } from 'electron-updater';
import { app, dialog, BrowserWindow } from 'electron';
import { EventEmitter } from 'events';
import { logger } from '../logger';
import { autoUpdater } from "electron-updater";
import { app, dialog, BrowserWindow } from "electron";
import { EventEmitter } from "events";
import { logger } from "../logger";
export class AutoUpdaterService extends EventEmitter {
private checkingForUpdate = false;
@ -10,12 +10,12 @@ export class AutoUpdaterService extends EventEmitter {
constructor() {
super();
// Only set up auto-updater in production
if (process.env.NODE_ENV !== 'development' && app.isPackaged) {
if (process.env.NODE_ENV !== "development" && app.isPackaged) {
this.setupAutoUpdater();
} else {
logger.updater.info('Auto-updater disabled in development mode');
logger.updater.info("Auto-updater disabled in development mode");
}
}
@ -29,20 +29,20 @@ export class AutoUpdaterService extends EventEmitter {
autoUpdater.autoInstallOnAppQuit = true;
// Development settings
if (process.env.NODE_ENV === 'development') {
if (process.env.NODE_ENV === "development") {
// In development, you can test with a local update server
// autoUpdater.updateConfigPath = path.join(__dirname, 'dev-app-update.yml');
autoUpdater.forceDevUpdateConfig = true;
}
// Event handlers
autoUpdater.on('checking-for-update', () => {
logger.updater.info('Checking for update...');
autoUpdater.on("checking-for-update", () => {
logger.updater.info("Checking for update...");
this.checkingForUpdate = true;
});
autoUpdater.on('update-available', (info) => {
logger.updater.info('Update available', {
autoUpdater.on("update-available", (info) => {
logger.updater.info("Update available", {
version: info.version,
releaseDate: info.releaseDate,
});
@ -51,36 +51,39 @@ export class AutoUpdaterService extends EventEmitter {
this.showUpdateDialog(info);
});
autoUpdater.on('update-not-available', (info) => {
logger.updater.info('Update not available', { version: info.version });
autoUpdater.on("update-not-available", (info) => {
logger.updater.info("Update not available", { version: info.version });
this.checkingForUpdate = false;
this.updateAvailable = false;
});
autoUpdater.on('error', (err) => {
logger.updater.error('Error in auto-updater', { error: err.message });
autoUpdater.on("error", (err) => {
logger.updater.error("Error in auto-updater", { error: err.message });
this.checkingForUpdate = false;
// Show error dialog only if user manually checked for updates
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
dialog.showErrorBox('Update Error', `Error checking for updates: ${err.message}`);
dialog.showErrorBox(
"Update Error",
`Error checking for updates: ${err.message}`,
);
}
});
autoUpdater.on('download-progress', (progressObj) => {
logger.updater.info('Download progress', {
autoUpdater.on("download-progress", (progressObj) => {
logger.updater.info("Download progress", {
bytesPerSecond: progressObj.bytesPerSecond,
percent: progressObj.percent,
transferred: progressObj.transferred,
total: progressObj.total,
});
// Emit event for tRPC subscription
this.emit('download-progress', progressObj);
this.emit("download-progress", progressObj);
});
autoUpdater.on('update-downloaded', (info) => {
logger.updater.info('Update downloaded', { version: info.version });
autoUpdater.on("update-downloaded", (info) => {
logger.updater.info("Update downloaded", { version: info.version });
this.showInstallDialog(info);
});
}
@ -91,20 +94,21 @@ export class AutoUpdaterService extends EventEmitter {
}
const result = await dialog.showMessageBox(this.mainWindow, {
type: 'info',
title: 'Update Available',
type: "info",
title: "Update Available",
message: `A new version (${info.version}) is available.`,
detail: 'Would you like to download it now? The update will be installed when you restart the app.',
buttons: ['Download Now', 'Later'],
detail:
"Would you like to download it now? The update will be installed when you restart the app.",
buttons: ["Download Now", "Later"],
defaultId: 0,
cancelId: 1,
});
if (result.response === 0) {
logger.updater.info('User chose to download update');
logger.updater.info("User chose to download update");
autoUpdater.downloadUpdate();
} else {
logger.updater.info('User chose to skip update');
logger.updater.info("User chose to skip update");
}
}
@ -114,69 +118,75 @@ export class AutoUpdaterService extends EventEmitter {
}
const result = await dialog.showMessageBox(this.mainWindow, {
type: 'info',
title: 'Update Ready',
type: "info",
title: "Update Ready",
message: `Update ${info.version} has been downloaded.`,
detail: 'The update will be installed when you restart the app. Would you like to restart now?',
buttons: ['Restart Now', 'Later'],
detail:
"The update will be installed when you restart the app. Would you like to restart now?",
buttons: ["Restart Now", "Later"],
defaultId: 0,
cancelId: 1,
});
if (result.response === 0) {
logger.updater.info('User chose to restart and install update');
logger.updater.info("User chose to restart and install update");
autoUpdater.quitAndInstall();
} else {
logger.updater.info('User chose to install update later');
logger.updater.info("User chose to install update later");
}
}
async checkForUpdates(userInitiated = false): Promise<void> {
// Skip in development
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
logger.updater.info('Skipping update check in development mode');
if (process.env.NODE_ENV === "development" || !app.isPackaged) {
logger.updater.info("Skipping update check in development mode");
if (userInitiated && this.mainWindow && !this.mainWindow.isDestroyed()) {
dialog.showMessageBox(this.mainWindow, {
type: 'info',
title: 'Development Mode',
message: 'Update checking is disabled in development mode.',
buttons: ['OK']
type: "info",
title: "Development Mode",
message: "Update checking is disabled in development mode.",
buttons: ["OK"],
});
}
return;
}
if (this.checkingForUpdate) {
logger.updater.info('Already checking for updates');
logger.updater.info("Already checking for updates");
return;
}
try {
logger.updater.info('Starting update check', { userInitiated });
logger.updater.info("Starting update check", { userInitiated });
await autoUpdater.checkForUpdates();
} catch (error) {
logger.updater.error('Failed to check for updates', {
error: error instanceof Error ? error.message : String(error)
logger.updater.error("Failed to check for updates", {
error: error instanceof Error ? error.message : String(error),
});
if (userInitiated && this.mainWindow && !this.mainWindow.isDestroyed()) {
dialog.showErrorBox('Update Check Failed', 'Failed to check for updates. Please try again later.');
dialog.showErrorBox(
"Update Check Failed",
"Failed to check for updates. Please try again later.",
);
}
}
}
async checkForUpdatesAndNotify(): Promise<void> {
// Skip in development
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
logger.updater.info('Skipping background update check in development mode');
if (process.env.NODE_ENV === "development" || !app.isPackaged) {
logger.updater.info(
"Skipping background update check in development mode",
);
return;
}
try {
await autoUpdater.checkForUpdatesAndNotify();
} catch (error) {
logger.updater.error('Failed to check for updates and notify', {
error: error instanceof Error ? error.message : String(error)
logger.updater.error("Failed to check for updates and notify", {
error: error instanceof Error ? error.message : String(error),
});
}
}
@ -191,20 +201,20 @@ export class AutoUpdaterService extends EventEmitter {
async downloadUpdate(): Promise<void> {
// Skip in development
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
logger.updater.info('Skipping update download in development mode');
throw new Error('Update downloads are disabled in development mode');
if (process.env.NODE_ENV === "development" || !app.isPackaged) {
logger.updater.info("Skipping update download in development mode");
throw new Error("Update downloads are disabled in development mode");
}
if (!this.updateAvailable) {
throw new Error('No update available to download');
throw new Error("No update available to download");
}
try {
await autoUpdater.downloadUpdate();
} catch (error) {
logger.updater.error('Failed to download update', {
error: error instanceof Error ? error.message : String(error)
logger.updater.error("Failed to download update", {
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
@ -212,11 +222,11 @@ export class AutoUpdaterService extends EventEmitter {
quitAndInstall(): void {
// Skip in development
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
logger.updater.info('Skipping quit and install in development mode');
if (process.env.NODE_ENV === "development" || !app.isPackaged) {
logger.updater.info("Skipping quit and install in development mode");
return;
}
autoUpdater.quitAndInstall();
}
}
}

View file

@ -1,13 +1,13 @@
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import path from 'node:path';
import fs from 'node:fs';
import process from 'node:process'; // Added import for process
import { app, app as electronApp } from 'electron'; // electronApp for app.getAppPath() consistency
import split2 from 'split2';
import { v4 as uuid } from 'uuid';
import { spawn, ChildProcessWithoutNullStreams } from "child_process";
import path from "node:path";
import fs from "node:fs";
import process from "node:process"; // Added import for process
import { app, app as electronApp } from "electron"; // electronApp for app.getAppPath() consistency
import split2 from "split2";
import { v4 as uuid } from "uuid";
import { EventEmitter } from 'events';
import { createScopedLogger } from './logger';
import { EventEmitter } from "events";
import { createScopedLogger } from "./logger";
import {
RpcRequestSchema,
RpcRequest,
@ -25,7 +25,7 @@ import {
MuteSystemAudioResult,
RestoreSystemAudioParams,
RestoreSystemAudioResult,
} from '@amical/types';
} from "@amical/types";
// Define the interface for RPC methods
interface RPCMethods {
@ -63,9 +63,12 @@ interface SwiftIOBridgeEvents {
export class SwiftIOBridge extends EventEmitter {
private proc: ChildProcessWithoutNullStreams | null = null;
private pending = new Map<string, { callback: (resp: RpcResponse) => void; startTime: number }>();
private pending = new Map<
string,
{ callback: (resp: RpcResponse) => void; startTime: number }
>();
private helperPath: string;
private logger = createScopedLogger('swift-bridge');
private logger = createScopedLogger("swift-bridge");
constructor() {
super();
@ -74,18 +77,18 @@ export class SwiftIOBridge extends EventEmitter {
}
private determineHelperPath(): string {
const helperName = 'SwiftHelper'; // Swift native helper executable
const helperName = "SwiftHelper"; // Swift native helper executable
return electronApp.isPackaged
? path.join(process.resourcesPath, 'bin', helperName)
? path.join(process.resourcesPath, "bin", helperName)
: path.join(
electronApp.getAppPath(),
'..',
'..',
'packages',
'native-helpers',
'swift-helper',
'bin',
helperName
"..",
"..",
"packages",
"native-helpers",
"swift-helper",
"bin",
helperName,
);
}
@ -93,27 +96,27 @@ export class SwiftIOBridge extends EventEmitter {
try {
fs.accessSync(this.helperPath, fs.constants.X_OK);
} catch (err) {
this.logger.error('SwiftHelper executable not found or not executable', {
this.logger.error("SwiftHelper executable not found or not executable", {
helperPath: this.helperPath,
});
this.emit(
'error',
"error",
new Error(
`Helper executable not found at ${this.helperPath}. Attempt to build it if in dev mode.`
)
`Helper executable not found at ${this.helperPath}. Attempt to build it if in dev mode.`,
),
);
// In a real app, you might try to build it here or provide more robust error handling.
return;
}
this.logger.info('Spawning SwiftHelper', { helperPath: this.helperPath });
this.proc = spawn(this.helperPath, [], { stdio: ['pipe', 'pipe', 'pipe'] });
this.logger.info("Spawning SwiftHelper", { helperPath: this.helperPath });
this.proc = spawn(this.helperPath, [], { stdio: ["pipe", "pipe", "pipe"] });
this.proc.stdout.pipe(split2()).on('data', (line: string) => {
this.proc.stdout.pipe(split2()).on("data", (line: string) => {
if (!line.trim()) return; // Ignore empty lines
try {
const message = JSON.parse(line);
this.logger.debug('Received message from helper', { message });
this.logger.debug("Received message from helper", { message });
// Try to parse as RpcResponse first
const responseValidation = RpcResponseSchema.safeParse(message);
@ -130,52 +133,57 @@ export class SwiftIOBridge extends EventEmitter {
const eventValidation = HelperEventSchema.safeParse(message);
if (eventValidation.success) {
const helperEvent = eventValidation.data;
this.emit('helperEvent', helperEvent);
this.emit("helperEvent", helperEvent);
return; // Handled as a helper event
}
// If it's neither a recognized RPC response nor a helper event
this.logger.warn('Received unknown message from helper', { message });
this.logger.warn("Received unknown message from helper", { message });
} catch (e) {
this.logger.error('Error parsing JSON from helper', { error: e, line });
this.emit('error', new Error(`Error parsing JSON from helper: ${line}`));
this.logger.error("Error parsing JSON from helper", { error: e, line });
this.emit(
"error",
new Error(`Error parsing JSON from helper: ${line}`),
);
}
});
this.proc.stderr.on('data', (data: Buffer) => {
this.proc.stderr.on("data", (data: Buffer) => {
const errorMsg = data.toString();
this.logger.warn('SwiftHelper stderr output', { message: errorMsg });
this.logger.warn("SwiftHelper stderr output", { message: errorMsg });
// Don't emit as error since stderr is often just debug info
// this.emit('error', new Error(`Helper stderr: ${errorMsg}`));
});
this.proc.on('error', (err) => {
this.logger.error('Failed to start SwiftHelper process', { error: err });
this.emit('error', err);
this.proc.on("error", (err) => {
this.logger.error("Failed to start SwiftHelper process", { error: err });
this.emit("error", err);
this.proc = null;
});
this.proc.on('close', (code, signal) => {
this.logger.info('SwiftHelper process exited', { code, signal });
this.emit('close', code, signal);
this.proc.on("close", (code, signal) => {
this.logger.info("SwiftHelper process exited", { code, signal });
this.emit("close", code, signal);
this.proc = null;
// Optionally, implement retry logic or notify further
});
process.nextTick(() => {
this.emit('ready'); // Emit ready on next tick
this.emit("ready"); // Emit ready on next tick
});
this.logger.info('Helper process started and listeners attached');
this.logger.info("Helper process started and listeners attached");
}
public call<M extends keyof RPCMethods>(
method: M,
params: RPCMethods[M]['params'],
timeoutMs = 5000
): Promise<RPCMethods[M]['result']> {
params: RPCMethods[M]["params"],
timeoutMs = 5000,
): Promise<RPCMethods[M]["result"]> {
if (!this.proc || !this.proc.stdin || !this.proc.stdin.writable) {
return Promise.reject(
new Error('Swift helper process is not running or stdin is not writable.')
new Error(
"Swift helper process is not running or stdin is not writable.",
),
);
}
@ -186,61 +194,69 @@ export class SwiftIOBridge extends EventEmitter {
// Validate request payload before sending
const validationResult = RpcRequestSchema.safeParse(requestPayload);
if (!validationResult.success) {
this.logger.error('Invalid RPC request payload', {
this.logger.error("Invalid RPC request payload", {
method,
error: validationResult.error.flatten(),
});
return Promise.reject(
new Error(`Invalid RPC request payload: ${validationResult.error.message}`)
new Error(
`Invalid RPC request payload: ${validationResult.error.message}`,
),
);
}
this.logger.debug('Sending RPC request', {
this.logger.debug("Sending RPC request", {
method,
id,
startedAt: new Date(startTime).toISOString(),
});
this.proc.stdin.write(JSON.stringify(requestPayload) + '\n', (err) => {
this.proc.stdin.write(JSON.stringify(requestPayload) + "\n", (err) => {
if (err) {
this.logger.error('Error writing to helper stdin', { method, id, error: err });
this.logger.error("Error writing to helper stdin", {
method,
id,
error: err,
});
// Note: The promise might have already been set up, consider how to reject it.
// For now, this error will be logged. The timeout will eventually reject.
} else {
this.logger.debug('Successfully sent RPC request', { method, id });
this.logger.debug("Successfully sent RPC request", { method, id });
}
});
const responsePromise = new Promise<RPCMethods[M]['result']>((resolve, reject) => {
this.pending.set(id, {
callback: (resp: RpcResponse) => {
this.pending.delete(id); // Clean up immediately
const completedAt = Date.now();
const duration = completedAt - startTime;
const responsePromise = new Promise<RPCMethods[M]["result"]>(
(resolve, reject) => {
this.pending.set(id, {
callback: (resp: RpcResponse) => {
this.pending.delete(id); // Clean up immediately
const completedAt = Date.now();
const duration = completedAt - startTime;
if (resp.error) {
const error = new Error(resp.error.message);
(error as any).code = resp.error.code;
(error as any).data = resp.error.data;
reject(error);
} else {
// Log the raw resp.result with timing information
this.logger.debug('Raw RPC response result received', {
method,
id,
result: resp.result,
startedAt: new Date(startTime).toISOString(),
completedAt: new Date(completedAt).toISOString(),
durationMs: duration,
});
// Here, we might need to validate resp.result against the specific method's result schema
// For now, casting as any, but for type safety, validation is better.
// Example: const resultValidation = RPCMethods[method].resultSchema.safeParse(resp.result);
resolve(resp.result as any);
}
},
startTime,
});
});
if (resp.error) {
const error = new Error(resp.error.message);
(error as any).code = resp.error.code;
(error as any).data = resp.error.data;
reject(error);
} else {
// Log the raw resp.result with timing information
this.logger.debug("Raw RPC response result received", {
method,
id,
result: resp.result,
startedAt: new Date(startTime).toISOString(),
completedAt: new Date(completedAt).toISOString(),
durationMs: duration,
});
// Here, we might need to validate resp.result against the specific method's result schema
// For now, casting as any, but for type safety, validation is better.
// Example: const resultValidation = RPCMethods[method].resultSchema.safeParse(resp.result);
resolve(resp.result as any);
}
},
startTime,
});
},
);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
@ -251,8 +267,8 @@ export class SwiftIOBridge extends EventEmitter {
const duration = timedOutAt - startTime;
reject(
new Error(
`SwiftIOBridge: RPC call "${method}" (id: ${id}) timed out after ${timeoutMs}ms (duration: ${duration}ms, started: ${new Date(startTime).toISOString()})`
)
`SwiftIOBridge: RPC call "${method}" (id: ${id}) timed out after ${timeoutMs}ms (duration: ${duration}ms, started: ${new Date(startTime).toISOString()})`,
),
);
}
}, timeoutMs);
@ -267,14 +283,17 @@ export class SwiftIOBridge extends EventEmitter {
public stopHelper(): void {
if (this.proc) {
this.logger.info('Stopping SwiftHelper process');
this.logger.info("Stopping SwiftHelper process");
this.proc.kill();
this.proc = null;
}
}
// Typed event emitter methods
on<E extends keyof SwiftIOBridgeEvents>(event: E, listener: SwiftIOBridgeEvents[E]): this {
on<E extends keyof SwiftIOBridgeEvents>(
event: E,
listener: SwiftIOBridgeEvents[E],
): this {
super.on(event, listener);
return this;
}