import { net } from "electron"; import { app, BrowserWindow } from "electron"; import { isDev } from "./constants"; /** * Version check URL — GitHub raw version.json (no API required). * Override with UPDATE_SERVER_URL for local testing. */ const VERSION_JSON_URL = process.env.UPDATE_SERVER_URL || "https://raw.githubusercontent.com/presenton/presenton/refs/heads/main/electron/version.json"; const CURRENT_VERSION = app.getVersion(); /** Maximum number of fetch attempts (polls). */ const MAX_ATTEMPTS = 3; /** Wait 2 minutes after load before first poll (10s in dev for testing). */ const INITIAL_DELAY_MS = isDev ? 10 * 1_000 : 2 * 60 * 1_000; /** 1 minute between poll attempts (5s in dev for testing). */ const POLL_INTERVAL_MS = isDev ? 5 * 1_000 : 1 * 60 * 1_000; /** Short delay before injecting banner to allow React/Next.js to mount. */ const INJECT_DELAY_MS = isDev ? 500 : 1_000; function log(msg: string): void { const line = `[UpdateChecker] ${msg}\n`; process.stderr.write(line); console.log(`[UpdateChecker] ${msg}`); } interface VersionResponse { version: string; message?: string; downloads: { linux: string; mac: string; windows: string; }; } function getDownloadUrlForPlatform(downloads: VersionResponse["downloads"]): string { const platform = process.platform; if (platform === "darwin") return downloads.mac; if (platform === "win32") return downloads.windows; return downloads.linux; } /** * Simple semver comparison that strips pre-release labels for numeric comparison. * Returns true if `remote` is strictly newer than `current`. */ function isNewerVersion(current: string, remote: string): boolean { const toNumbers = (v: string) => v .replace(/[^0-9.]/g, "") .split(".") .map(Number); const curr = toNumbers(current); const rem = toNumbers(remote); const len = Math.max(curr.length, rem.length); for (let i = 0; i < len; i++) { const c = curr[i] ?? 0; const r = rem[i] ?? 0; if (r > c) return true; if (r < c) return false; } return false; } async function fetchVersionInfo(): Promise { try { log(`Fetching ${VERSION_JSON_URL}...`); const response = await net.fetch(VERSION_JSON_URL, { method: "GET", headers: { "User-Agent": `Presenton/${CURRENT_VERSION}` }, }); if (!response.ok) { log(`Fetch failed: HTTP ${response.status}`); return null; } const data = (await response.json()) as VersionResponse; log(`Fetched version: ${data.version}`); return data; } catch (err) { log(`Fetch error: ${err}`); return null; } } /** Pending update to re-inject on navigation (production: React/Next.js may replace DOM). */ let pendingUpdate: { version: string; downloadUrl: string; message?: string } | null = null; /** * Schedules banner injection after INJECT_DELAY_MS so React/Next.js can mount first. * In production (.deb), the DOM may not be ready when did-finish-load fires. */ function scheduleBannerInjection( win: BrowserWindow, version: string, downloadUrl: string, message?: string ): void { pendingUpdate = { version, downloadUrl, message }; setTimeout(() => { if (win.isDestroyed() || !pendingUpdate) return; log(`Injecting banner now`); injectUpdateBanner(win, pendingUpdate.version, pendingUpdate.downloadUrl, pendingUpdate.message); }, INJECT_DELAY_MS); } /** Escape HTML to prevent XSS; preserve newlines for display. */ function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/\n/g, "
"); } /** * Injects an update banner at the bottom, aligned with the app UI. * Includes a "View details" overlay for changelog/message. */ function injectUpdateBanner( win: BrowserWindow, latest: string, downloadUrl: string, message?: string ): void { const hasMessage = Boolean(message && message.trim()); const safeMessage = hasMessage ? escapeHtml(message!.trim()) : ""; const safeMessageJson = JSON.stringify(safeMessage); const viewDetailsBtnHtml = hasMessage ? '' : ""; const script = /* js */ ` (function () { if (document.getElementById('__presenton_update_banner__')) return; const msgHtml = ${safeMessageJson}; const banner = document.createElement('div'); banner.id = '__presenton_update_banner__'; banner.style.cssText = [ 'position:fixed', 'bottom:16px', 'left:50%', 'transform:translateX(-50%)', 'max-width:min(560px,calc(100vw - 32px))', 'width:100%', 'background:rgba(255,255,255,0.95)', 'backdrop-filter:blur(12px)', '-webkit-backdrop-filter:blur(12px)', 'color:#191919', 'display:flex', 'align-items:center', 'justify-content:space-between', 'padding:12px 16px', 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', 'font-size:13px', 'z-index:2147483646', 'border:1px solid rgba(148,163,184,0.3)', 'border-radius:12px', 'box-shadow:0 4px 24px rgba(0,0,0,0.08)', 'gap:12px', ].join(';'); banner.innerHTML = \` Presenton ${latest} is available — you have ${CURRENT_VERSION}
${viewDetailsBtnHtml} Download update
\`; document.body.appendChild(banner); if (msgHtml) { const overlay = document.createElement('div'); overlay.id = '__presenton_update_overlay__'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:none;align-items:center;justify-content:center;z-index:2147483647;padding:24px;'; overlay.onclick = function(e) { if (e.target === overlay) overlay.style.display = 'none'; }; overlay.innerHTML = \`

What's new in ${latest}

\`; document.body.appendChild(overlay); document.getElementById('__presenton_overlay_content__').innerHTML = msgHtml; document.getElementById('__presenton_view_details_btn__').onclick = function() { document.getElementById('__presenton_update_overlay__').style.display = 'flex'; }; } })(); `; win.webContents.executeJavaScript(script).catch((err) => { log(`Banner injection failed: ${err}`); }); } /** * Polls for version info up to MAX_ATTEMPTS times with 1 min between attempts. * Stops as soon as a successful response is received or all attempts are exhausted. */ async function checkForUpdatesWithRetry(win: BrowserWindow): Promise { log(`Starting check (current: ${CURRENT_VERSION})`); for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { if (win.isDestroyed()) { log("Window destroyed, aborting"); return; } log(`Attempt ${attempt}/${MAX_ATTEMPTS}`); const data = await fetchVersionInfo(); if (data) { const newer = isNewerVersion(CURRENT_VERSION, data.version); log(`Remote ${data.version} vs current ${CURRENT_VERSION} -> newer? ${newer}`); if (newer) { const downloadUrl = getDownloadUrlForPlatform(data.downloads); log(`Injecting banner for ${data.version} (after ${INJECT_DELAY_MS}ms delay)`); scheduleBannerInjection(win, data.version, downloadUrl, data.message); } else { log("No update needed, skipping banner"); } return; } // Wait 1 minute before the next poll (skip delay after the last attempt) if (attempt < MAX_ATTEMPTS) { log(`Next poll in ${POLL_INTERVAL_MS / 1_000}s...`); await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } } log("All attempts failed, no update info"); } /** * Starts the update checker. * Waits 2 minutes after load, then polls 3 times with 1 min interval. * Re-injects banner on every navigation (handles Next.js client routing). */ export function startUpdateChecker(win: BrowserWindow): void { log("Registered, waiting for did-finish-load"); let hasRunCheck = false; const onLoad = () => { if (pendingUpdate) { log("did-finish-load (navigation), re-injecting banner"); scheduleBannerInjection(win, pendingUpdate.version, pendingUpdate.downloadUrl, pendingUpdate.message); } else if (!hasRunCheck) { hasRunCheck = true; log(`did-finish-load fired, first poll in ${INITIAL_DELAY_MS / 1_000}s`); setTimeout(() => { if (win.isDestroyed()) return; checkForUpdatesWithRetry(win); }, INITIAL_DELAY_MS); } }; if (!win.webContents.isLoading()) { log(`Page already loaded, first poll in ${INITIAL_DELAY_MS / 1_000}s`); hasRunCheck = true; setTimeout(() => { if (win.isDestroyed()) return; checkForUpdatesWithRetry(win); }, INITIAL_DELAY_MS); } win.webContents.on("did-finish-load", onLoad); } export function stopUpdateChecker(): void { pendingUpdate = null; }