From 81538d57090fa99bcea10ff96c5040045b00f48d Mon Sep 17 00:00:00 2001 From: fullex <0xfullex@gmail.com> Date: Mon, 11 Aug 2025 20:40:50 +0800 Subject: [PATCH] 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. --- packages/shared/IpcChannel.ts | 8 + packages/shared/data/preferences.ts | 2 +- src/main/data/PreferenceService.ts | 311 +++++++++++++++++ src/main/data/db/seeding/preferenceSeeding.ts | 4 +- .../migrators/PreferencesMigrator.ts | 6 +- src/main/ipc.ts | 25 ++ src/main/services/PrefService.ts | 1 - src/preload/index.ts | 19 ++ src/renderer/src/data/PreferenceService.ts | 322 ++++++++++++++++++ src/renderer/src/data/hooks/usePreference.ts | 154 +++++++++ 10 files changed, 845 insertions(+), 7 deletions(-) create mode 100644 src/main/data/PreferenceService.ts delete mode 100644 src/main/services/PrefService.ts create mode 100644 src/renderer/src/data/PreferenceService.ts create mode 100644 src/renderer/src/data/hooks/usePreference.ts diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index 6050f5a498..9b376d23b9 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -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', diff --git a/packages/shared/data/preferences.ts b/packages/shared/data/preferences.ts index 1b6de8402f..49e2cf4fc8 100644 --- a/packages/shared/data/preferences.ts +++ b/packages/shared/data/preferences.ts @@ -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, diff --git a/src/main/data/PreferenceService.ts b/src/main/data/PreferenceService.ts new file mode 100644 index 0000000000..2aec6cb94c --- /dev/null +++ b/src/main/data/PreferenceService.ts @@ -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>() // windowId -> Set + private cache: Record = { ...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 { + 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(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(key: K, value: PreferencesType['default'][K]): Promise { + 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 { + if (!this.initialized) { + logger.warn('Preference cache not initialized, returning defaults for multiple keys') + const output: Record = {} + 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 = {} + 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): Promise { + 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 { + 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> { + return new Map(this.subscriptions) + } +} + +// Export singleton instance +export const preferenceService = PreferenceService.getInstance() +export default preferenceService diff --git a/src/main/data/db/seeding/preferenceSeeding.ts b/src/main/data/db/seeding/preferenceSeeding.ts index 25ff0744be..9310704142 100644 --- a/src/main/data/db/seeding/preferenceSeeding.ts +++ b/src/main/data/db/seeding/preferenceSeeding.ts @@ -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}` diff --git a/src/main/data/migrate/dataRefactor/migrators/PreferencesMigrator.ts b/src/main/data/migrate/dataRefactor/migrators/PreferencesMigrator.ts index 6fbefc22eb..f18a6f4b30 100644 --- a/src/main/data/migrate/dataRefactor/migrators/PreferencesMigrator.ts +++ b/src/main/data/migrate/dataRefactor/migrators/PreferencesMigrator.ts @@ -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, diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e337d0d247..30641f653e 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -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) => { + 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) + } + }) } diff --git a/src/main/services/PrefService.ts b/src/main/services/PrefService.ts deleted file mode 100644 index 0f6caff1e5..0000000000 --- a/src/main/services/PrefService.ts +++ /dev/null @@ -1 +0,0 @@ -class PrefService {} diff --git a/src/preload/index.ts b/src/preload/index.ts index a548ae8b21..6ff97125d1 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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: (key: K) => ipcRenderer.invoke(IpcChannel.Preference_Get, key), + + set: (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) => 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) + } } } diff --git a/src/renderer/src/data/PreferenceService.ts b/src/renderer/src/data/PreferenceService.ts new file mode 100644 index 0000000000..76a9349c14 --- /dev/null +++ b/src/renderer/src/data/PreferenceService.ts @@ -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() + private listeners = new Set<() => void>() + private keyListeners = new Map void>>() + private changeListenerCleanup: (() => void) | null = null + private subscribedKeys = new Set() + + 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(key: K): Promise { + // 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(key: K, value: PreferencesType['default'][K]): Promise { + 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> { + // Check which keys are already cached + const cachedResults: Record = {} + 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 = {} + 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): Promise { + 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 { + 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 = + (key: K) => + (): PreferencesType['default'][K] | undefined => { + return this.cache.get(key) + } + + /** + * Get cached value without async fetch + */ + getCachedValue(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 { + 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 diff --git a/src/renderer/src/data/hooks/usePreference.ts b/src/renderer/src/data/hooks/usePreference.ts new file mode 100644 index 0000000000..96c88f5c1c --- /dev/null +++ b/src/renderer/src/data/hooks/usePreference.ts @@ -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( + key: K +): [PreferencesType['default'][K] | undefined, (value: PreferencesType['default'][K]) => Promise] { + // 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>( + keys: T +): [ + { [P in keyof T]: PreferencesType['default'][T[P]] | undefined }, + (updates: Partial<{ [P in keyof T]: PreferencesType['default'][T[P]] }>) => Promise +] { + // 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 = {} + 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 = {} + 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 +}