backsplash-app
Version:
An AI powered wallpaper app.
547 lines (470 loc) • 17.7 kB
text/typescript
import { ipcMain, app } from "electron";
import log from "@/logger";
import { StoreService } from "./storeService";
import { EventEmitter } from "events";
import { STORE_MESSAGES } from "../channels/messages";
import { updateElectronApp, UpdateSourceType } from "update-electron-app";
import * as fs from "fs";
import * as path from "path";
import * as https from "https";
import { spawn } from "child_process";
// Define update state types
export interface UpdateInfo {
version: string;
releaseDate: string;
releaseNotes?: string;
downloadUrl?: string;
size?: number;
}
export interface UpdateState {
checking: boolean;
available: boolean;
downloading: boolean;
downloadProgress?: number;
downloaded: boolean;
error: string | null;
info: UpdateInfo | null;
}
// Use the key from STORE_MESSAGES
const UPDATE_STATE_KEY = STORE_MESSAGES.KEYS.UPDATE_STATE;
// Global singleton instance
let updateServiceInstance: UpdateService | null = null;
export class UpdateService extends EventEmitter {
private storeService: StoreService;
private updateCheckInterval?: NodeJS.Timeout;
private static handlersInitialized = false;
private downloadPath?: string;
constructor(storeService: StoreService) {
super();
this.storeService = storeService;
// Debug environment variables
console.log("[UpdateService] Environment debug:");
console.log(` NODE_ENV: ${process.env.NODE_ENV}`);
console.log(` S3_BUCKET: ${process.env.S3_BUCKET || "NOT SET"}`);
console.log(` S3_REGION: ${process.env.S3_REGION || "NOT SET"}`);
console.log(` Platform: ${process.platform}`);
console.log(` Arch: ${process.arch}`);
// Only enable auto-updater in production and when we have an S3 bucket configured
if (process.env.NODE_ENV === "production" && process.env.S3_BUCKET) {
const baseUrl = `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${process.platform}/${process.arch}`;
console.log(`[UpdateService] Initializing auto-updater for production with URL: ${baseUrl}`);
updateElectronApp({
updateSource: {
type: UpdateSourceType.StaticStorage,
baseUrl,
},
updateInterval: "1 hour",
logger: console,
});
} else {
console.log("[UpdateService] Auto-updater disabled:");
console.log(` - Production: ${process.env.NODE_ENV === "production"}`);
console.log(` - S3 Bucket configured: ${!!process.env.S3_BUCKET}`);
}
// Initialize default update state in store
this.initUpdateState();
}
private initUpdateState(): void {
// Define initial state
const initialState: UpdateState = {
checking: false,
available: false,
downloading: false,
downloadProgress: 0,
downloaded: false,
error: null,
info: null,
};
// Only set if not already defined
if (!this.storeService.has(UPDATE_STATE_KEY)) {
try {
this.safeSetUpdateState(initialState);
} catch (error) {
log.error(`[UpdateService] Failed to initialize update state: ${error}`);
}
}
}
private async checkForUpdatesOnS3(): Promise<UpdateInfo | null> {
if (!process.env.S3_BUCKET) {
throw new Error("S3_BUCKET not configured");
}
const currentVersion = app.getVersion();
const platform = process.platform;
const arch = process.arch;
// Check for latest.yml or latest-mac.yml depending on platform
const latestFileName = platform === "darwin" ? "latest-mac.yml" : "latest.yml";
const latestUrl = `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${platform}/${arch}/${latestFileName}`;
log.info(`[UpdateService] Checking for updates at: ${latestUrl}`);
try {
const latestInfo = await this.fetchLatestInfo(latestUrl);
if (this.isNewerVersion(latestInfo.version, currentVersion)) {
log.info(`[UpdateService] Update available: ${latestInfo.version} (current: ${currentVersion})`);
return latestInfo;
} else {
log.info(`[UpdateService] No update available. Current: ${currentVersion}, Latest: ${latestInfo.version}`);
return null;
}
} catch (error) {
log.error(`[UpdateService] Failed to check for updates: ${error}`);
throw error;
}
}
private fetchLatestInfo(url: string): Promise<UpdateInfo> {
return new Promise((resolve, reject) => {
https
.get(url, (response) => {
if (response.statusCode !== 200) {
reject(new Error(`Failed to fetch latest info: HTTP ${response.statusCode}`));
return;
}
let data = "";
response.on("data", (chunk) => {
data += chunk;
});
response.on("end", () => {
try {
// Parse YAML-like format from electron-builder
const lines = data.split("\n");
const info: Partial<UpdateInfo> = {};
for (const line of lines) {
const [key, ...valueParts] = line.split(":");
const value = valueParts.join(":").trim();
if (key === "version") {
info.version = value;
} else if (key === "releaseDate") {
info.releaseDate = value;
} else if (key === "path") {
// Construct download URL
const baseUrl = url.substring(0, url.lastIndexOf("/"));
info.downloadUrl = `${baseUrl}/${value}`;
}
}
if (!info.version) {
reject(new Error("Invalid latest info format: missing version"));
return;
}
resolve(info as UpdateInfo);
} catch (error) {
reject(new Error(`Failed to parse latest info: ${error}`));
}
});
})
.on("error", (error) => {
reject(error);
});
});
}
private isNewerVersion(remoteVersion: string, currentVersion: string): boolean {
// Simple semantic version comparison
const parseVersion = (version: string) => {
return version
.replace(/^v/, "")
.split(".")
.map((num) => parseInt(num, 10));
};
const remote = parseVersion(remoteVersion);
const current = parseVersion(currentVersion);
for (let i = 0; i < Math.max(remote.length, current.length); i++) {
const r = remote[i] || 0;
const c = current[i] || 0;
if (r > c) return true;
if (r < c) return false;
}
return false;
}
private async downloadUpdate(updateInfo: UpdateInfo): Promise<string> {
if (!updateInfo.downloadUrl) {
throw new Error("No download URL available");
}
const tempDir = path.join(app.getPath("temp"), "backsplash-updates");
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
const fileName = path.basename(updateInfo.downloadUrl);
const downloadPath = path.join(tempDir, fileName);
log.info(`[UpdateService] Downloading update to: ${downloadPath}`);
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(downloadPath);
https
.get(updateInfo.downloadUrl!, (response) => {
if (response.statusCode !== 200) {
reject(new Error(`Download failed: HTTP ${response.statusCode}`));
return;
}
const totalSize = parseInt(response.headers["content-length"] || "0", 10);
let downloadedSize = 0;
response.on("data", (chunk) => {
downloadedSize += chunk.length;
if (totalSize > 0) {
const progress = (downloadedSize / totalSize) * 100;
this.safeSetUpdateState({ downloadProgress: progress });
}
});
response.pipe(file);
file.on("finish", () => {
file.close();
this.downloadPath = downloadPath;
resolve(downloadPath);
});
file.on("error", (error) => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
fs.unlink(downloadPath, () => {}); // Clean up on error
reject(error);
});
})
.on("error", (error) => {
reject(error);
});
});
}
private async installUpdate(): Promise<void> {
if (!this.downloadPath || !fs.existsSync(this.downloadPath)) {
throw new Error("No update file available for installation");
}
log.info(`[UpdateService] Installing update from: ${this.downloadPath}`);
if (process.platform === "darwin") {
// On macOS, open the DMG or run the installer
spawn("open", [this.downloadPath], { detached: true });
} else if (process.platform === "win32") {
// On Windows, run the installer
spawn(this.downloadPath, [], { detached: true });
} else {
// On Linux, handle AppImage or other formats
spawn("chmod", ["+x", this.downloadPath]);
spawn(this.downloadPath, [], { detached: true });
}
// Quit the current app after starting the installer
setTimeout(() => {
app.quit();
}, 1000);
}
public static cleanupHandlers(): void {
// Clean up existing handlers
try {
ipcMain.removeHandler("update:get-settings");
ipcMain.removeHandler("update:check");
ipcMain.removeHandler("update:download");
ipcMain.removeHandler("update:install");
UpdateService.handlersInitialized = false;
log.info("[UpdateService] Cleaned up existing IPC handlers");
} catch (error) {
// Ignore errors if handlers don't exist
log.debug("[UpdateService] No existing handlers to clean up");
}
}
public initIpcHandlers(): void {
// Prevent duplicate handler registration using static flag
if (UpdateService.handlersInitialized) {
log.warn("[UpdateService] IPC handlers already initialized globally, skipping...");
return;
}
log.info("[UpdateService] Initializing IPC handlers");
// Clean up any existing handlers first
UpdateService.cleanupHandlers();
// Get settings handler
ipcMain.handle("update:get-settings", () => {
return {
autoUpdateEnabled: process.env.NODE_ENV === "production" && !!process.env.S3_BUCKET,
lastChecked: Date.now(),
};
});
// Check for updates handler
ipcMain.handle("update:check", async () => {
try {
log.info("[UpdateService] Manual update check requested");
// Update state to show checking
this.safeSetUpdateState({
checking: true,
error: null,
available: false,
downloaded: false,
});
// Check if we're in development mode
if (process.env.NODE_ENV !== "production") {
const errorMsg = "Updates are only available in production builds";
log.warn(`[UpdateService] ${errorMsg}`);
this.safeSetUpdateState({
checking: false,
error: errorMsg,
available: false,
});
return false;
}
if (!process.env.S3_BUCKET) {
const errorMsg = "Update server not configured. Updates will be available when released.";
log.warn(`[UpdateService] S3_BUCKET not configured for updates`);
this.safeSetUpdateState({
checking: false,
error: errorMsg,
available: false,
});
return false;
}
// Actually check for updates on S3
try {
const updateInfo = await this.checkForUpdatesOnS3();
if (updateInfo) {
this.safeSetUpdateState({
checking: false,
available: true,
error: null,
info: updateInfo,
});
return true;
} else {
this.safeSetUpdateState({
checking: false,
available: false,
error: null,
});
return false;
}
} catch (error) {
// Handle S3 access errors (like 403 for private buckets)
if (error instanceof Error && error.message.includes("HTTP 403")) {
log.info(
"[UpdateService] S3 bucket is private - manual checks not available, but auto-updates will work in production",
);
this.safeSetUpdateState({
checking: false,
available: false,
error: null,
});
return false;
}
throw error; // Re-throw other errors
}
} catch (error) {
const errorMsg = `Update check failed: ${error}`;
log.error(`[UpdateService] ${errorMsg}`);
this.safeSetUpdateState({
checking: false,
error: errorMsg,
available: false,
});
return false;
}
});
// Download update handler
ipcMain.handle("update:download", async () => {
try {
log.info("[UpdateService] Update download requested");
if (process.env.NODE_ENV !== "production") {
const errorMsg = "Downloads are only available in production builds";
log.warn(`[UpdateService] ${errorMsg}`);
this.safeSetUpdateState({ error: errorMsg });
return false;
}
if (!process.env.S3_BUCKET) {
const errorMsg = "Update server not configured. Downloads will be available when released.";
log.warn(`[UpdateService] S3_BUCKET not configured for downloads`);
this.safeSetUpdateState({ error: errorMsg });
return false;
}
// Get current update info from state
const currentState = this.storeService.get<"update-state">(UPDATE_STATE_KEY);
if (!currentState?.info) {
const errorMsg = "No update information available. Please check for updates first.";
log.error(`[UpdateService] ${errorMsg}`);
this.safeSetUpdateState({ error: errorMsg });
return false;
}
// Start download
this.safeSetUpdateState({
downloading: true,
downloadProgress: 0,
error: null,
});
await this.downloadUpdate(currentState.info);
this.safeSetUpdateState({
downloading: false,
downloaded: true,
downloadProgress: 100,
});
return true;
} catch (error) {
const errorMsg = `Update download failed: ${error}`;
log.error(`[UpdateService] ${errorMsg}`);
this.safeSetUpdateState({
downloading: false,
error: errorMsg,
});
return false;
}
});
// Install update handler
ipcMain.handle("update:install", async () => {
try {
log.info("[UpdateService] Update installation requested");
if (process.env.NODE_ENV !== "production") {
const errorMsg = "Installation is only available in production builds";
log.warn(`[UpdateService] ${errorMsg}`);
this.safeSetUpdateState({ error: errorMsg });
return false;
}
if (!process.env.S3_BUCKET) {
const errorMsg = "Update server not configured. Installation will be available when released.";
log.warn(`[UpdateService] S3_BUCKET not configured for installation`);
this.safeSetUpdateState({ error: errorMsg });
return false;
}
// Install the update
await this.installUpdate();
return true;
} catch (error) {
const errorMsg = `Update installation failed: ${error}`;
log.error(`[UpdateService] ${errorMsg}`);
this.safeSetUpdateState({ error: errorMsg });
return false;
}
});
UpdateService.handlersInitialized = true;
log.info("[UpdateService] IPC handlers successfully initialized");
}
// Helper to safely set update state in the store
private safeSetUpdateState(state: UpdateState | Partial<UpdateState>): void {
try {
this.storeService.set(UPDATE_STATE_KEY, state as any);
} catch (error) {
log.error(`[UpdateService] Error setting update state: ${error}`);
}
}
public startPeriodicUpdateChecks(intervalMinutes = 240): void {
log.info(`[UpdateService] Starting periodic update checks every ${intervalMinutes} minutes`);
// Only start periodic checks in production with S3 configured
if (process.env.NODE_ENV === "production" && process.env.S3_BUCKET) {
this.updateCheckInterval = setInterval(
async () => {
try {
log.info("[UpdateService] Running periodic update check");
await this.checkForUpdatesOnS3();
} catch (error) {
log.error(`[UpdateService] Periodic update check failed: ${error}`);
}
},
intervalMinutes * 60 * 1000,
);
}
}
public stopPeriodicUpdateChecks(): void {
log.info("[UpdateService] Stopping periodic update checks");
if (this.updateCheckInterval) {
clearInterval(this.updateCheckInterval);
this.updateCheckInterval = undefined;
}
}
}
export function getUpdateService(storeService?: StoreService): UpdateService {
if (!storeService) {
throw new Error("StoreService is required for UpdateService");
}
// Return existing instance if available
if (updateServiceInstance) {
log.info("[UpdateService] Returning existing singleton instance");
return updateServiceInstance;
}
// Create new instance
log.info("[UpdateService] Creating new singleton instance");
updateServiceInstance = new UpdateService(storeService);
return updateServiceInstance;
}