backsplash-app
Version:
An AI powered wallpaper app.
543 lines (473 loc) • 20.7 kB
text/typescript
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;
}
}