UNPKG

backsplash-app

Version:
491 lines (436 loc) 17.6 kB
import log from "@/logger"; import { getApiService } from "@/ipc/services/apiServiceInstance"; import { getWallpaperImageUrl } from "@/ipc/apiService"; import { FilesystemStorageService } from "@/ipc/services/filesystemStorageService"; import { StoreService } from "@/ipc/services/storeService"; import { WallpaperServiceGenerationResponse, WallpaperServiceGenerationFailure, WallpaperServiceUpscaleResponse, WallpaperHistoryEntry, WallpaperMetadata, } from "@/types/wallpaper"; import { BrowserWindow } from "electron"; import { WallpaperUpdates } from "../channels/wallpaperChannels"; import { GenerationService } from "./generationService"; import { Worker } from "worker_threads"; import path from "path"; // Constants for performance optimization const BATCH_OPERATION_TIMEOUT = 30000; // 30 seconds const MAX_RETRIES = 3; const RETRY_DELAY = 1000; const WORKER_CLEANUP_TIMEOUT = 5000; // 5 seconds /** * Wallpaper Service * * Handles fetching, generating, upscaling, caching, and setting wallpapers. */ export class WallpaperService { private localCacheDir: string; private fileSystem: FilesystemStorageService; private storeService: StoreService; private readonly styleMapKey = "wallpaper-style-map"; private readonly generationService: GenerationService; private worker: Worker | null = null; private messageQueue: Array<{ message: any; resolve: (value: any) => void; reject: (error: any) => void }> = []; private isProcessingQueue = false; private workerCleanupTimeout: NodeJS.Timeout | null = null; private apiService: ReturnType<typeof getApiService>; constructor(storeService: StoreService, fileSystem: FilesystemStorageService) { this.fileSystem = fileSystem; this.localCacheDir = this.fileSystem.getStorageDir(); this.storeService = storeService; this.apiService = getApiService(storeService); this.generationService = new GenerationService( this.storeService, this.fileSystem, this.getStyleMap.bind(this), this.setStyleMap.bind(this), ); log.info(`[WallpaperService] Cache directory: ${this.localCacheDir}`); log.info("[WallpaperService] GenerationService instantiated by WallpaperService."); } private initializeWorker() { if (this.worker) return; try { const workerPath = path.join(__dirname, "wallpaperWorker.js"); log.info(`[WallpaperService] Attempting to initialize worker at: ${workerPath}`); this.worker = new Worker(workerPath); log.info(`[WallpaperService] Worker initialized successfully`); // Add error boundaries to prevent worker crashes this.worker.on("error", (error) => { log.error("[WallpaperService] Worker error:", error); const { reject } = this.messageQueue.shift() || {}; if (reject) { reject(error); } this.cleanupWorker(); this.processNextMessage(); }); this.worker.on("exit", (code) => { if (code !== 0) { log.error(`[WallpaperService] Worker stopped with exit code ${code}`); } this.cleanupWorker(); this.processNextMessage(); }); this.worker.on("message", (response) => { const { resolve } = this.messageQueue.shift() || {}; if (resolve) { resolve(response); } this.processNextMessage(); }); } catch (error) { log.error("[WallpaperService] Failed to initialize worker:", error); this.worker = null; } } private cleanupWorker() { if (this.worker) { this.worker.terminate(); this.worker = null; } if (this.workerCleanupTimeout) { clearTimeout(this.workerCleanupTimeout); this.workerCleanupTimeout = null; } } private scheduleWorkerCleanup() { if (this.workerCleanupTimeout) { clearTimeout(this.workerCleanupTimeout); } this.workerCleanupTimeout = setTimeout(() => { if (this.messageQueue.length === 0) { this.cleanupWorker(); } }, WORKER_CLEANUP_TIMEOUT); } private async processNextMessage() { if (this.isProcessingQueue || this.messageQueue.length === 0) return; this.isProcessingQueue = true; try { const { message, reject } = this.messageQueue[0]; // Initialize worker asynchronously await new Promise<void>((resolveInit) => { this.initializeWorker(); resolveInit(); }); if (this.worker) { this.worker.postMessage(message); } else { reject(new Error("Worker initialization failed")); this.messageQueue.shift(); } } catch (error) { const { reject } = this.messageQueue.shift() || {}; if (reject) { reject(error); } } finally { this.isProcessingQueue = false; this.scheduleWorkerCleanup(); } } private async sendMessageToWorker(message: any): Promise<any> { return new Promise((resolve, reject) => { this.messageQueue.push({ message, resolve, reject }); this.processNextMessage(); }); } private async batchFileSystemOperations<T>(operations: Array<() => Promise<T>>): Promise<T[]> { const timeoutPromise = new Promise<never>((_, reject) => { setTimeout(() => reject(new Error("Batch operation timeout")), BATCH_OPERATION_TIMEOUT); }); try { // Reduce chunk size for better responsiveness const chunkSize = 3; const results: T[] = []; // Process chunks with a small delay between them to prevent UI blocking for (let i = 0; i < operations.length; i += chunkSize) { const chunk = operations.slice(i, i + chunkSize); const chunkResults = await Promise.race([Promise.all(chunk.map((op) => op())), timeoutPromise]); results.push(...chunkResults); // Add a small delay between chunks to allow UI updates if (i + chunkSize < operations.length) { await new Promise((resolve) => setTimeout(resolve, 50)); } } return results; } catch (error) { log.error("[WallpaperService] Batch operation failed:", error); throw error; } } /** * Send a status update to all renderer processes (used internally or by GenerationService via constructor). * This method might be redundant if sendStatusUpdateFunc from wallpaperIPC is used directly by GenerationService. * However, keeping it here if WallpaperService itself needs to send distinct updates. */ private sendInternalStatusUpdate(status: { type: string; message: string; data?: any }): void { const windows = BrowserWindow.getAllWindows(); windows.forEach((window) => { if (!window.isDestroyed()) { // Assuming a generic channel or define a specific one for these internal updates window.webContents.send("wallpaper-service-status", status); } }); } /** * Send a wallpaper update to all renderer processes */ public sendWallpaperUpdate(update: { objectKey: string; originalImageUrl: string; upscaledImageUrl: string | null; setAsBackground?: boolean; style: string; metadata?: any; }): void { try { const windows = BrowserWindow.getAllWindows(); windows.forEach((window) => { if (!window.isDestroyed()) { window.webContents.send(WallpaperUpdates.UPDATE, update); } }); log.info(`[WallpaperService] Sent wallpaper update to renderer for key: ${update.objectKey}`); } catch (error) { log.error("[WallpaperService] Error sending wallpaper update:", error); } } /** * Set the wallpaper using the native system APIs. * Only uses upscaled versions - will fail if no upscaled version exists. * @param objectKey The unique identifier (e.g., S3 key/filename) for the wallpaper. * @returns Object with success status and upscaling information */ async setWallpaper(objectKey: string): Promise<{ success: boolean; isUpscaled: boolean }> { log.info(`[WallpaperService] Request to set wallpaper for key: ${objectKey}`); let attempts = 0; while (attempts < MAX_RETRIES) { try { // Batch file system operations const [upscaledInfo, metadata, styleMap] = await this.batchFileSystemOperations([ async () => this.fileSystem.getWallpaperUpscaledInfo(objectKey), async () => this.apiService.wallpapers.getMetadata(objectKey), async () => this.getStyleMap(), ]); const { upscaledPath } = upscaledInfo; const styleInfo = styleMap[objectKey] || metadata.style || "Unknown"; let localPathToSet: string | null = null; // If we have a local upscaled version, use it if (upscaledPath) { log.info(`[WallpaperService] Using existing upscaled version locally: ${upscaledPath}`); localPathToSet = upscaledPath; } else { log.info(`[WallpaperService] No local upscaled version found for ${objectKey}. Performing upscale...`); let upscaleResult: { success: boolean; error?: string } = { success: false }; // Try worker upscaling first if available if (this.worker) { try { log.info(`[WallpaperService] Attempting worker-based upscaling for ${objectKey}`); upscaleResult = await this.sendMessageToWorker({ type: "upscale", objectKey }); log.info(`[WallpaperService] Worker upscaling result:`, upscaleResult); } catch (workerError) { log.error(`[WallpaperService] Worker upscaling failed:`, workerError); upscaleResult = { success: false, error: `Worker error: ${workerError}` }; } } // Fallback to synchronous upscaling if worker failed or is not available if (!upscaleResult.success) { try { log.info(`[WallpaperService] Falling back to synchronous upscaling for ${objectKey}`); upscaleResult = await this.generationService.upscaleWallpaper(objectKey); log.info(`[WallpaperService] Synchronous upscaling result:`, upscaleResult); } catch (syncError) { log.error(`[WallpaperService] Synchronous upscaling failed:`, syncError); upscaleResult = { success: false, error: `Sync error: ${syncError}` }; } } if (upscaleResult.success) { const { upscaledPath: freshUpscaledPath } = await this.fileSystem.getWallpaperUpscaledInfo(objectKey); if (freshUpscaledPath) { localPathToSet = freshUpscaledPath; log.info(`[WallpaperService] Successfully upscaled to: ${freshUpscaledPath}`); } else { log.error(`[WallpaperService] Upscaling succeeded but no upscaled file found for ${objectKey}`); } } else { log.error(`[WallpaperService] All upscaling attempts failed for ${objectKey}:`, upscaleResult.error); } } if (!localPathToSet) { log.error(`[WallpaperService] Could not determine a valid wallpaper path to set for key: ${objectKey}.`); return { success: false, isUpscaled: false }; } // Get the image URLs for frontend const [upscaledImageUrl, originalImageUrl] = await this.batchFileSystemOperations([ async () => getWallpaperImageUrl(localPathToSet!), async () => getWallpaperImageUrl(objectKey), ]); // Send a wallpaper update to the frontend this.sendWallpaperUpdate({ objectKey, originalImageUrl, upscaledImageUrl, setAsBackground: true, style: styleInfo, }); this.storeService.setLastWallpaper({ objectKey, originalImageUrl, upscaledImageUrl, style: styleInfo, generatedAt: new Date().toISOString(), }); // Set the wallpaper const wallpaperModule = await import("wallpaper"); await wallpaperModule.setWallpaper(localPathToSet); log.info(`[WallpaperService] Successfully set wallpaper.`); // Store the current wallpaper path for scheduler enforcement this.storeService.set("current-wallpaper-path", localPathToSet); // Perform cache cleanup and history update in parallel await Promise.all([ this.cleanupCache(localPathToSet), this.addToWallpaperHistory({ key: objectKey, upscaledImageUrl, originalImageUrl, setAt: new Date().toISOString(), isUpscaled: true, metadata: { ...metadata, style: styleInfo, }, }), ]); return { success: true, isUpscaled: true }; } catch (error) { attempts++; if (attempts === MAX_RETRIES) { log.error(`[WallpaperService] Error setting wallpaper after ${MAX_RETRIES} attempts:`, error); return { success: false, isUpscaled: false }; } log.warn(`[WallpaperService] Attempt ${attempts} failed, retrying in ${RETRY_DELAY}ms...`); await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); } } return { success: false, isUpscaled: false }; } /** * Generate a new wallpaper based on style, mode, etc. Delegates to GenerationService. * GenerationService will pull its parameters directly from the StoreService. */ async generateWallpaper(): Promise<WallpaperServiceGenerationResponse | WallpaperServiceGenerationFailure> { return this.generationService.generateWallpaper(); } /** * Generate a new wallpaper and immediately set it as the wallpaper. * @returns The result of the wallpaper generation. */ async generateWallpaperAndImmediatelySet(): Promise< WallpaperServiceGenerationResponse | WallpaperServiceGenerationFailure > { const result = await this.generateWallpaper(); if (result.success) { await this.setWallpaper(result.objectKey); } return result; } /** * Upscale an existing wallpaper. Delegates to GenerationService. */ async upscaleWallpaper(objectKey: string): Promise<WallpaperServiceUpscaleResponse> { try { log.info(`[WallpaperService] Attempting to upscale wallpaper: ${objectKey}`); return await this.generationService.upscaleWallpaper(objectKey); } catch (error) { log.error(`[WallpaperService] Error upscaling wallpaper: ${objectKey}`, error); return { success: false, error: error instanceof Error ? error.message : "Unknown error occurred", }; } } /** * Cleans up the wallpaper cache directory, keeping the N most recent files. * @param keepPath Optional path to explicitly keep, regardless of age. * @param keepCount Number of most recent files to keep (default: 5). */ private async cleanupCache(keepPath?: string, keepCount = 5): Promise<void> { try { const pathsToKeep = keepPath ? [keepPath] : []; await this.fileSystem.cleanupOldFiles(pathsToKeep, keepCount); // Schedule next cleanup setTimeout(() => { this.cleanupCache().catch((error) => { log.error("[WallpaperService] Scheduled cleanup failed:", error); }); }, 3600000); // Run cleanup every hour } catch (error) { log.error("[WallpaperService] Cache cleanup failed:", error); } } /** * Add a wallpaper to the history in electron-store */ private async addToWallpaperHistory(wallpaper: { key: string; upscaledImageUrl: string; originalImageUrl: string; setAt: string; isUpscaled: boolean; metadata?: WallpaperMetadata; }): Promise<void> { log.debug("[WallpaperService] Adding to wallpaper history", wallpaper); try { const historyEntry: WallpaperHistoryEntry = { key: wallpaper.key, upscaledImageUrl: wallpaper.upscaledImageUrl, originalImageUrl: wallpaper.originalImageUrl, setAt: wallpaper.setAt, isUpscaled: wallpaper.isUpscaled, metadata: wallpaper.metadata, }; // Batch store operations await Promise.all([ this.storeService.addWallpaperHistoryEntry(historyEntry), this.updateStyleMap(wallpaper.key, wallpaper.metadata?.style), ]); // Notify renderer processes const windows = BrowserWindow.getAllWindows(); const history = this.storeService.getWallpaperHistory(); await Promise.all( windows.map((window) => { if (!window.isDestroyed() && window.webContents) { return window.webContents.send("wallpaper-history-updated", history); } return Promise.resolve(); }), ); } catch (error) { log.error("[WallpaperService] Failed to add to wallpaper history:", error); throw error; } } private async updateStyleMap(key: string, style?: string): Promise<void> { if (!style) return; const styleMap = this.getStyleMap(); styleMap[key] = style; await this.setStyleMap(styleMap); } private getStyleMap(): Record<string, string> { const styleMap = this.storeService.get(this.styleMapKey); return styleMap && typeof styleMap === "object" && !Array.isArray(styleMap) ? (styleMap as Record<string, string>) : {}; } private setStyleMap(newMap: Record<string, string>): void { this.storeService.set(this.styleMapKey, newMap as Record<string, string>); } private getSavedStyle(objectKey: string): string | undefined { const styleMap = this.getStyleMap(); return styleMap[objectKey]; } }