Files
cherry-studio/src/main/data/PreferenceService.ts
fullex 72f32e4b8f feat(preferences): add getAll method and IPC handler for retrieving all preferences
This commit introduces a new IPC channel and handler for fetching all user preferences at once, enhancing the efficiency of preference management. The PreferenceService is updated with a getAll method to retrieve all preferences from the memory cache, and the renderer-side PreferenceService is adjusted to support this new functionality. Additionally, type safety improvements are made across the preference handling code, ensuring better consistency and reducing potential errors.
2025-08-12 01:01:22 +08:00

300 lines
9.2 KiB
TypeScript

import { loggerService } from '@logger'
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 dbService from './db/DbService'
import { preferenceTable } from './db/schemas/preference'
const logger = loggerService.withContext('PreferenceService')
type MultiPreferencesResultType<K extends PreferenceKeyType> = { [P in K]: PreferenceDefaultScopeType[P] | undefined }
const DefaultScope = 'default'
/**
* PreferenceService manages preference data storage and synchronization across multiple windows
*
* Features:
* - Memory-cached preferences for high performance
* - SQLite database persistence using Drizzle ORM
* - Multi-window subscription and synchronization
* - Type-safe preference operations
* - Batch operations support
* - Change notification broadcasting
*/
export class PreferenceService {
private static instance: PreferenceService
private subscriptions = new Map<number, Set<string>>() // windowId -> Set<keys>
private cache: PreferenceDefaultScopeType = DefaultPreferences.default
private initialized = false
private constructor() {
this.setupWindowCleanup()
}
/**
* Get the singleton instance of PreferenceService
*/
public static getInstance(): PreferenceService {
if (!PreferenceService.instance) {
PreferenceService.instance = new PreferenceService()
}
return PreferenceService.instance
}
/**
* Initialize preference cache from database
* Should be called once at application startup
*/
async initialize(): Promise<void> {
if (this.initialized) {
return
}
try {
const db = dbService.getDb()
const results = await db.select().from(preferenceTable).where(eq(preferenceTable.scope, DefaultScope))
// Update cache with database values, keeping defaults for missing keys
for (const result of results) {
const key = result.key
if (key in this.cache) {
this.cache[key] = result.value
}
}
this.initialized = true
logger.info(`Preference cache initialized with ${results.length} values`)
} catch (error) {
logger.error('Failed to initialize preference cache:', error as Error)
// Keep default values on initialization failure
this.initialized = false
}
}
/**
* Get a single preference value from memory cache
* Fast synchronous access - no database queries after initialization
*/
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]
}
return this.cache[key] ?? DefaultPreferences.default[key]
}
/**
* Set a single preference value
* Updates both database and memory cache, then broadcasts changes to subscribed windows
*/
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 db = dbService.getDb()
await db
.update(preferenceTable)
.set({
value: value as any
})
.where(and(eq(preferenceTable.scope, DefaultScope), eq(preferenceTable.key, key)))
// Update memory cache immediately
this.cache[key] = value
// Broadcast change to subscribed windows
await this.notifyChange(key, value)
logger.debug(`Preference ${key} updated successfully`)
} catch (error) {
logger.error(`Failed to set preference ${key}:`, error as Error)
throw error
}
}
/**
* Get multiple preferences at once from memory cache
* Fast synchronous access - no database queries
*/
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>
for (const key of keys) {
if (key in DefaultPreferences.default) {
output[key] = DefaultPreferences.default[key]
} else {
output[key] = undefined as MultiPreferencesResultType<K>[K]
}
}
return output
}
const output: MultiPreferencesResultType<K> = {} as MultiPreferencesResultType<K>
for (const key of keys) {
if (key in this.cache) {
output[key] = this.cache[key]
} else {
output[key] = undefined
}
}
return output
}
/**
* Set multiple preferences at once
* Updates both database and memory cache in a transaction, then broadcasts changes
*/
async setMultiple(updates: Partial<PreferenceDefaultScopeType>): Promise<void> {
try {
//check if all keys are in the cache
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`)
}
}
await dbService.getDb().transaction(async (tx) => {
for (const [key, value] of Object.entries(updates)) {
await tx
.update(preferenceTable)
.set({
value
})
.where(and(eq(preferenceTable.scope, DefaultScope), eq(preferenceTable.key, key)))
}
})
// Update memory cache for all changed keys
for (const [key, value] of Object.entries(updates)) {
if (key in this.cache) {
this.cache[key] = value
}
}
// Broadcast all changes
const changePromises = Object.entries(updates).map(([key, value]) => this.notifyChange(key, value))
await Promise.all(changePromises)
logger.debug(`Updated ${Object.keys(updates).length} preferences successfully`)
} catch (error) {
logger.error('Failed to set multiple preferences:', error as Error)
throw error
}
}
/**
* Subscribe a window to preference changes
* Window will receive notifications for specified keys
*/
subscribe(windowId: number, keys: string[]): void {
if (!this.subscriptions.has(windowId)) {
this.subscriptions.set(windowId, new Set())
}
const windowKeys = this.subscriptions.get(windowId)!
keys.forEach((key) => windowKeys.add(key))
logger.debug(`Window ${windowId} subscribed to ${keys.length} preference keys`)
}
/**
* Unsubscribe a window from preference changes
*/
unsubscribe(windowId: number): void {
this.subscriptions.delete(windowId)
logger.debug(`Window ${windowId} unsubscribed from preference changes`)
}
/**
* Broadcast preference change to all subscribed windows
*/
private async notifyChange(key: string, value: any): Promise<void> {
const affectedWindows: number[] = []
for (const [windowId, subscribedKeys] of this.subscriptions.entries()) {
if (subscribedKeys.has(key)) {
affectedWindows.push(windowId)
}
}
if (affectedWindows.length === 0) {
return
}
// Send to all affected windows
for (const windowId of affectedWindows) {
try {
const window = BrowserWindow.fromId(windowId)
if (window && !window.isDestroyed()) {
window.webContents.send(IpcChannel.Preference_Changed, key, value, DefaultScope)
} else {
// Clean up invalid window subscription
this.subscriptions.delete(windowId)
}
} catch (error) {
logger.error(`Failed to notify window ${windowId}:`, error as Error)
this.subscriptions.delete(windowId)
}
}
logger.debug(`Broadcasted preference change ${key} to ${affectedWindows.length} windows`)
}
/**
* Setup automatic cleanup of closed window subscriptions
*/
private setupWindowCleanup(): void {
// This will be called when windows are closed
const cleanup = () => {
const validWindowIds = BrowserWindow.getAllWindows()
.filter((w) => !w.isDestroyed())
.map((w) => w.id)
const subscribedWindowIds = Array.from(this.subscriptions.keys())
const invalidWindowIds = subscribedWindowIds.filter((id) => !validWindowIds.includes(id))
invalidWindowIds.forEach((id) => this.subscriptions.delete(id))
if (invalidWindowIds.length > 0) {
logger.debug(`Cleaned up ${invalidWindowIds.length} invalid window subscriptions`)
}
}
// Run cleanup periodically (every 30 seconds)
setInterval(cleanup, 30000)
}
/**
* Get all preferences from memory cache
* Returns complete preference object for bulk operations
*/
getAll(): PreferenceDefaultScopeType {
if (!this.initialized) {
logger.warn('Preference cache not initialized, returning defaults')
return DefaultPreferences.default
}
return { ...this.cache }
}
/**
* Get all current subscriptions (for debugging)
*/
getSubscriptions(): Map<number, Set<string>> {
return new Map(this.subscriptions)
}
}
// Export singleton instance
export const preferenceService = PreferenceService.getInstance()
export default preferenceService