UNPKG

backsplash-app

Version:
547 lines (470 loc) 17.7 kB
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; }