mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-07 13:59:28 +08:00
refactor(preferences): consolidate IPC handlers into PreferenceService
- Moved IPC handler logic for preference operations from ipc.ts to PreferenceService. - Introduced a unified method for registering IPC handlers, improving code organization and maintainability. - Enhanced preference change notification system with optimized performance and added support for main process listeners.
This commit is contained in:
parent
99be38c325
commit
30e6883333
@ -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<void> {
|
||||
public async initialize(): Promise<void> {
|
||||
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<K extends PreferenceKeyType>(key: K): PreferenceDefaultScopeType[K] {
|
||||
public get<K extends PreferenceKeyType>(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<K extends PreferenceKeyType>(key: K, value: PreferenceDefaultScopeType[K]): Promise<void> {
|
||||
public async set<K extends PreferenceKeyType>(key: K, value: PreferenceDefaultScopeType[K]): Promise<void> {
|
||||
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<K extends PreferenceKeyType>(keys: K[]): MultiPreferencesResultType<K> {
|
||||
public getMultiple<K extends PreferenceKeyType>(keys: K[]): MultiPreferencesResultType<K> {
|
||||
if (!this.initialized) {
|
||||
logger.warn('Preference cache not initialized, returning defaults for multiple keys')
|
||||
const output: MultiPreferencesResultType<K> = {} as MultiPreferencesResultType<K>
|
||||
@ -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<PreferenceDefaultScopeType>): Promise<void> {
|
||||
public async setMultiple(updates: Partial<PreferenceDefaultScopeType>): Promise<void> {
|
||||
try {
|
||||
//check if all keys are in the cache
|
||||
// Performance optimization: filter out unchanged values
|
||||
const actualUpdates: Record<string, any> = {}
|
||||
const oldValues: Record<string, any> = {}
|
||||
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<void> {
|
||||
public subscribeChange<K extends PreferenceKeyType>(
|
||||
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<void> {
|
||||
// 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<number, Set<string>> {
|
||||
public getSubscriptions(): Map<number, Set<string>> {
|
||||
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<PreferenceDefaultScopeType>) => {
|
||||
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
|
||||
|
||||
@ -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<PreferenceDefaultScopeType>) => {
|
||||
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()
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user