257 lines
8.1 KiB
TypeScript
257 lines
8.1 KiB
TypeScript
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;
|
|
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<VersionResponse | null> {
|
|
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 } | 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
|
|
): void {
|
|
pendingUpdate = { version, downloadUrl };
|
|
setTimeout(() => {
|
|
if (win.isDestroyed() || !pendingUpdate) return;
|
|
log(`Injecting banner now`);
|
|
injectUpdateBanner(win, pendingUpdate.version, pendingUpdate.downloadUrl);
|
|
}, INJECT_DELAY_MS);
|
|
}
|
|
|
|
/**
|
|
* Injects a Cursor-style update banner at the bottom of the renderer window.
|
|
* Safe to call multiple times; a second call while the banner is visible is a no-op.
|
|
*/
|
|
function injectUpdateBanner(
|
|
win: BrowserWindow,
|
|
latest: string,
|
|
downloadUrl: string,
|
|
releaseNotes?: string
|
|
): void {
|
|
const notesHtml = releaseNotes
|
|
? `<span style="color:#a6adc8;margin-right:8px;">${releaseNotes}</span>`
|
|
: "";
|
|
|
|
const script = /* js */ `
|
|
(function () {
|
|
if (document.getElementById('__presenton_update_banner__')) return;
|
|
|
|
const banner = document.createElement('div');
|
|
banner.id = '__presenton_update_banner__';
|
|
banner.style.cssText = [
|
|
'position:fixed',
|
|
'bottom:0',
|
|
'left:0',
|
|
'right:0',
|
|
'background:#1e1e2e',
|
|
'color:#cdd6f4',
|
|
'display:flex',
|
|
'align-items:center',
|
|
'justify-content:space-between',
|
|
'padding:10px 18px',
|
|
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
|
|
'font-size:13px',
|
|
'z-index:2147483647',
|
|
'border-top:1px solid #313244',
|
|
'box-shadow:0 -4px 16px rgba(0,0,0,0.35)',
|
|
'gap:12px',
|
|
].join(';');
|
|
|
|
banner.innerHTML = \`
|
|
<span style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;">
|
|
<span style="font-size:16px;">✨</span>
|
|
<span>
|
|
Presenton <strong>${latest}</strong> is available
|
|
— you have <strong>${CURRENT_VERSION}</strong>
|
|
</span>
|
|
${notesHtml}
|
|
</span>
|
|
<div style="display:flex;gap:8px;align-items:center;flex-shrink:0;">
|
|
<a
|
|
href="${downloadUrl}"
|
|
target="_blank"
|
|
style="color:#89b4fa;text-decoration:none;border:1px solid #89b4fa;padding:4px 14px;border-radius:5px;font-size:12px;white-space:nowrap;"
|
|
>Download update</a>
|
|
<button
|
|
onclick="document.getElementById('__presenton_update_banner__').remove()"
|
|
title="Dismiss"
|
|
style="background:none;border:none;color:#6c7086;cursor:pointer;font-size:20px;line-height:1;padding:0 2px;"
|
|
>×</button>
|
|
</div>
|
|
\`;
|
|
|
|
document.body.appendChild(banner);
|
|
})();
|
|
`;
|
|
|
|
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<void> {
|
|
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);
|
|
} 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<void>((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);
|
|
} 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;
|
|
}
|