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