mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-02 02:09:03 +08:00
feat(preferences): add IPC channels and handlers for preference management
This commit introduces new IPC channels for getting, setting, and subscribing to preferences, enhancing the application's ability to manage user preferences. It also updates the preferences interface to use a consistent naming convention and refactors the preference seeding and migration processes to align with these changes. Additionally, the PrefService has been removed as it is no longer needed.
This commit is contained in:
parent
54449e7130
commit
81538d5709
@ -278,6 +278,14 @@ export enum IpcChannel {
|
||||
Memory_DeleteAllMemoriesForUser = 'memory:delete-all-memories-for-user',
|
||||
Memory_GetUsersList = 'memory:get-users-list',
|
||||
|
||||
// Preference
|
||||
Preference_Get = 'preference:get',
|
||||
Preference_Set = 'preference:set',
|
||||
Preference_GetMultiple = 'preference:get-multiple',
|
||||
Preference_SetMultiple = 'preference:set-multiple',
|
||||
Preference_Subscribe = 'preference:subscribe',
|
||||
Preference_Changed = 'preference:changed',
|
||||
|
||||
// TRACE
|
||||
TRACE_SAVE_DATA = 'trace:saveData',
|
||||
TRACE_GET_DATA = 'trace:getData',
|
||||
|
||||
@ -332,7 +332,7 @@ export interface PreferencesType {
|
||||
}
|
||||
|
||||
/* eslint sort-keys: ["error", "asc", {"caseSensitive": true, "natural": false}] */
|
||||
export const defaultPreferences: PreferencesType = {
|
||||
export const DefaultPreferences: PreferencesType = {
|
||||
default: {
|
||||
'app.developer_mode.enabled': false,
|
||||
'app.disable_hardware_acceleration': false,
|
||||
|
||||
311
src/main/data/PreferenceService.ts
Normal file
311
src/main/data/PreferenceService.ts
Normal file
@ -0,0 +1,311 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { PreferencesType } from '@shared/data/preferences'
|
||||
import { DefaultPreferences } from '@shared/data/preferences'
|
||||
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 PreferenceKey = keyof PreferencesType['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: Record<string, any> = { ...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, 'default'))
|
||||
|
||||
// Update cache with database values, keeping defaults for missing keys
|
||||
for (const result of results) {
|
||||
const key = result.key as PreferenceKey
|
||||
if (key in this.cache) {
|
||||
this.cache[key] = result.value as any
|
||||
}
|
||||
}
|
||||
|
||||
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 = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single preference value from memory cache
|
||||
* Fast synchronous access - no database queries after initialization
|
||||
*/
|
||||
get<K extends PreferenceKey>(key: K): PreferencesType['default'][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 PreferenceKey>(key: K, value: PreferencesType['default'][K]): Promise<void> {
|
||||
try {
|
||||
const db = dbService.getDb()
|
||||
const scope = 'default'
|
||||
|
||||
// First try to update existing record
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(preferenceTable)
|
||||
.where(and(eq(preferenceTable.scope, scope), eq(preferenceTable.key, key)))
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing record
|
||||
await db
|
||||
.update(preferenceTable)
|
||||
.set({
|
||||
value: value as any
|
||||
})
|
||||
.where(and(eq(preferenceTable.scope, scope), eq(preferenceTable.key, key)))
|
||||
} else {
|
||||
// Insert new record
|
||||
await db.insert(preferenceTable).values({
|
||||
scope,
|
||||
key,
|
||||
value: value as any
|
||||
})
|
||||
}
|
||||
|
||||
// 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(keys: string[]): Record<string, any> {
|
||||
if (!this.initialized) {
|
||||
logger.warn('Preference cache not initialized, returning defaults for multiple keys')
|
||||
const output: Record<string, any> = {}
|
||||
for (const key of keys) {
|
||||
if (key in DefaultPreferences.default) {
|
||||
output[key] = DefaultPreferences.default[key as PreferenceKey]
|
||||
} else {
|
||||
output[key] = undefined
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
const output: Record<string, any> = {}
|
||||
for (const key of keys) {
|
||||
if (key in this.cache) {
|
||||
output[key] = this.cache[key as PreferenceKey]
|
||||
} 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: Record<string, any>): Promise<void> {
|
||||
try {
|
||||
const scope = 'default'
|
||||
|
||||
await dbService.transaction(async (tx) => {
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
// Check if record exists
|
||||
const existing = await tx
|
||||
.select()
|
||||
.from(preferenceTable)
|
||||
.where(and(eq(preferenceTable.scope, scope), eq(preferenceTable.key, key)))
|
||||
.limit(1)
|
||||
|
||||
if (existing.length > 0) {
|
||||
// Update existing record
|
||||
await tx
|
||||
.update(preferenceTable)
|
||||
.set({
|
||||
value
|
||||
})
|
||||
.where(and(eq(preferenceTable.scope, scope), eq(preferenceTable.key, key)))
|
||||
} else {
|
||||
// Insert new record
|
||||
await tx.insert(preferenceTable).values({
|
||||
scope,
|
||||
key,
|
||||
value
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Update memory cache for all changed keys
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (key in this.cache) {
|
||||
this.cache[key as PreferenceKey] = 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, 'default')
|
||||
} 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 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
|
||||
@ -1,5 +1,5 @@
|
||||
import { preferenceTable } from '@data/db/schemas/preference'
|
||||
import { defaultPreferences } from '@shared/data/preferences'
|
||||
import { DefaultPreferences } from '@shared/data/preferences'
|
||||
|
||||
import type { DbType, ISeed } from '../types'
|
||||
|
||||
@ -18,7 +18,7 @@ class PreferenceSeed implements ISeed {
|
||||
}> = []
|
||||
|
||||
// Process each scope in defaultPreferences
|
||||
for (const [scope, scopeData] of Object.entries(defaultPreferences)) {
|
||||
for (const [scope, scopeData] of Object.entries(DefaultPreferences)) {
|
||||
// Process each key-value pair in the scope
|
||||
for (const [key, value] of Object.entries(scopeData)) {
|
||||
const prefKey = `${scope}.${key}`
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import dbService from '@data/db/DbService'
|
||||
import { preferenceTable } from '@data/db/schemas/preference'
|
||||
import { loggerService } from '@logger'
|
||||
import { defaultPreferences } from '@shared/data/preferences'
|
||||
import { DefaultPreferences } from '@shared/data/preferences'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
|
||||
import { configManager } from '../../../../services/ConfigManager'
|
||||
@ -151,7 +151,7 @@ export class PreferencesMigrator {
|
||||
|
||||
// Process ElectronStore mappings - no sourceCategory needed
|
||||
ELECTRON_STORE_MAPPINGS.forEach((mapping) => {
|
||||
const defaultValue = defaultPreferences.default[mapping.targetKey] ?? null
|
||||
const defaultValue = DefaultPreferences.default[mapping.targetKey] ?? null
|
||||
items.push({
|
||||
originalKey: mapping.originalKey,
|
||||
targetKey: mapping.targetKey,
|
||||
@ -164,7 +164,7 @@ export class PreferencesMigrator {
|
||||
// Process Redux mappings
|
||||
Object.entries(REDUX_STORE_MAPPINGS).forEach(([category, mappings]) => {
|
||||
mappings.forEach((mapping) => {
|
||||
const defaultValue = defaultPreferences.default[mapping.targetKey] ?? null
|
||||
const defaultValue = DefaultPreferences.default[mapping.targetKey] ?? null
|
||||
items.push({
|
||||
originalKey: mapping.originalKey, // May contain nested paths like "codeEditor.enabled"
|
||||
targetKey: mapping.targetKey,
|
||||
|
||||
@ -13,6 +13,7 @@ import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
|
||||
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
|
||||
import { Notification } from 'src/renderer/src/types/notification'
|
||||
|
||||
import preferenceService from './data/PreferenceService'
|
||||
import appService from './services/AppService'
|
||||
import AppUpdater from './services/AppUpdater'
|
||||
import BackupManager from './services/BackupManager'
|
||||
@ -696,4 +697,28 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) {
|
||||
(_, spanId: string, modelName: string, context: string, msg: any) =>
|
||||
addStreamMessage(spanId, modelName, context, msg)
|
||||
)
|
||||
|
||||
// Preference handlers
|
||||
ipcMain.handle(IpcChannel.Preference_Get, async (_, key: string) => {
|
||||
return preferenceService.get(key as any)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Preference_Set, async (_, key: string, value: any) => {
|
||||
await preferenceService.set(key as any, value)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Preference_GetMultiple, async (_, keys: string[]) => {
|
||||
return preferenceService.getMultiple(keys)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Preference_SetMultiple, async (_, updates: Record<string, any>) => {
|
||||
await preferenceService.setMultiple(updates)
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.Preference_Subscribe, async (event, keys: string[]) => {
|
||||
const windowId = BrowserWindow.fromWebContents(event.sender)?.id
|
||||
if (windowId) {
|
||||
preferenceService.subscribe(windowId, keys)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -1 +0,0 @@
|
||||
class PrefService {}
|
||||
@ -4,6 +4,7 @@ import { SpanEntity, TokenUsage } from '@mcp-trace/trace-core'
|
||||
import { SpanContext } from '@opentelemetry/api'
|
||||
import { UpgradeChannel } from '@shared/config/constant'
|
||||
import type { LogLevel, LogSourceWithContext } from '@shared/config/logger'
|
||||
import type { PreferencesType } from '@shared/data/preferences'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import {
|
||||
AddMemoryOptions,
|
||||
@ -393,6 +394,24 @@ const api = {
|
||||
cleanLocalData: () => ipcRenderer.invoke(IpcChannel.TRACE_CLEAN_LOCAL_DATA),
|
||||
addStreamMessage: (spanId: string, modelName: string, context: string, message: any) =>
|
||||
ipcRenderer.invoke(IpcChannel.TRACE_ADD_STREAM_MESSAGE, spanId, modelName, context, message)
|
||||
},
|
||||
preference: {
|
||||
get: <K extends keyof PreferencesType['default']>(key: K) => ipcRenderer.invoke(IpcChannel.Preference_Get, key),
|
||||
|
||||
set: <K extends keyof PreferencesType['default']>(key: K, value: PreferencesType['default'][K]) =>
|
||||
ipcRenderer.invoke(IpcChannel.Preference_Set, key, value),
|
||||
|
||||
getMultiple: (keys: string[]) => ipcRenderer.invoke(IpcChannel.Preference_GetMultiple, keys),
|
||||
|
||||
setMultiple: (updates: Record<string, any>) => ipcRenderer.invoke(IpcChannel.Preference_SetMultiple, updates),
|
||||
|
||||
subscribe: (keys: string[]) => ipcRenderer.invoke(IpcChannel.Preference_Subscribe, keys),
|
||||
|
||||
onChanged: (callback: (key: string, value: any, scope: string) => void) => {
|
||||
const listener = (_: any, key: string, value: any, scope: string) => callback(key, value, scope)
|
||||
ipcRenderer.on(IpcChannel.Preference_Changed, listener)
|
||||
return () => ipcRenderer.off(IpcChannel.Preference_Changed, listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
322
src/renderer/src/data/PreferenceService.ts
Normal file
322
src/renderer/src/data/PreferenceService.ts
Normal file
@ -0,0 +1,322 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { PreferencesType } from '@shared/data/preferences'
|
||||
import { DefaultPreferences } from '@shared/data/preferences'
|
||||
|
||||
const logger = loggerService.withContext('PreferenceService')
|
||||
|
||||
type PreferenceKey = keyof PreferencesType['default']
|
||||
|
||||
/**
|
||||
* Renderer-side PreferenceService providing cached access to preferences
|
||||
* with real-time synchronization across windows using useSyncExternalStore
|
||||
*/
|
||||
export class PreferenceService {
|
||||
private static instance: PreferenceService
|
||||
private cache = new Map<string, any>()
|
||||
private listeners = new Set<() => void>()
|
||||
private keyListeners = new Map<string, Set<() => void>>()
|
||||
private changeListenerCleanup: (() => void) | null = null
|
||||
private subscribedKeys = new Set<string>()
|
||||
|
||||
private constructor() {
|
||||
this.setupChangeListener()
|
||||
// Initialize window source for logging if not already done
|
||||
if (typeof loggerService.initWindowSource === 'function') {
|
||||
try {
|
||||
loggerService.initWindowSource('main')
|
||||
} catch (error) {
|
||||
// Window source already initialized, ignore error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of PreferenceService
|
||||
*/
|
||||
static getInstance(): PreferenceService {
|
||||
if (!PreferenceService.instance) {
|
||||
PreferenceService.instance = new PreferenceService()
|
||||
}
|
||||
return PreferenceService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup IPC change listener for preference updates from main process
|
||||
*/
|
||||
private setupChangeListener() {
|
||||
if (!window.api?.preference?.onChanged) {
|
||||
logger.error('Preference API not available in preload context')
|
||||
return
|
||||
}
|
||||
|
||||
this.changeListenerCleanup = window.api.preference.onChanged((key, value, scope) => {
|
||||
// Only handle default scope since we simplified API
|
||||
if (scope !== 'default') {
|
||||
return
|
||||
}
|
||||
|
||||
const oldValue = this.cache.get(key)
|
||||
|
||||
if (oldValue !== value) {
|
||||
this.cache.set(key, value)
|
||||
this.notifyListeners(key)
|
||||
logger.debug(`Preference ${key} updated to:`, { value })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all relevant listeners about preference changes
|
||||
*/
|
||||
private notifyListeners(key: string) {
|
||||
// Notify global listeners
|
||||
this.listeners.forEach((listener) => listener())
|
||||
|
||||
// Notify specific key listeners
|
||||
const keyListeners = this.keyListeners.get(key)
|
||||
if (keyListeners) {
|
||||
keyListeners.forEach((listener) => listener())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single preference value with caching
|
||||
*/
|
||||
async get<K extends PreferenceKey>(key: K): Promise<PreferencesType['default'][K]> {
|
||||
// Check cache first
|
||||
if (this.cache.has(key)) {
|
||||
return this.cache.get(key)
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch from main process if not cached
|
||||
const value = await window.api.preference.get(key)
|
||||
this.cache.set(key, value)
|
||||
|
||||
// Auto-subscribe to this key for future updates
|
||||
if (!this.subscribedKeys.has(key)) {
|
||||
await this.subscribeToKeyInternal(key)
|
||||
}
|
||||
|
||||
return value
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get preference ${key}:`, error as Error)
|
||||
// Return default value on error
|
||||
return DefaultPreferences.default[key] as PreferencesType['default'][K]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a single preference value
|
||||
*/
|
||||
async set<K extends PreferenceKey>(key: K, value: PreferencesType['default'][K]): Promise<void> {
|
||||
try {
|
||||
await window.api.preference.set(key, value)
|
||||
|
||||
// Update local cache immediately for responsive UI
|
||||
this.cache.set(key, value)
|
||||
this.notifyListeners(key)
|
||||
|
||||
logger.debug(`Preference ${key} set to:`, { value })
|
||||
} catch (error) {
|
||||
logger.error(`Failed to set preference ${key}:`, error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple preferences at once
|
||||
*/
|
||||
async getMultiple(keys: string[]): Promise<Record<string, any>> {
|
||||
// Check which keys are already cached
|
||||
const cachedResults: Record<string, any> = {}
|
||||
const uncachedKeys: string[] = []
|
||||
|
||||
for (const key of keys) {
|
||||
if (this.cache.has(key)) {
|
||||
cachedResults[key] = this.cache.get(key)
|
||||
} else {
|
||||
uncachedKeys.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch uncached keys from main process
|
||||
if (uncachedKeys.length > 0) {
|
||||
try {
|
||||
const uncachedResults = await window.api.preference.getMultiple(uncachedKeys)
|
||||
|
||||
// Update cache with new results
|
||||
for (const [key, value] of Object.entries(uncachedResults)) {
|
||||
this.cache.set(key, value)
|
||||
}
|
||||
|
||||
// Auto-subscribe to new keys
|
||||
for (const key of uncachedKeys) {
|
||||
if (!this.subscribedKeys.has(key)) {
|
||||
await this.subscribeToKeyInternal(key)
|
||||
}
|
||||
}
|
||||
|
||||
return { ...cachedResults, ...uncachedResults }
|
||||
} catch (error) {
|
||||
logger.error('Failed to get multiple preferences:', error as Error)
|
||||
|
||||
// Fill in default values for failed keys
|
||||
const defaultResults: Record<string, any> = {}
|
||||
for (const key of uncachedKeys) {
|
||||
if (key in DefaultPreferences.default) {
|
||||
defaultResults[key] = DefaultPreferences.default[key as PreferenceKey]
|
||||
}
|
||||
}
|
||||
|
||||
return { ...cachedResults, ...defaultResults }
|
||||
}
|
||||
}
|
||||
|
||||
return cachedResults
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple preferences at once
|
||||
*/
|
||||
async setMultiple(updates: Record<string, any>): Promise<void> {
|
||||
try {
|
||||
await window.api.preference.setMultiple(updates)
|
||||
|
||||
// Update local cache for all updated values
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
this.cache.set(key, value)
|
||||
this.notifyListeners(key)
|
||||
}
|
||||
|
||||
logger.debug(`Updated ${Object.keys(updates).length} preferences`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to set multiple preferences:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a specific key for change notifications
|
||||
*/
|
||||
private async subscribeToKeyInternal(key: string): Promise<void> {
|
||||
if (!this.subscribedKeys.has(key)) {
|
||||
try {
|
||||
await window.api.preference.subscribe([key])
|
||||
this.subscribedKeys.add(key)
|
||||
logger.debug(`Subscribed to preference key: ${key}`)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to subscribe to preference key ${key}:`, error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to global preference changes (for useSyncExternalStore)
|
||||
*/
|
||||
subscribe = (callback: () => void): (() => void) => {
|
||||
this.listeners.add(callback)
|
||||
return () => {
|
||||
this.listeners.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to specific key changes (for useSyncExternalStore)
|
||||
*/
|
||||
subscribeToKey =
|
||||
(key: string) =>
|
||||
(callback: () => void): (() => void) => {
|
||||
if (!this.keyListeners.has(key)) {
|
||||
this.keyListeners.set(key, new Set())
|
||||
}
|
||||
|
||||
const keyListeners = this.keyListeners.get(key)!
|
||||
keyListeners.add(callback)
|
||||
|
||||
// Auto-subscribe to this key for updates
|
||||
this.subscribeToKeyInternal(key)
|
||||
|
||||
return () => {
|
||||
keyListeners.delete(callback)
|
||||
if (keyListeners.size === 0) {
|
||||
this.keyListeners.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get snapshot for useSyncExternalStore
|
||||
*/
|
||||
getSnapshot =
|
||||
<K extends PreferenceKey>(key: K) =>
|
||||
(): PreferencesType['default'][K] | undefined => {
|
||||
return this.cache.get(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached value without async fetch
|
||||
*/
|
||||
getCachedValue<K extends PreferenceKey>(key: K): PreferencesType['default'][K] | undefined {
|
||||
return this.cache.get(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a preference is cached
|
||||
*/
|
||||
isCached(key: string): boolean {
|
||||
return this.cache.has(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload specific preferences into cache
|
||||
*/
|
||||
async preload(keys: string[]): Promise<void> {
|
||||
const uncachedKeys = keys.filter((key) => !this.isCached(key))
|
||||
|
||||
if (uncachedKeys.length > 0) {
|
||||
try {
|
||||
const values = await this.getMultiple(uncachedKeys)
|
||||
logger.debug(`Preloaded ${Object.keys(values).length} preferences`)
|
||||
} catch (error) {
|
||||
logger.error('Failed to preload preferences:', error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached preferences (for testing/debugging)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear()
|
||||
logger.debug('Preference cache cleared')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics (for debugging)
|
||||
*/
|
||||
getCacheStats(): { size: number; keys: string[] } {
|
||||
return {
|
||||
size: this.cache.size,
|
||||
keys: Array.from(this.cache.keys())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup service (call when shutting down)
|
||||
*/
|
||||
cleanup(): void {
|
||||
if (this.changeListenerCleanup) {
|
||||
this.changeListenerCleanup()
|
||||
this.changeListenerCleanup = null
|
||||
}
|
||||
this.clearCache()
|
||||
this.listeners.clear()
|
||||
this.keyListeners.clear()
|
||||
this.subscribedKeys.clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const preferenceService = PreferenceService.getInstance()
|
||||
export default preferenceService
|
||||
154
src/renderer/src/data/hooks/usePreference.ts
Normal file
154
src/renderer/src/data/hooks/usePreference.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { loggerService } from '@logger'
|
||||
import type { PreferencesType } from '@shared/data/preferences'
|
||||
import { useCallback, useEffect, useSyncExternalStore } from 'react'
|
||||
|
||||
import { preferenceService } from '../PreferenceService'
|
||||
|
||||
const logger = loggerService.withContext('usePreference')
|
||||
|
||||
type PreferenceKey = keyof PreferencesType['default']
|
||||
|
||||
/**
|
||||
* React hook for managing a single preference value
|
||||
* Uses useSyncExternalStore for optimal React 18 integration
|
||||
*
|
||||
* @param key - The preference key to manage
|
||||
* @returns [value, setValue] - Current value and setter function
|
||||
*/
|
||||
export function usePreference<K extends PreferenceKey>(
|
||||
key: K
|
||||
): [PreferencesType['default'][K] | undefined, (value: PreferencesType['default'][K]) => Promise<void>] {
|
||||
// Subscribe to changes for this specific preference
|
||||
const value = useSyncExternalStore(
|
||||
preferenceService.subscribeToKey(key),
|
||||
preferenceService.getSnapshot(key),
|
||||
() => undefined // SSR snapshot (not used in Electron context)
|
||||
)
|
||||
|
||||
// Load initial value asynchronously if not cached
|
||||
useEffect(() => {
|
||||
if (value === undefined && !preferenceService.isCached(key)) {
|
||||
preferenceService.get(key).catch((error) => {
|
||||
logger.error(`Failed to load initial preference ${key}:`, error as Error)
|
||||
})
|
||||
}
|
||||
}, [key, value])
|
||||
|
||||
// Memoized setter function
|
||||
const setValue = useCallback(
|
||||
async (newValue: PreferencesType['default'][K]) => {
|
||||
try {
|
||||
await preferenceService.set(key, newValue)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to set preference ${key}:`, error as Error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[key]
|
||||
)
|
||||
|
||||
return [value, setValue]
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for managing multiple preference values
|
||||
* Efficiently batches operations and provides type-safe interface
|
||||
*
|
||||
* @param keys - Object mapping local names to preference keys
|
||||
* @returns [values, updateValues] - Current values and batch update function
|
||||
*/
|
||||
export function usePreferences<T extends Record<string, PreferenceKey>>(
|
||||
keys: T
|
||||
): [
|
||||
{ [P in keyof T]: PreferencesType['default'][T[P]] | undefined },
|
||||
(updates: Partial<{ [P in keyof T]: PreferencesType['default'][T[P]] }>) => Promise<void>
|
||||
] {
|
||||
// Track changes to any of the specified keys
|
||||
const keyList = Object.values(keys)
|
||||
const keyListString = keyList.join(',')
|
||||
const allValues = useSyncExternalStore(
|
||||
useCallback(
|
||||
(callback) => {
|
||||
// Subscribe to all keys and aggregate the unsubscribe functions
|
||||
const unsubscribeFunctions = keyList.map((key) => preferenceService.subscribeToKey(key)(callback))
|
||||
|
||||
return () => {
|
||||
unsubscribeFunctions.forEach((unsubscribe) => unsubscribe())
|
||||
}
|
||||
},
|
||||
[keyList]
|
||||
),
|
||||
|
||||
useCallback(() => {
|
||||
// Return current snapshot of all values
|
||||
const snapshot: Record<string, any> = {}
|
||||
for (const [localKey, prefKey] of Object.entries(keys)) {
|
||||
snapshot[localKey] = preferenceService.getCachedValue(prefKey)
|
||||
}
|
||||
return snapshot
|
||||
}, [keys]),
|
||||
|
||||
() => ({}) // SSR snapshot
|
||||
)
|
||||
|
||||
// Load initial values asynchronously if not cached
|
||||
useEffect(() => {
|
||||
const uncachedKeys = keyList.filter((key) => !preferenceService.isCached(key))
|
||||
|
||||
if (uncachedKeys.length > 0) {
|
||||
preferenceService.getMultiple(uncachedKeys).catch((error) => {
|
||||
logger.error('Failed to load initial preferences:', error as Error)
|
||||
})
|
||||
}
|
||||
}, [keyList, keyListString])
|
||||
|
||||
// Memoized batch update function
|
||||
const updateValues = useCallback(
|
||||
async (updates: Partial<{ [P in keyof T]: PreferencesType['default'][T[P]] }>) => {
|
||||
try {
|
||||
// Convert local keys back to preference keys
|
||||
const prefUpdates: Record<string, any> = {}
|
||||
for (const [localKey, value] of Object.entries(updates)) {
|
||||
const prefKey = keys[localKey as keyof T]
|
||||
if (prefKey) {
|
||||
prefUpdates[prefKey] = value
|
||||
}
|
||||
}
|
||||
|
||||
await preferenceService.setMultiple(prefUpdates)
|
||||
} catch (error) {
|
||||
logger.error('Failed to update preferences:', error as Error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[keys]
|
||||
)
|
||||
|
||||
// Type-cast the values to the expected shape
|
||||
const typedValues = allValues as { [P in keyof T]: PreferencesType['default'][T[P]] | undefined }
|
||||
|
||||
return [typedValues, updateValues]
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for preloading preferences to improve performance
|
||||
* Useful for components that will use many preferences
|
||||
*
|
||||
* @param keys - Array of preference keys to preload
|
||||
*/
|
||||
export function usePreferencePreload(keys: PreferenceKey[]): void {
|
||||
const keysString = keys.join(',')
|
||||
useEffect(() => {
|
||||
preferenceService.preload(keys).catch((error) => {
|
||||
logger.error('Failed to preload preferences:', error as Error)
|
||||
})
|
||||
}, [keys, keysString])
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for getting the preference service instance
|
||||
* Useful for non-reactive operations or advanced usage
|
||||
*/
|
||||
export function usePreferenceService() {
|
||||
return preferenceService
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user