From 087e825086f05db6ed1d92018b91637b87784655 Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Fri, 15 Aug 2025 19:45:13 +0800 Subject: [PATCH] feat(preferences): implement custom notifier for preference change management - Introduced `PreferenceNotifier` class to replace `EventEmitter`, enhancing performance and memory efficiency for preference change notifications. - Refactored `PreferenceService` to utilize the new notifier for managing subscriptions and notifications. - Updated `SelectionService` to adopt the new subscription model, improving the handling of preference changes and ensuring proper cleanup of listeners. - Enhanced subscription statistics and debugging capabilities within the notifier. --- src/main/data/PreferenceService.ts | 173 ++++++++++++++++++++++---- src/main/services/SelectionService.ts | 74 ++++++----- 2 files changed, 190 insertions(+), 57 deletions(-) diff --git a/src/main/data/PreferenceService.ts b/src/main/data/PreferenceService.ts index ee6fd338f5..74ed0e4b1f 100644 --- a/src/main/data/PreferenceService.ts +++ b/src/main/data/PreferenceService.ts @@ -5,12 +5,124 @@ import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data import { IpcChannel } from '@shared/IpcChannel' import { and, eq } from 'drizzle-orm' import { BrowserWindow, ipcMain } from 'electron' -import { EventEmitter } from 'events' import { preferenceTable } from './db/schemas/preference' const logger = loggerService.withContext('PreferenceService') +/** + * Custom observer pattern implementation for preference change notifications + * Replaces EventEmitter to avoid listener limits and improve performance + * Optimized for memory efficiency and this binding safety + */ +class PreferenceNotifier { + private subscriptions = new Map void>>() + + /** + * Subscribe to preference changes for a specific key + * Uses arrow function to ensure proper this binding + * @param key - The preference key to watch + * @param callback - Function to call when the preference changes + * @param metadata - Optional metadata for debugging (unused but kept for API compatibility) + * @returns Unsubscribe function + */ + subscribe = ( + key: string, + callback: (key: string, newValue: any, oldValue?: any) => void, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _metadata?: string + ): (() => void) => { + if (!this.subscriptions.has(key)) { + this.subscriptions.set(key, new Set()) + } + + const keySubscriptions = this.subscriptions.get(key)! + keySubscriptions.add(callback) + + logger.debug(`Added subscription for ${key}, total for this key: ${keySubscriptions.size}`) + + // Return unsubscriber with proper this binding + return () => { + const currentKeySubscriptions = this.subscriptions.get(key) + if (currentKeySubscriptions) { + currentKeySubscriptions.delete(callback) + if (currentKeySubscriptions.size === 0) { + this.subscriptions.delete(key) + logger.debug(`Removed last subscription for ${key}, cleaned up key`) + } else { + logger.debug(`Removed subscription for ${key}, remaining: ${currentKeySubscriptions.size}`) + } + } + } + } + + /** + * Notify all subscribers of a preference change + * Uses arrow function to ensure proper this binding + * @param key - The preference key that changed + * @param newValue - The new value + * @param oldValue - The previous value + */ + notify = (key: string, newValue: any, oldValue?: any): void => { + const keySubscriptions = this.subscriptions.get(key) + if (keySubscriptions && keySubscriptions.size > 0) { + logger.debug(`Notifying ${keySubscriptions.size} subscribers for preference ${key}`) + keySubscriptions.forEach((callback) => { + try { + callback(key, newValue, oldValue) + } catch (error) { + logger.error(`Error in preference subscription callback for ${key}:`, error as Error) + } + }) + } + } + + /** + * Get the total number of subscriptions across all keys + */ + getTotalSubscriptionCount = (): number => { + let total = 0 + for (const keySubscriptions of this.subscriptions.values()) { + total += keySubscriptions.size + } + return total + } + + /** + * Get the number of subscriptions for a specific key + */ + getKeySubscriptionCount = (key: string): number => { + return this.subscriptions.get(key)?.size || 0 + } + + /** + * Get all subscribed keys + */ + getSubscribedKeys = (): string[] => { + return Array.from(this.subscriptions.keys()) + } + + /** + * Remove all subscriptions for cleanup + */ + removeAllSubscriptions = (): void => { + const totalCount = this.getTotalSubscriptionCount() + this.subscriptions.clear() + logger.debug(`Removed all ${totalCount} preference subscriptions`) + } + + /** + * Get subscription statistics for debugging + */ + getSubscriptionStats = (): Record => { + const stats: Record = {} + for (const [key, keySubscriptions] of this.subscriptions.entries()) { + stats[key] = keySubscriptions.size + } + return stats + } +} + type MultiPreferencesResultType = { [P in K]: PreferenceDefaultScopeType[P] | undefined } const DefaultScope = 'default' @@ -34,8 +146,8 @@ export class PreferenceService { private static isIpcHandlerRegistered = false - // EventEmitter for main process change notifications - private mainEventEmitter = new EventEmitter() + // Custom notifier for main process change notifications + private notifier = new PreferenceNotifier() private constructor() { this.setupWindowCleanup() @@ -94,20 +206,6 @@ export class PreferenceService { return this.cache[key] ?? DefaultPreferences.default[key] } - /** - * Get a single preference value from memory cache and subscribe to changes - * @param key - The preference key to get - * @param callback - The callback function to call when the preference changes - * @returns The current value of the preference - */ - public getAndSubscribeChange( - key: K, - callback: (newValue: PreferenceDefaultScopeType[K], oldValue?: PreferenceDefaultScopeType[K]) => void - ): PreferenceDefaultScopeType[K] { - const value = this.get(key) - this.subscribeChange(key, callback) - return value - } /** * Set a single preference value * Updates both database and memory cache, then broadcasts changes to all listeners @@ -283,11 +381,7 @@ export class PreferenceService { } } - this.mainEventEmitter.on('preference-changed', listener) - - return () => { - this.mainEventEmitter.off('preference-changed', listener) - } + return this.notifier.subscribe(key, listener, `subscribeChange-${key}`) } /** @@ -304,10 +398,14 @@ export class PreferenceService { } } - this.mainEventEmitter.on('preference-changed', listener) + // Subscribe to all keys and collect unsubscribe functions + const unsubscribeFunctions = keys.map((key) => + this.notifier.subscribe(key, listener, `subscribeMultipleChanges-${key}`) + ) + // Return a function that unsubscribes from all keys return () => { - this.mainEventEmitter.off('preference-changed', listener) + unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()) } } @@ -315,7 +413,7 @@ export class PreferenceService { * Remove all main process listeners for cleanup */ public removeAllChangeListeners(): void { - this.mainEventEmitter.removeAllListeners('preference-changed') + this.notifier.removeAllSubscriptions() logger.debug('Removed all main process preference listeners') } @@ -323,7 +421,28 @@ export class PreferenceService { * Get main process listener count for debugging */ public getChangeListenerCount(): number { - return this.mainEventEmitter.listenerCount('preference-changed') + return this.notifier.getTotalSubscriptionCount() + } + + /** + * Get subscription count for a specific preference key + */ + public getKeyListenerCount(key: PreferenceKeyType): number { + return this.notifier.getKeySubscriptionCount(key) + } + + /** + * Get all subscribed preference keys + */ + public getSubscribedKeys(): string[] { + return this.notifier.getSubscribedKeys() + } + + /** + * Get detailed subscription statistics for debugging + */ + public getSubscriptionStats(): Record { + return this.notifier.getSubscriptionStats() } /** @@ -332,7 +451,7 @@ export class PreferenceService { */ private async notifyChange(key: string, value: any, oldValue?: any): Promise { // 1. Notify main process listeners - this.mainEventEmitter.emit('preference-changed', key, value, oldValue) + this.notifier.notify(key, value, oldValue) // 2. Notify renderer process windows const affectedWindows: number[] = [] diff --git a/src/main/services/SelectionService.ts b/src/main/services/SelectionService.ts index 855cdc0bb4..5a9803c458 100644 --- a/src/main/services/SelectionService.ts +++ b/src/main/services/SelectionService.ts @@ -76,6 +76,8 @@ export class SelectionService { private filterMode = 'default' private filterList: string[] = [] + private unsubscriberForChangeListeners: (() => void)[] = [] + private toolbarWindow: BrowserWindow | null = null private actionWindows = new Set() private preloadedActionWindows: BrowserWindow[] = [] @@ -143,13 +145,15 @@ export class SelectionService { * Ensures UI elements scale properly with system DPI settings */ private initZoomFactor(): void { - const zoomFactor = preferenceService.getAndSubscribeChange('app.zoom_factor', (zoomFactor: number) => { - this.setZoomFactor(zoomFactor) - }) + const zoomFactor = preferenceService.get('app.zoom_factor') if (zoomFactor) { this.setZoomFactor(zoomFactor) } + + preferenceService.subscribeChange('app.zoom_factor', (zoomFactor: number) => { + this.setZoomFactor(zoomFactor) + }) } public setZoomFactor = (zoomFactor: number) => { @@ -157,9 +161,17 @@ export class SelectionService { } private initConfig(): void { - this.triggerMode = preferenceService.getAndSubscribeChange( - 'feature.selection.trigger_mode', - (triggerMode: string) => { + this.triggerMode = preferenceService.get('feature.selection.trigger_mode') as TriggerMode + this.isFollowToolbar = preferenceService.get('feature.selection.follow_toolbar') + this.isRemeberWinSize = preferenceService.get('feature.selection.remember_win_size') + this.filterMode = preferenceService.get('feature.selection.filter_mode') + this.filterList = preferenceService.get('feature.selection.filter_list') + + this.setHookGlobalFilterMode(this.filterMode, this.filterList) + this.setHookFineTunedList() + + this.unsubscriberForChangeListeners.push( + preferenceService.subscribeChange('feature.selection.trigger_mode', (triggerMode: string) => { const oldTriggerMode = this.triggerMode as TriggerMode this.triggerMode = triggerMode as TriggerMode @@ -169,17 +181,15 @@ export class SelectionService { if (oldTriggerMode !== triggerMode) { this.setHookGlobalFilterMode(this.filterMode, this.filterList) } - } - ) as TriggerMode - this.isFollowToolbar = preferenceService.getAndSubscribeChange( - 'feature.selection.follow_toolbar', - (followToolbar: boolean) => { - this.isFollowToolbar = followToolbar - } + }) ) - this.isRemeberWinSize = preferenceService.getAndSubscribeChange( - 'feature.selection.remember_win_size', - (rememberWinSize: boolean) => { + this.unsubscriberForChangeListeners.push( + preferenceService.subscribeChange('feature.selection.follow_toolbar', (followToolbar: boolean) => { + this.isFollowToolbar = followToolbar + }) + ) + this.unsubscriberForChangeListeners.push( + preferenceService.subscribeChange('feature.selection.remember_win_size', (rememberWinSize: boolean) => { this.isRemeberWinSize = rememberWinSize //when off, reset the last action window size to default if (!this.isRemeberWinSize) { @@ -188,22 +198,20 @@ export class SelectionService { height: this.ACTION_WINDOW_HEIGHT } } - } + }) ) - this.filterMode = preferenceService.getAndSubscribeChange('feature.selection.filter_mode', (filterMode: string) => { - this.filterMode = filterMode - this.setHookGlobalFilterMode(this.filterMode, this.filterList) - }) - this.filterList = preferenceService.getAndSubscribeChange( - 'feature.selection.filter_list', - (filterList: string[]) => { + this.unsubscriberForChangeListeners.push( + preferenceService.subscribeChange('feature.selection.filter_mode', (filterMode: string) => { + this.filterMode = filterMode + this.setHookGlobalFilterMode(this.filterMode, this.filterList) + }) + ) + this.unsubscriberForChangeListeners.push( + preferenceService.subscribeChange('feature.selection.filter_list', (filterList: string[]) => { this.filterList = filterList this.setHookGlobalFilterMode(this.filterMode, this.filterList) - } + }) ) - - this.setHookGlobalFilterMode(this.filterMode, this.filterList) - this.setHookFineTunedList() } /** @@ -342,9 +350,13 @@ export class SelectionService { if (!this.selectionHook) return false this.selectionHook.stop() - this.selectionHook.cleanup() //already remove all listeners + for (const unsubscriber of this.unsubscriberForChangeListeners) { + unsubscriber() + } + this.unsubscriberForChangeListeners = [] + //reset the listener states this.isCtrlkeyListenerActive = false this.isHideByMouseKeyListenerActive = false @@ -1532,7 +1544,9 @@ export class SelectionService { export function initSelectionService(): boolean { if (!isSupportedOS) return false - const enabled = preferenceService.getAndSubscribeChange('feature.selection.enabled', (enabled: boolean): void => { + const enabled = preferenceService.get('feature.selection.enabled') + + preferenceService.subscribeChange('feature.selection.enabled', (enabled: boolean): void => { //avoid closure const ss = SelectionService.getInstance() if (!ss) {