UNPKG

backsplash-app

Version:
543 lines (473 loc) 20.7 kB
import Store from "electron-store"; import log from "@/logger"; import type { WallpaperMode } from "@/types/wallpaperModeTypes"; import { WallpaperData, WallpaperHistoryEntry } from "@/types/wallpaper"; import { LicenseInfo, UsageStats } from "@/types/license"; import { BrowserWindow, app } from "electron"; import { STORE_MESSAGES } from "../channels/messages"; import { EventEmitter } from "events"; import fs from "fs"; import path from "path"; import { Category } from "@/ipc/apiService"; import { getMachineId } from "../utilities/machineId"; import { createEncryptionKey } from "../utilities/encryption"; // Import additional types interface WallpaperGenerationState { isGenerating: boolean; isUpscaling: boolean; error: string | null; } interface SchedulerState { isActive: boolean; lastRun: number; nextRun: number; } interface UpdateState { checking: boolean; available: boolean; downloading: boolean; downloadProgress?: number; downloaded: boolean; error: string | null; info: { version: string; releaseDate: string; releaseNotes?: string; downloadUrl?: string; size?: number; } | null; } const { KEYS } = STORE_MESSAGES; // Type definitions for mode parameters export interface WallpaperModeParams { mode: WallpaperMode; customStyle?: string; locationData?: { city?: string; state?: string; country?: string; local_time_str?: string; coordinates?: GeolocationCoordinates; }; weatherType?: string; mood?: string; timeOfDay?: string; season?: string; } // Define the store schema interface StoreSchema { [KEYS.CURRENT_MODE]: WallpaperMode; [KEYS.MODE_PARAMS]: Record<string, WallpaperModeParams>; [KEYS.ACTIVE_STYLES]: string[]; [KEYS.CUSTOM_STYLE]: string; [KEYS.SCHEDULER_INTERVAL_MINUTES]: number; [KEYS.SCHEDULER_ACTIVE]: boolean; [KEYS.EXPANDED_CATEGORIES]: string[]; [KEYS.LAST_WALLPAPER]: WallpaperData; [KEYS.WALLPAPER_HISTORY]: WallpaperHistoryEntry[]; "wallpaper-generation-state": WallpaperGenerationState; "wallpaper-style-map": Record<string, string>; "scheduler-state": SchedulerState; "user-has-seen-tour": boolean; "device-id": { data: string; timestamp: number; }; "license-key": string; "license-validation": { valid: boolean; subscription_active: boolean; plan: string; expires_at: number; last_checked: number; }; licenseInfo: LicenseInfo; usageStats: UsageStats; categories: { data: Category[]; timestamp: number; licensePlan?: string; }; "custom-replicate-api-key": string; "selected-image-model": string; "update-state": UpdateState; } const MAX_WALLPAPER_HISTORY_ENTRIES = 200; // We don't need to define default store values here since they're managed // by electron-store internally based on the schema type // No need for the StoreConstructorOptions type since we're using 'any' cast export class StoreService extends EventEmitter { private store: Store<StoreSchema>; private cache: Map<keyof StoreSchema, any> = new Map(); private isInitialized = false; private storePath: string; private readonly encryptionKey: string | null; private updateTimeout: NodeJS.Timeout | null = null; private appDataPath: string; private storeName = "backsplash-cache"; constructor() { super(); log.debug("[StoreService] Constructor called."); // Detect correct Electron app config dir this.appDataPath = app.getPath("userData"); log.debug(`[StoreService] App data path: ${this.appDataPath}`); this.storePath = path.join(this.appDataPath, "backsplash-cache.json"); log.debug(`[StoreService] Store path: ${this.storePath}`); // Setup encryption - Get encryption key from machine id const machineId = getMachineId(); if (machineId) { log.debug("[StoreService] Machine ID retrieved successfully for encryption key generation."); this.encryptionKey = createEncryptionKey(machineId); } else { log.warn("[StoreService] Could not get machine ID. Encryption will not be enabled."); this.encryptionKey = null; } log.debug(`[StoreService] Encryption key (derived): ${this.encryptionKey ? "******" : "NOT SET"}`); // Avoid logging full key log.debug(`[StoreService] Store path: ${this.storePath}`); this.store = this.initializeStore(); this.initializeCache(); log.info("[StoreService] Initialized with electron-store successfully."); } private initializeStore(): Store<StoreSchema> { log.debug("[StoreService] initializeStore called."); try { if (this.encryptionKey) { log.debug("[StoreService] Creating encrypted store."); const options = { name: this.storeName, fileExtension: "enc", encryptionKey: this.encryptionKey, watch: true, clearInvalidConfig: true, beforeEachMigration: (store: any, context: any) => { // Use any to bypass the strict type checking for migration context if (context.versions && context.versions.from === 1) { log.debug("[StoreService] Migration: Version 1 -> 2 setup"); } }, }; const storeInstance = new Store<StoreSchema>(options as any); log.debug("[StoreService] New Store instance created."); return storeInstance; } else { log.debug("[StoreService] Creating non-encrypted store."); const options = { name: this.storeName, watch: true, clearInvalidConfig: true, beforeEachMigration: (store: any, context: any) => { // Use any to bypass the strict type checking for migration context if (context.versions && context.versions.from === 1) { log.debug("[StoreService] Migration: Version 1 -> 2 setup"); } }, }; return new Store<StoreSchema>(options as any); } } catch (error) { log.error("[StoreService] Failed to initialize store:", error); throw error; } } private resetStore(): void { log.warn("[StoreService] resetStore called. This will clear all stored data."); try { log.debug("[StoreService] Clearing current store instance."); this.store.clear(); if (fs.existsSync(this.storePath)) { log.debug(`[StoreService] Deleting store file at: ${this.storePath}`); fs.unlinkSync(this.storePath); log.info("[StoreService] Deleted store file during reset."); } log.debug("[StoreService] Clearing internal cache."); this.cache.clear(); log.debug("[StoreService] Re-initializing store instance with defaults."); this.store = this.initializeStore(); // Re-initialize (will use defaults) log.info("[StoreService] Store reset and re-initialization complete."); } catch (error) { log.error("[StoreService] Error during resetStore:", error); throw error; // Re-throw to indicate reset failure } } private initializeCache() { log.debug("[StoreService] initializeCache called."); if (this.isInitialized) { log.debug("[StoreService] Cache already initialized. Skipping."); return; } try { log.debug("[StoreService] Populating cache with frequently accessed values."); // For each key, log before and after getting from store [ KEYS.CURRENT_MODE, KEYS.ACTIVE_STYLES, KEYS.SCHEDULER_ACTIVE, KEYS.SCHEDULER_INTERVAL_MINUTES, KEYS.MODE_PARAMS, ].forEach((key) => { log.debug(`[StoreService] Caching key: ${key}`); const value = this.store.get(key as keyof StoreSchema); this.cache.set(key as keyof StoreSchema, value); log.debug( `[StoreService] Cached key: ${key}, value (type): ${typeof value}, value (summary): ${value && typeof value === "object" ? Object.keys(value) : value}`, ); }); this.isInitialized = true; log.debug("[StoreService] Cache initialization complete."); } catch (error) { log.error("[StoreService] Error initializing cache:", error); log.warn("[StoreService] Attempting to reset store due to cache initialization error."); this.resetStore(); // This could loop if resetStore also fails to init cache, but initializeStore is robust. log.debug("[StoreService] Retrying cache initialization after store reset."); this.initializeCache(); // Retry once } } public get<T_Key extends keyof StoreSchema>(key: string, defaultValue?: StoreSchema[T_Key]): StoreSchema[T_Key] { log.debug(`[StoreService] get called for key: "${key}"`); try { if (this.cache.has(key as keyof StoreSchema)) { const cachedValue = this.cache.get(key as keyof StoreSchema); log.debug( `[StoreService] Cached value for "${key}" (summary): ${cachedValue && typeof cachedValue === "object" ? JSON.stringify(Object.keys(cachedValue)) : cachedValue}`, ); return cachedValue as StoreSchema[T_Key]; } const value = this.store.get(key as keyof StoreSchema, defaultValue) as StoreSchema[T_Key]; log.debug(`[StoreService] Value from electron-store for key "${key}" (type): ${typeof value}`); // log.debug(`[StoreService] Value for "${key}" (summary): ${value && typeof value === 'object' ? JSON.stringify(Object.keys(value)) : value}`); log.debug(`[StoreService] Setting value for key "${key}" in cache.`); this.cache.set(key as keyof StoreSchema, value); return value; } catch (error) { log.error(`[StoreService] Error getting value for key "${String(key)}":`, error); log.warn(`[StoreService] Returning provided default value for key "${String(key)}" due to error.`); return defaultValue as StoreSchema[T_Key]; } } public set<T_Key extends keyof StoreSchema>(key: string, value: StoreSchema[T_Key]): void { log.debug(`[StoreService] set called for key: "${key}"`); try { const currentValue = this.get<T_Key>(key); // Uses the enhanced get method // Determine if the value has changed let hasChanged = false; if (key === KEYS.MODE_PARAMS) { // Special handling for mode params to better detect property deletions const currentParams = currentValue as Record<string, WallpaperModeParams>; const newParams = value as Record<string, WallpaperModeParams>; // Compare the modes that exist in either object const allModes = new Set([...Object.keys(currentParams || {}), ...Object.keys(newParams || {})]); for (const mode of allModes) { const currentModeParams = currentParams?.[mode]; const newModeParams = newParams?.[mode]; // If mode exists in one but not the other, they're different if (!currentModeParams || !newModeParams) { hasChanged = true; break; } // Compare locationData specially to handle undefined/deleted properties if (currentModeParams.locationData || newModeParams.locationData) { const currentLocationKeys = Object.keys(currentModeParams.locationData || {}); const newLocationKeys = Object.keys(newModeParams.locationData || {}); // If the keys in locationData are different, they're different if ( currentLocationKeys.length !== newLocationKeys.length || !currentLocationKeys.every((k) => newLocationKeys.includes(k)) ) { hasChanged = true; break; } } // Default to JSON string comparison for other fields if (JSON.stringify(currentModeParams) !== JSON.stringify(newModeParams)) { hasChanged = true; break; } } } else { // For non-mode-params, use the standard JSON comparison hasChanged = JSON.stringify(currentValue) !== JSON.stringify(value); } if (!hasChanged) { log.debug(`[StoreService] Value for "${String(key)}" unchanged, skipping update.`); return; } log.debug(`[StoreService] Value for "${String(key)}" HAS changed. Proceeding with update.`); log.debug(`[StoreService] Updating cache for key: "${key}".`); this.cache.set(key as keyof StoreSchema, value); log.debug(`[StoreService] Updating electron-store for key: "${key}".`); this.store.set(key as keyof StoreSchema, value); log.info(`[StoreService] Successfully set key "${String(key)}" in store and cache.`); // Notify windows after a small delay to batch updates if (this.updateTimeout) { clearTimeout(this.updateTimeout); } this.updateTimeout = setTimeout(() => { log.debug(`[StoreService] Notifying windows of update for key: "${key}"`); this.notifyWindows(key, value); }, 100); } catch (error) { log.error(`[StoreService] Error setting value for key "${String(key)}":`, error); if (error instanceof SyntaxError) { // This typically shouldn't happen with our serialize checks, but as a safeguard log.error( `[StoreService] SyntaxError encountered during set operation for key "${String(key)}". This might indicate a severe issue. Attempting to reset store.`, ); this.resetStore(); } // Optionally re-throw or handle more gracefully depending on requirements } } private notifyWindows<T>(key: string, value: T): void { log.debug(`[StoreService] notifyWindows called for key: "${key}"`); const windows = BrowserWindow.getAllWindows(); if (windows.length === 0) return; const eventChannel = `${STORE_MESSAGES.EVENTS.UPDATED}:${key}`; windows.forEach((win) => { if (!win.isDestroyed() && win.webContents) { win.webContents.send(eventChannel, value); } }); } public clear(): void { log.warn("[StoreService] clear called. This will clear the entire store and cache."); this.cache.clear(); log.debug("[StoreService] Internal cache cleared."); this.store.clear(); log.debug("[StoreService] electron-store cleared."); this.isInitialized = false; // Force re-initialization of cache if accessed again log.debug("[StoreService] Re-initializing cache after clear."); this.initializeCache(); log.info("[StoreService] Store cleared and cache reinitialized."); } public onDidChange<T>(key: keyof StoreSchema, callback: (newValue?: T, oldValue?: T) => void): void { log.debug(`[StoreService] onDidChange listener being set up for key: ${String(key)}`); this.store.onDidChange(key, (newValue, oldValue) => { callback(newValue as T, oldValue as T); }); } // Specific getters and setters public getCurrentMode(): WallpaperMode { return this.get<typeof KEYS.CURRENT_MODE>(KEYS.CURRENT_MODE) as WallpaperMode; } public setCurrentMode(mode: WallpaperMode): void { this.set(KEYS.CURRENT_MODE, mode); log.info(`[StoreService] Current wallpaper mode set to: ${mode}`); } public getModeParams(mode: WallpaperMode): WallpaperModeParams | undefined { const allParams = this.getAllModeParams(); return allParams[mode]; } public setModeParams(params: WallpaperModeParams): void { const allParams = this.getAllModeParams(); allParams[params.mode] = params; this.set(KEYS.MODE_PARAMS, allParams); log.info(`[StoreService] Parameters set for mode: ${params.mode}`); } public getAllModeParams(): Record<string, WallpaperModeParams> { const params = this.get<typeof KEYS.MODE_PARAMS>(KEYS.MODE_PARAMS); return typeof params === "object" && params !== null ? (params as Record<string, WallpaperModeParams>) : {}; } public getActiveStyles(): string[] { return this.get<typeof KEYS.ACTIVE_STYLES>(KEYS.ACTIVE_STYLES) as string[]; } public setActiveStyles(styles: string[]): void { this.set(KEYS.ACTIVE_STYLES, styles); log.info(`[StoreService] Active styles updated (${styles.length} styles)`); } public getExpandedCategories(): string[] { return this.get<typeof KEYS.EXPANDED_CATEGORIES>(KEYS.EXPANDED_CATEGORIES) as string[]; } public setExpandedCategories(categories: string[]): void { this.set(KEYS.EXPANDED_CATEGORIES, categories); log.info(`[StoreService] Expanded categories updated (${categories.length} categories)`); } public getLastWallpaper(): WallpaperData { return this.get<typeof KEYS.LAST_WALLPAPER>(KEYS.LAST_WALLPAPER) as WallpaperData; } public setLastWallpaper(wallpaper: WallpaperData | null): void { this.set(KEYS.LAST_WALLPAPER, wallpaper); if (wallpaper) { log.info(`[StoreService] Last wallpaper updated: ${wallpaper.objectKey}`); } else { log.info("[StoreService] Last wallpaper cleared."); } } public getSchedulerIntervalMinutes(): number { return this.get<typeof KEYS.SCHEDULER_INTERVAL_MINUTES>(KEYS.SCHEDULER_INTERVAL_MINUTES) as number; } public setSchedulerIntervalMinutes(minutes: number): void { if (minutes > 0) { this.set(KEYS.SCHEDULER_INTERVAL_MINUTES, minutes); log.info(`[StoreService] Scheduler interval minutes set to: ${minutes}`); } else { log.warn(`[StoreService] Attempted to set invalid scheduler interval: ${minutes}`); } } public getSchedulerActive(): boolean { return this.get<typeof KEYS.SCHEDULER_ACTIVE>(KEYS.SCHEDULER_ACTIVE) as boolean; } public setSchedulerActive(isActive: boolean): void { this.set(KEYS.SCHEDULER_ACTIVE, isActive); log.info(`[StoreService] Scheduler active state set to: ${isActive}`); } public getWallpaperHistory(): WallpaperHistoryEntry[] { log.debug("[StoreService] getWallpaperHistory called."); const history = this.get<typeof KEYS.WALLPAPER_HISTORY>(KEYS.WALLPAPER_HISTORY) as WallpaperHistoryEntry[]; log.debug(`[StoreService] Wallpaper history retrieved. Count: ${history ? history.length : "null/undefined"}.`); return history || []; // Ensure it always returns an array } public addWallpaperHistoryEntry(entry: WallpaperHistoryEntry): void { log.debug(`[StoreService] addWallpaperHistoryEntry called for key: ${entry?.key}`); if (!entry || !entry.key) { log.warn("[StoreService] Attempted to add invalid entry to wallpaper history.", entry); return; } const history = (this.getWallpaperHistory() || []).slice(); // Get a mutable copy log.debug(`[StoreService] Current history length before adding: ${history.length}`); const existingEntryIndex = history.findIndex((e) => e.key === entry.key); if (existingEntryIndex !== -1) { log.debug(`[StoreService] Entry ${entry.key} already exists in history. Removing old instance.`); history.splice(existingEntryIndex, 1); } log.debug(`[StoreService] Adding new entry for ${entry.key} to the beginning of history.`); history.unshift(entry); if (history.length > MAX_WALLPAPER_HISTORY_ENTRIES) { log.debug( `[StoreService] History length (${history.length}) exceeds max (${MAX_WALLPAPER_HISTORY_ENTRIES}). Trimming.`, ); history.pop(); } this.set(KEYS.WALLPAPER_HISTORY, history); log.info(`[StoreService] Added entry to wallpaper history: ${entry.key}. New history length: ${history.length}`); } public clearWallpaperHistory(): void { log.warn("[StoreService] clearWallpaperHistory called."); this.set(KEYS.WALLPAPER_HISTORY, []); log.info("[StoreService] Wallpaper history cleared via clearWallpaperHistory method."); } public has(key: string): boolean { log.debug(`[StoreService] has called for key: "${key}"`); // Check cache first, then store const inCache = this.cache.has(key as keyof StoreSchema); if (inCache) { log.debug(`[StoreService] Key "${key}" found in cache during 'has' check.`); return true; } const inStore = this.store.has(key as keyof StoreSchema); log.debug(`[StoreService] Key "${key}" ${inStore ? "found" : "not found"} in electron-store during 'has' check.`); return inStore; } public delete(key: string): void { log.warn(`[StoreService] delete called for key: "${key}". This will remove the key from store and cache.`); this.cache.delete(key as keyof StoreSchema); log.debug(`[StoreService] Key "${key}" deleted from cache.`); this.store.delete(key as keyof StoreSchema); log.info(`[StoreService] Key "${key}" deleted from electron-store.`); } /** * Check if encryption is enabled for this store */ private isEncryptionEnabled(): boolean { return this.encryptionKey !== null; } }