diff --git a/src/main/data/PreferenceService.ts b/src/main/data/PreferenceService.ts index ca59adc50c..92f30a9ee8 100644 --- a/src/main/data/PreferenceService.ts +++ b/src/main/data/PreferenceService.ts @@ -4,7 +4,8 @@ import { DefaultPreferences } from '@shared/data/preferences' import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/types' import { IpcChannel } from '@shared/IpcChannel' import { and, eq } from 'drizzle-orm' -import { BrowserWindow } from 'electron' +import { BrowserWindow, ipcMain } from 'electron' +import { EventEmitter } from 'events' import { preferenceTable } from './db/schemas/preference' @@ -20,9 +21,10 @@ const DefaultScope = 'default' * - Memory-cached preferences for high performance * - SQLite database persistence using Drizzle ORM * - Multi-window subscription and synchronization + * - Main process change notification support * - Type-safe preference operations * - Batch operations support - * - Change notification broadcasting + * - Unified change notification broadcasting */ export class PreferenceService { private static instance: PreferenceService @@ -30,6 +32,11 @@ export class PreferenceService { private cache: PreferenceDefaultScopeType = DefaultPreferences.default private initialized = false + private static isIpcHandlerRegistered = false + + // EventEmitter for main process change notifications + private mainEventEmitter = new EventEmitter() + private constructor() { this.setupWindowCleanup() } @@ -48,7 +55,7 @@ export class PreferenceService { * Initialize preference cache from database * Should be called once at application startup */ - async initialize(): Promise { + public async initialize(): Promise { if (this.initialized) { return } @@ -78,7 +85,7 @@ export class PreferenceService { * Get a single preference value from memory cache * Fast synchronous access - no database queries after initialization */ - get(key: K): PreferenceDefaultScopeType[K] { + public get(key: K): PreferenceDefaultScopeType[K] { if (!this.initialized) { logger.warn(`Preference cache not initialized, returning default for ${key}`) return DefaultPreferences.default[key] @@ -89,14 +96,23 @@ export class PreferenceService { /** * Set a single preference value - * Updates both database and memory cache, then broadcasts changes to subscribed windows + * Updates both database and memory cache, then broadcasts changes to all listeners + * Optimized to skip database writes and notifications when value hasn't changed */ - async set(key: K, value: PreferenceDefaultScopeType[K]): Promise { + public async set(key: K, value: PreferenceDefaultScopeType[K]): Promise { try { if (!(key in this.cache)) { throw new Error(`Preference ${key} not found in cache`) } + const oldValue = this.cache[key] // Save old value for notification + + // Performance optimization: skip update if value hasn't changed + if (this.isEqual(oldValue, value)) { + logger.debug(`Preference ${key} value unchanged, skipping database write and notification`) + return + } + await dbService .getDb() .update(preferenceTable) @@ -108,8 +124,8 @@ export class PreferenceService { // Update memory cache immediately this.cache[key] = value - // Broadcast change to subscribed windows - await this.notifyChange(key, value) + // Unified notification to both main and renderer processes + await this.notifyChange(key, value, oldValue) logger.debug(`Preference ${key} updated successfully`) } catch (error) { @@ -122,7 +138,7 @@ export class PreferenceService { * Get multiple preferences at once from memory cache * Fast synchronous access - no database queries */ - getMultiple(keys: K[]): MultiPreferencesResultType { + public getMultiple(keys: K[]): MultiPreferencesResultType { if (!this.initialized) { logger.warn('Preference cache not initialized, returning defaults for multiple keys') const output: MultiPreferencesResultType = {} as MultiPreferencesResultType @@ -151,18 +167,40 @@ export class PreferenceService { /** * Set multiple preferences at once * Updates both database and memory cache in a transaction, then broadcasts changes + * Optimized to skip unchanged values and reduce database operations */ - async setMultiple(updates: Partial): Promise { + public async setMultiple(updates: Partial): Promise { try { - //check if all keys are in the cache + // Performance optimization: filter out unchanged values + const actualUpdates: Record = {} + const oldValues: Record = {} + let skippedCount = 0 + for (const [key, value] of Object.entries(updates)) { if (!(key in this.cache) || value === undefined || value === null) { throw new Error(`Preference ${key} not found in cache or value is undefined or null`) } + + const oldValue = this.cache[key] + + // Only include keys that actually changed + if (!this.isEqual(oldValue, value)) { + actualUpdates[key] = value + oldValues[key] = oldValue + } else { + skippedCount++ + } } + // Early return if no values actually changed + if (Object.keys(actualUpdates).length === 0) { + logger.debug(`All ${Object.keys(updates).length} preference values unchanged, skipping batch update`) + return + } + + // Only update items that actually changed await dbService.getDb().transaction(async (tx) => { - for (const [key, value] of Object.entries(updates)) { + for (const [key, value] of Object.entries(actualUpdates)) { await tx .update(preferenceTable) .set({ @@ -172,18 +210,22 @@ export class PreferenceService { } }) - // Update memory cache for all changed keys - for (const [key, value] of Object.entries(updates)) { + // Update memory cache for changed keys only + for (const [key, value] of Object.entries(actualUpdates)) { if (key in this.cache) { this.cache[key] = value } } - // Broadcast all changes - const changePromises = Object.entries(updates).map(([key, value]) => this.notifyChange(key, value)) + // Unified batch notification for changed values only + const changePromises = Object.entries(actualUpdates).map(([key, value]) => + this.notifyChange(key, value, oldValues[key]) + ) await Promise.all(changePromises) - logger.debug(`Updated ${Object.keys(updates).length} preferences successfully`) + logger.debug( + `Updated ${Object.keys(actualUpdates).length}/${Object.keys(updates).length} preferences successfully (${skippedCount} unchanged)` + ) } catch (error) { logger.error('Failed to set multiple preferences:', error as Error) throw error @@ -194,7 +236,7 @@ export class PreferenceService { * Subscribe a window to preference changes * Window will receive notifications for specified keys */ - subscribe(windowId: number, keys: string[]): void { + public subscribeForWindow(windowId: number, keys: string[]): void { if (!this.subscriptions.has(windowId)) { this.subscriptions.set(windowId, new Set()) } @@ -208,15 +250,77 @@ export class PreferenceService { /** * Unsubscribe a window from preference changes */ - unsubscribe(windowId: number): void { + public unsubscribeForWindow(windowId: number): void { this.subscriptions.delete(windowId) logger.debug(`Window ${windowId} unsubscribed from preference changes`) } /** - * Broadcast preference change to all subscribed windows + * Subscribe to preference changes in main process + * Returns unsubscribe function for cleanup */ - private async notifyChange(key: string, value: any): Promise { + public subscribeChange( + key: K, + callback: (newValue: PreferenceDefaultScopeType[K], oldValue: PreferenceDefaultScopeType[K]) => void + ): () => void { + const listener = (changedKey: string, newValue: any, oldValue: any) => { + if (changedKey === key) { + callback(newValue, oldValue) + } + } + + this.mainEventEmitter.on('preference-changed', listener) + + return () => { + this.mainEventEmitter.off('preference-changed', listener) + } + } + + /** + * Subscribe to multiple preference changes in main process + * Returns unsubscribe function for cleanup + */ + public subscribeMultipleChanges( + keys: PreferenceKeyType[], + callback: (key: PreferenceKeyType, newValue: any, oldValue: any) => void + ): () => void { + const listener = (changedKey: string, newValue: any, oldValue: any) => { + if (keys.includes(changedKey as PreferenceKeyType)) { + callback(changedKey as PreferenceKeyType, newValue, oldValue) + } + } + + this.mainEventEmitter.on('preference-changed', listener) + + return () => { + this.mainEventEmitter.off('preference-changed', listener) + } + } + + /** + * Remove all main process listeners for cleanup + */ + public removeAllChangeListeners(): void { + this.mainEventEmitter.removeAllListeners('preference-changed') + logger.debug('Removed all main process preference listeners') + } + + /** + * Get main process listener count for debugging + */ + public getChangeListenerCount(): number { + return this.mainEventEmitter.listenerCount('preference-changed') + } + + /** + * Unified notification method for both main and renderer processes + * Broadcasts preference changes to main process listeners and subscribed renderer windows + */ + private async notifyChange(key: string, value: any, oldValue?: any): Promise { + // 1. Notify main process listeners + this.mainEventEmitter.emit('preference-changed', key, value, oldValue) + + // 2. Notify renderer process windows const affectedWindows: number[] = [] for (const [windowId, subscribedKeys] of this.subscriptions.entries()) { @@ -226,10 +330,11 @@ export class PreferenceService { } if (affectedWindows.length === 0) { + logger.debug(`Preference ${key} changed, notified main listeners only`) return } - // Send to all affected windows + // Send to all affected renderer windows for (const windowId of affectedWindows) { try { const window = BrowserWindow.fromId(windowId) @@ -245,7 +350,7 @@ export class PreferenceService { } } - logger.debug(`Broadcasted preference change ${key} to ${affectedWindows.length} windows`) + logger.debug(`Preference ${key} changed, notified main listeners and ${affectedWindows.length} renderer windows`) } /** @@ -276,7 +381,7 @@ export class PreferenceService { * Get all preferences from memory cache * Returns complete preference object for bulk operations */ - getAll(): PreferenceDefaultScopeType { + public getAll(): PreferenceDefaultScopeType { if (!this.initialized) { logger.warn('Preference cache not initialized, returning defaults') return DefaultPreferences.default @@ -288,9 +393,90 @@ export class PreferenceService { /** * Get all current subscriptions (for debugging) */ - getSubscriptions(): Map> { + public getSubscriptions(): Map> { return new Map(this.subscriptions) } + + /** + * Deep equality check for preference values + * Handles primitives, arrays, and plain objects + */ + private isEqual(a: any, b: any): boolean { + // Handle strict equality (primitives, same reference) + if (a === b) return true + + // Handle null/undefined + if (a == null || b == null) return a === b + + // Handle different types + if (typeof a !== typeof b) return false + + // Handle arrays + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false + return a.every((item, index) => this.isEqual(item, b[index])) + } + + // Handle objects (plain objects only) + if (typeof a === 'object' && typeof b === 'object') { + // Check if both are plain objects + if (Object.getPrototypeOf(a) !== Object.prototype || Object.getPrototypeOf(b) !== Object.prototype) { + return false + } + + const keysA = Object.keys(a) + const keysB = Object.keys(b) + + if (keysA.length !== keysB.length) return false + + return keysA.every((key) => keysB.includes(key) && this.isEqual(a[key], b[key])) + } + + return false + } + + /** + * Register IPC handlers for preference operations + * Provides communication interface between main and renderer processes + */ + public static registerIpcHandler(): void { + if (this.isIpcHandlerRegistered) return + + const instance = PreferenceService.getInstance() + + ipcMain.handle(IpcChannel.Preference_Get, (_, key: PreferenceKeyType) => { + return instance.get(key) + }) + + ipcMain.handle( + IpcChannel.Preference_Set, + async (_, key: PreferenceKeyType, value: PreferenceDefaultScopeType[PreferenceKeyType]) => { + await instance.set(key, value) + } + ) + + ipcMain.handle(IpcChannel.Preference_GetMultiple, (_, keys: PreferenceKeyType[]) => { + return instance.getMultiple(keys) + }) + + ipcMain.handle(IpcChannel.Preference_SetMultiple, async (_, updates: Partial) => { + await instance.setMultiple(updates) + }) + + ipcMain.handle(IpcChannel.Preference_GetAll, () => { + return instance.getAll() + }) + + ipcMain.handle(IpcChannel.Preference_Subscribe, async (event, keys: string[]) => { + const windowId = BrowserWindow.fromWebContents(event.sender)?.id + if (windowId) { + instance.subscribeForWindow(windowId, keys) + } + }) + + this.isIpcHandlerRegistered = true + logger.info('PreferenceService IPC handlers registered') + } } // Export singleton instance diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 6d23ec46f6..c7ae15a123 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -2,14 +2,13 @@ import fs from 'node:fs' import { arch } from 'node:os' import path from 'node:path' -import { preferenceService } from '@data/PreferenceService' +import { PreferenceService } from '@data/PreferenceService' import { loggerService } from '@logger' import { isLinux, isMac, isPortable, isWin } from '@main/constant' import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process' import { handleZoomFactor } from '@main/utils/zoom' import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core' import { MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH, UpgradeChannel } from '@shared/config/constant' -import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/types' import { IpcChannel } from '@shared/IpcChannel' import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types' import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron' @@ -708,35 +707,5 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.CodeTools_Run, codeToolsService.run) // Preference handlers - // TODO move to preferenceService - - ipcMain.handle(IpcChannel.Preference_Get, (_, key: PreferenceKeyType) => { - return preferenceService.get(key) - }) - - ipcMain.handle( - IpcChannel.Preference_Set, - async (_, key: PreferenceKeyType, value: PreferenceDefaultScopeType[PreferenceKeyType]) => { - await preferenceService.set(key, value) - } - ) - - ipcMain.handle(IpcChannel.Preference_GetMultiple, (_, keys: PreferenceKeyType[]) => { - return preferenceService.getMultiple(keys) - }) - - ipcMain.handle(IpcChannel.Preference_SetMultiple, async (_, updates: Partial) => { - await preferenceService.setMultiple(updates) - }) - - ipcMain.handle(IpcChannel.Preference_GetAll, () => { - return preferenceService.getAll() - }) - - ipcMain.handle(IpcChannel.Preference_Subscribe, async (event, keys: string[]) => { - const windowId = BrowserWindow.fromWebContents(event.sender)?.id - if (windowId) { - preferenceService.subscribe(windowId, keys) - } - }) + PreferenceService.registerIpcHandler() }