UNPKG

backsplash-app

Version:
549 lines (481 loc) 19.9 kB
/** * Wallpaper Scheduler Service * * Handles client-side scheduling of wallpaper changes in the Electron app. * This service manages: * - Single schedule for wallpaper changes at specified intervals * - Setting the wallpaper at the scheduled times * - Enforcement of wallpaper every 5 minutes to override Mac behavior */ import { ipcMain, BrowserWindow } from "electron"; import * as schedule from "node-schedule"; // import { getWallpaper } from "wallpaper"; // Removed static import to use dynamic import instead import log from "@/logger"; import { WallpaperService } from "./wallpaperService"; import { StoreService, WallpaperModeParams } from "./storeService"; import { WallpaperUpdates } from "../channels/wallpaperChannels"; import { WallpaperServiceGenerationFailure } from "@/types/wallpaper"; import type { WallpaperMode } from "@/types/wallpaperModeTypes"; import { STORE_MESSAGES } from "../channels/messages"; import _ from "lodash"; // Constants for performance optimization const SCHEDULER_CHECK_INTERVAL = 60000; // 1 minute const WALLPAPER_ENFORCEMENT_INTERVAL = 5; // 5 minutes const DEBOUNCE_WAIT = 1000; // 1 second const MAX_RETRIES = 3; const RETRY_DELAY = 1000; // 1 second interface SchedulerState { isRunning: boolean; lastCheck: number; nextScheduledTime: number | null; retryCount: number; } export class SchedulerService { private wallpaperCreationJob: schedule.Job | null = null; private wallpaperEnforcementJob: schedule.Job | null = null; private wallpaperService: WallpaperService; private storeService: StoreService; private intervalId: NodeJS.Timeout | null = null; private state: SchedulerState = { isRunning: false, lastCheck: 0, nextScheduledTime: null, retryCount: 0, }; constructor(storeService: StoreService, wallpaperService: WallpaperService) { this.wallpaperService = wallpaperService; this.storeService = storeService; this.initIpcListeners(); this.initStoreListeners(); // Upon launch, cleanup state from the previous run, as the scheduler doesn't run when the app is quit. // We could do this in quit, but it won't be called if the app is force quit. This way we can guarantee // that the scheduler has clean state when the app is launched. this.stopScheduler(); this.initializeState(); log.info("[Scheduler] Initialized successfully."); } /** * Initialize listeners for store changes relevant to the scheduler */ private initStoreListeners(): void { this.storeService.onDidChange( STORE_MESSAGES.KEYS.CURRENT_MODE, (newMode?: WallpaperMode, oldMode?: WallpaperMode) => { if (newMode !== oldMode) { log.info(`[Scheduler] Detected mode change from '${oldMode || "unset"}' to '${newMode || "unset"}'.`); this.handleModeChange(newMode, oldMode); } }, ); } /** * Handles actions to take when the wallpaper mode changes. * For now, it stops the scheduler if it's active. */ private handleModeChange(newMode?: WallpaperMode, oldMode?: WallpaperMode): void { // Avoid reacting if mode hasn't actually changed or during initial setup if oldMode is undefined if (newMode === oldMode && oldMode !== undefined) { return; } log.info(`[Scheduler] Handling mode change. New mode: ${newMode}, Old mode: ${oldMode}.`); if (this.wallpaperCreationJob) { log.info("[Scheduler] Wallpaper mode changed, stopping active scheduler job."); this.stopScheduler(); // This already sets storeService.setSchedulerActive(false) and sends update } // Future: Add logic here if scheduler should auto-start or reconfigure based on the newMode } /** * Initialize IPC listeners for communication with renderer process */ private initIpcListeners(): void { log.debug("[Scheduler] Initializing IPC listeners..."); ipcMain.handle("scheduler:start", async () => { log.info("[Scheduler IPC] Received start scheduler request."); return this.startScheduler(); }); ipcMain.handle("scheduler:stop", () => { log.info("[Scheduler IPC] Received stop scheduler request."); return this.stopScheduler(); }); ipcMain.handle("scheduler:getNextRunTime", () => { log.debug("[Scheduler IPC] Received getNextRunTime request."); return this.getNextScheduledRunTime(); }); log.debug("[Scheduler] IPC listeners initialized."); } /** * Returns the next scheduled run time for the scheduler job * @returns The next scheduled run time in milliseconds since epoch, or null if no job is scheduled */ getNextScheduledRunTime(): number | null { if (!this.wallpaperCreationJob) { log.debug("[Scheduler] getNextScheduledRunTime: No active job"); return null; } try { const nextInvocation = this.wallpaperCreationJob.nextInvocation(); if (nextInvocation) { const nextRunTime = nextInvocation.getTime(); log.debug(`[Scheduler] Next scheduled run time: ${new Date(nextRunTime).toISOString()}`); return nextRunTime; } else { log.warn("[Scheduler] Job exists but nextInvocation() returned falsy value"); return null; } } catch (error) { log.error("[Scheduler] Error getting next run time:", error); return null; } } /** * Send scheduler status update to all renderer processes */ private sendSchedulerStatusUpdate(status: { active: boolean; intervalMinutes?: number; mode?: string }): void { try { const currentMode = this.storeService.getCurrentMode(); const nextRunTime = this.getNextScheduledRunTime(); const update = { type: "scheduler-changed", message: status.active ? `Scheduler ${status.active ? "started" : "stopped"} with interval: ${status.intervalMinutes}m, mode: ${currentMode || "Unknown"}` : "Scheduler stopped", timestamp: Date.now(), data: { ...status, mode: currentMode, nextRunTime, }, }; const windows = BrowserWindow.getAllWindows(); windows.forEach((window) => { if (!window.isDestroyed()) { window.webContents.send(WallpaperUpdates.STATUS_UPDATE, update); } }); log.info(`[Scheduler] Sent scheduler status update to renderer: ${update.message}`); } catch (error) { log.error("[Scheduler] Error sending scheduler status update:", error); } } /** * Check if the current wallpaper matches our expected wallpaper and re-set if needed */ private async enforceWallpaper(): Promise<void> { try { // Get the current wallpaper path using dynamic import const wallpaperModule = await import("wallpaper"); const currentWallpaperPath = await wallpaperModule.getWallpaper(); // Get our expected wallpaper path from store const expectedWallpaperPath = this.storeService.get("current-wallpaper-path"); if (!expectedWallpaperPath) { log.debug("[Scheduler] No expected wallpaper path set, skipping enforcement"); return; } if (currentWallpaperPath !== expectedWallpaperPath) { log.info( `[Scheduler] Wallpaper changed externally. Current: ${currentWallpaperPath}, Expected: ${expectedWallpaperPath}`, ); log.info("[Scheduler] Re-setting wallpaper to maintain Backsplash control"); // Re-set our wallpaper using the wallpaper module directly await wallpaperModule.setWallpaper(expectedWallpaperPath as string); // Send notification to renderer const windows = BrowserWindow.getAllWindows(); windows.forEach((window) => { if (!window.isDestroyed()) { window.webContents.send(WallpaperUpdates.STATUS_UPDATE, { type: "wallpaper-enforced", message: "Wallpaper restored after external change", timestamp: Date.now(), data: { previousPath: currentWallpaperPath, restoredPath: expectedWallpaperPath, }, }); } }); } else { log.debug("[Scheduler] Wallpaper matches expected, no enforcement needed"); } } catch (error) { log.error("[Scheduler] Error during wallpaper enforcement:", error); } } /** * Start the wallpaper enforcement job that runs every 5 minutes */ private startWallpaperEnforcement(): void { if (this.wallpaperEnforcementJob) { log.debug("[Scheduler] Wallpaper enforcement job already running"); return; } // Create a cron job that runs every 5 minutes const cronExpression = `*/${WALLPAPER_ENFORCEMENT_INTERVAL} * * * *`; this.wallpaperEnforcementJob = schedule.scheduleJob(cronExpression, async () => { log.debug("[Scheduler] Running wallpaper enforcement check"); await this.enforceWallpaper(); }); if (this.wallpaperEnforcementJob) { log.info(`[Scheduler] Wallpaper enforcement started (every ${WALLPAPER_ENFORCEMENT_INTERVAL} minutes)`); } else { log.error("[Scheduler] Failed to start wallpaper enforcement job"); } } /** * Stop the wallpaper enforcement job */ private stopWallpaperEnforcement(): void { if (this.wallpaperEnforcementJob) { this.wallpaperEnforcementJob.cancel(); this.wallpaperEnforcementJob = null; log.info("[Scheduler] Wallpaper enforcement stopped"); } } async immediateRun(): Promise<{ success: boolean; error?: string }> { log.info("[Scheduler] Attempting immediate wallpaper generation."); try { const result = await this.wallpaperService.generateWallpaperAndImmediatelySet(); if (!result.success) { const errorDetail = (result as WallpaperServiceGenerationFailure).error || "Generation failed during immediate run"; log.error( "[Scheduler] Immediate run: wallpaperService.generateWallpaperAndImmediatelySet reported failure.", errorDetail, ); return { success: false, error: errorDetail }; } log.info("[Scheduler] Immediate run successful."); return { success: true }; } catch (error) { const errorMsg = error instanceof Error ? error.message : "Unknown error during immediate run"; log.error("[Scheduler] Error during immediate run:", errorMsg); return { success: false, error: errorMsg }; } } async startScheduler(): Promise<{ success: boolean; error?: string }> { const intervalMinutes = this.storeService.getSchedulerIntervalMinutes(); const currentMode = this.storeService.getCurrentMode(); if (typeof intervalMinutes !== "number" || intervalMinutes <= 0) { const errorMsg = `Scheduler start failed: Invalid or missing interval in store (found: ${intervalMinutes}).`; log.error(`[Scheduler] ${errorMsg}`); return { success: false, error: errorMsg }; } if (!currentMode) { const errorMsg = "Scheduler start failed: Current mode not set in store."; log.error(`[Scheduler] ${errorMsg}`); return { success: false, error: errorMsg }; } if (currentMode === "CustomStyle" && !this.storeService.getModeParams("CustomStyle")?.customStyle) { const errorMsg = "Scheduler start failed: Custom style not set for CustomStyle mode."; log.error(`[Scheduler] ${errorMsg}`); return { success: false, error: errorMsg }; } if (currentMode === "Location") { const locationParams = this.storeService.getModeParams("Location"); if (!locationParams?.locationData?.city) { const errorMsg = "Scheduler start failed: Location data (city) not set for Location mode."; log.error(`[Scheduler] ${errorMsg}`); return { success: false, error: errorMsg }; } } log.info(`[Scheduler] Starting with interval: ${intervalMinutes} minutes, mode: ${currentMode}`); // Cancel any existing job first if (this.wallpaperCreationJob) { log.info("[Scheduler] Cancelling existing job before starting a new one."); this.wallpaperCreationJob.cancel(); this.wallpaperCreationJob = null; } // This is the job that will run at each scheduled interval const jobCallback = async () => { log.info("[Scheduler] Cron job triggered. Generating wallpaper..."); try { // Notify renderer processes that generation is starting const windows = BrowserWindow.getAllWindows(); windows.forEach((window) => { if (!window.isDestroyed()) { window.webContents.send(WallpaperUpdates.STATUS_UPDATE, { type: "generation-start", message: "Starting scheduled wallpaper generation...", timestamp: Date.now(), }); } }); // Generate the wallpaper await this.wallpaperService.generateWallpaperAndImmediatelySet(); log.info("[Scheduler] Wallpaper generation triggered by job successfully."); } catch (jobError) { log.error("[Scheduler] Error during scheduled wallpaper generation:", jobError); // Send error status to renderer const windows = BrowserWindow.getAllWindows(); windows.forEach((window) => { if (!window.isDestroyed()) { window.webContents.send(WallpaperUpdates.STATUS_UPDATE, { type: "generation-complete", message: "Scheduled generation failed", data: { success: false, error: jobError instanceof Error ? jobError.message : "Unknown error" }, timestamp: new Date().toISOString(), }); } }); } }; try { // Create a new date object for the first run - always starts with fresh timing const now = new Date(); // Schedule the next run exactly intervalMinutes from now const firstRunDate = new Date(now.getTime() + intervalMinutes * 60 * 1000); log.info(`[Scheduler] Scheduling first run at: ${firstRunDate.toISOString()}`); // Mark scheduler as active in the store this.storeService.setSchedulerActive(true); this.sendSchedulerStatusUpdate({ active: true, intervalMinutes, mode: currentMode }); // Start wallpaper enforcement this.startWallpaperEnforcement(); // Schedule the job to run at the exact time this.wallpaperCreationJob = schedule.scheduleJob(firstRunDate, () => { // Run the first job jobCallback(); // Then set up recurring job on the cron schedule const cronExpression = `*/${intervalMinutes} * * * *`; this.wallpaperCreationJob = schedule.scheduleJob(cronExpression, jobCallback); log.info(`[Scheduler] First run completed, now using cron schedule: ${cronExpression}`); }); // Verify the job was scheduled if (this.wallpaperCreationJob) { log.info( `[Scheduler] Job scheduled successfully. Next scheduled run: ${this.wallpaperCreationJob.nextInvocation()}`, ); return { success: true }; } else { const errorMsg = "Failed to schedule job (node-schedule returned null)."; log.error("[Scheduler] " + errorMsg); this.storeService.setSchedulerActive(false); this.stopWallpaperEnforcement(); return { success: false, error: errorMsg }; } } catch (error) { const errorMsg = error instanceof Error ? error.message : "Unknown error during scheduler start"; log.error("[Scheduler] Error starting scheduler:", errorMsg); this.storeService.setSchedulerActive(false); this.stopWallpaperEnforcement(); return { success: false, error: errorMsg }; } } stopScheduler(): { success: boolean } { log.info("[Scheduler] Stopping scheduler."); if (this.wallpaperCreationJob) { this.wallpaperCreationJob.cancel(); this.wallpaperCreationJob = null; log.info("[Scheduler] Job cancelled."); } // Stop wallpaper enforcement this.stopWallpaperEnforcement(); this.storeService.setSchedulerActive(false); this.sendSchedulerStatusUpdate({ active: false }); return { success: true }; } private initializeState() { this.restoreState(); } private saveState(): void { try { const stateToSave: SchedulerState = { isRunning: this.state.isRunning, lastCheck: this.state.lastCheck, nextScheduledTime: this.state.nextScheduledTime, retryCount: this.state.retryCount, }; this.storeService.set("scheduler-state", stateToSave as unknown as Record<string, WallpaperModeParams>); } catch (error) { log.error("[SchedulerService] Error saving state:", error); } } private restoreState(): void { try { const savedState = this.storeService.get("scheduler-state"); if (savedState && typeof savedState === "object" && "isRunning" in savedState) { this.state = { ...this.state, ...(savedState as unknown as SchedulerState) }; } } catch (error) { log.error("[SchedulerService] Error restoring state:", error); } } private debouncedCheck = _.debounce(async () => { try { await this.checkAndUpdateWallpaper(); } catch (error) { log.error("[SchedulerService] Error in debounced check:", error); } }, DEBOUNCE_WAIT); private async checkAndUpdateWallpaper(): Promise<void> { const now = Date.now(); const lastWallpaperChange = Number(this.storeService.get("last-wallpaper-change") || 0); const interval = Number(this.storeService.get("scheduler-interval") || 0); const timeSinceLastChange = now - lastWallpaperChange; if (typeof interval === "number" && timeSinceLastChange >= interval) { log.info("[SchedulerService] Interval reached, changing wallpaper"); await this.changeWallpaper(); } else { const nextChange = lastWallpaperChange + interval; this.state.nextScheduledTime = nextChange; this.saveState(); log.info(`[SchedulerService] Next wallpaper change scheduled for ${new Date(nextChange).toISOString()}`); } } private async changeWallpaper() { try { const result = await this.wallpaperService.setWallpaper("random"); if (result.success) { this.storeService.set("last-wallpaper-change", Date.now()); this.state.retryCount = 0; this.saveState(); log.info("[SchedulerService] Wallpaper changed successfully"); } else { throw new Error("Failed to change wallpaper"); } } catch (error) { log.error("[SchedulerService] Error changing wallpaper:", error); this.handleRetry(); } } private handleRetry() { if (this.state.retryCount < MAX_RETRIES) { this.state.retryCount++; this.saveState(); setTimeout(() => this.debouncedCheck(), RETRY_DELAY * this.state.retryCount); } else { log.error("[SchedulerService] Max retries reached, stopping scheduler"); this.stop(); } } start() { if (this.state.isRunning) { log.info("[SchedulerService] Scheduler already running"); return; } this.state.isRunning = true; this.saveState(); // Perform initial check this.debouncedCheck(); // Set up interval for regular checks this.intervalId = setInterval(() => { this.debouncedCheck(); }, SCHEDULER_CHECK_INTERVAL); log.info("[SchedulerService] Scheduler started"); } stop() { if (!this.state.isRunning) { log.info("[SchedulerService] Scheduler already stopped"); return; } if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } this.state.isRunning = false; this.state.retryCount = 0; this.saveState(); log.info("[SchedulerService] Scheduler stopped"); } getState(): SchedulerState { return { ...this.state }; } }