feat: implement Gitea update checks and installer management

This commit is contained in:
2026-05-16 23:10:05 +02:00
parent 955a1f7116
commit fbc709463f
13 changed files with 547 additions and 3 deletions
+50 -1
View File
@@ -10,6 +10,12 @@ export type AppRuntimeConfig = {
loadDelayMs: number;
startupTimeoutMs: number;
nodecgKillTimeoutMs: number;
updatesEnabled: boolean;
updateApiUrl?: string;
updateReleasePageUrl?: string;
updateAssetPattern?: string;
updateConfigPathOverride?: string;
updateCheckDelayMs: number;
};
const MIN_TCP_PORT = 1;
@@ -29,6 +35,12 @@ export function getRuntimeConfig(): AppRuntimeConfig {
loadDelayMs: parseEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000),
startupTimeoutMs: parseEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000),
nodecgKillTimeoutMs: parseEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000),
updatesEnabled: parseEnvBool("SCOREKO_UPDATES_ENABLED", true),
updateApiUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_API_URL"),
updateReleasePageUrl: parseOptionalHttpUrl("SCOREKO_UPDATE_RELEASE_PAGE_URL"),
updateAssetPattern: getOptionalEnv("SCOREKO_UPDATE_ASSET_PATTERN"),
updateConfigPathOverride: getOptionalEnv("SCOREKO_UPDATE_CONFIG_PATH"),
updateCheckDelayMs: parseEnvIntInRange("SCOREKO_UPDATE_CHECK_DELAY_MS", 5000, 0, 600000),
};
}
@@ -60,12 +72,49 @@ export function parseEnvIntInRange(name: string, fallback: number, min: number,
const parsedValue = Number.parseInt(rawValue, 10);
if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) {
throw new Error(`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`);
throw new Error(
`The ${name} variable must be an integer between ${min} and ${max}. Received value: '${rawValue}'.`,
);
}
return parsedValue;
}
export function parseEnvBool(name: string, fallback: boolean): boolean {
const rawValue = process.env[name]?.trim().toLowerCase();
if (!rawValue) {
return fallback;
}
if (["1", "true", "yes", "on"].includes(rawValue)) {
return true;
}
if (["0", "false", "no", "off"].includes(rawValue)) {
return false;
}
throw new Error(`The ${name} variable must be a boolean. Received value: '${process.env[name]}'.`);
}
export function parseOptionalHttpUrl(name: string): string | undefined {
const rawValue = getOptionalEnv(name);
if (!rawValue) {
return undefined;
}
try {
const url = new URL(rawValue);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("unsupported protocol");
}
return url.toString();
} catch {
throw new Error(`The ${name} variable must be a valid HTTP(S) URL. Received value: '${rawValue}'.`);
}
}
export function parseEnvPort(name: string, fallback: string): string {
const rawValue = getEnv(name, fallback);
const parsedValue = Number.parseInt(rawValue, 10);
+8
View File
@@ -5,6 +5,7 @@ import { getRuntimeConfig } from "./config/runtime-config";
import { showFatalError, log } from "./errors/error-presenter";
import { createNodecgProcessManager, NodecgProcessManager } from "./nodecg/process-manager";
import { prepareUserNodecgRuntime } from "./nodecg/runtime-provisioner";
import { scheduleUpdateCheck } from "./updates/update-manager";
import { getRemainingDelayMs } from "./utils/timing";
import { createLoadingWindow, createMainWindow } from "./windows/window-factory";
@@ -102,6 +103,13 @@ async function launchApplication(): Promise<void> {
mainWindow.show();
closeLoadingWindow();
scheduleUpdateCheck({
appConfig,
rootPath,
getParentWindow: () => mainWindow,
beforeInstall: stopNodecgGracefully,
log,
});
}
async function startNodecg(): Promise<void> {
+213
View File
@@ -0,0 +1,213 @@
import { app, BrowserWindow, dialog, shell } from "electron";
import type { MessageBoxOptions } from "electron";
import fs from "node:fs";
import path from "node:path";
import { Readable } from "node:stream";
import { AppRuntimeConfig } from "../config/runtime-config";
import { buildReleaseUpdate, GiteaRelease, ReleaseUpdate, sanitizeFileName, UpdateFileConfig } from "./update-utils";
type UpdateManagerConfig = {
appConfig: AppRuntimeConfig;
rootPath: string;
getParentWindow: () => BrowserWindow | null;
beforeInstall: () => Promise<void>;
log: (...args: unknown[]) => void;
};
type UpdateSettings = {
enabled: boolean;
apiUrl?: string;
releasePageUrl?: string;
assetPattern: string;
};
export function scheduleUpdateCheck({
appConfig,
rootPath,
getParentWindow,
beforeInstall,
log,
}: UpdateManagerConfig): void {
const settings = loadUpdateSettings(appConfig, rootPath, log);
if (!settings.enabled || !settings.apiUrl) {
log("Update checks disabled or not configured.");
return;
}
setTimeout(() => {
void checkForUpdates({ settings, getParentWindow, beforeInstall, log });
}, appConfig.updateCheckDelayMs);
}
async function checkForUpdates({
settings,
getParentWindow,
beforeInstall,
log,
}: {
settings: UpdateSettings;
getParentWindow: () => BrowserWindow | null;
beforeInstall: () => Promise<void>;
log: (...args: unknown[]) => void;
}): Promise<void> {
try {
if (!settings.apiUrl) {
return;
}
const release = await fetchLatestRelease(settings.apiUrl);
const update = buildReleaseUpdate(release, app.getVersion(), settings.assetPattern);
if (!update) {
log("No Scoreko update available.");
return;
}
const shouldDownload = await askToDownloadUpdate(
update,
settings.releasePageUrl ?? update.pageUrl,
getParentWindow(),
);
if (!shouldDownload) {
return;
}
const installerPath = await downloadInstaller(update);
const shouldInstall = await askToInstallUpdate(update, getParentWindow());
if (!shouldInstall) {
await shell.showItemInFolder(installerPath);
return;
}
await beforeInstall();
const openError = await shell.openPath(installerPath);
if (openError) {
throw new Error(openError);
}
app.exit(0);
} catch (error) {
log("Update check failed.", error);
}
}
function loadUpdateSettings(
appConfig: AppRuntimeConfig,
rootPath: string,
log: (...args: unknown[]) => void,
): UpdateSettings {
const fileConfig = readUpdateFileConfig(appConfig, rootPath, log);
return {
enabled: appConfig.updatesEnabled && (Boolean(fileConfig.enabled) || Boolean(appConfig.updateApiUrl)),
apiUrl: appConfig.updateApiUrl ?? readOptionalString(fileConfig.apiUrl),
releasePageUrl: appConfig.updateReleasePageUrl ?? readOptionalString(fileConfig.releasePageUrl),
assetPattern:
appConfig.updateAssetPattern || readOptionalString(fileConfig.assetPattern) || "Scoreko-setup-.*\\.exe$",
};
}
function readUpdateFileConfig(
appConfig: AppRuntimeConfig,
rootPath: string,
log: (...args: unknown[]) => void,
): UpdateFileConfig {
const configPath = appConfig.updateConfigPathOverride ?? path.join(rootPath, "static", "updates.json");
if (!fs.existsSync(configPath)) {
return {};
}
try {
return JSON.parse(fs.readFileSync(configPath, "utf8")) as UpdateFileConfig;
} catch (error) {
log(`Could not read update config at ${configPath}.`, error);
return {};
}
}
async function fetchLatestRelease(apiUrl: string): Promise<GiteaRelease> {
const response = await fetch(apiUrl, {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Gitea update check failed with HTTP ${response.status}.`);
}
return (await response.json()) as GiteaRelease;
}
async function askToDownloadUpdate(
update: ReleaseUpdate,
releasePageUrl: string | undefined,
parentWindow: BrowserWindow | null,
): Promise<boolean> {
const result = await showMessageBox(parentWindow, {
type: "info",
title: "Actualización disponible",
message: `Scoreko ${update.version} está disponible.`,
detail: "Puedes descargarla ahora o seguir usando esta versión.",
buttons: releasePageUrl ? ["Descargar", "Ver release", "Ahora no"] : ["Descargar", "Ahora no"],
defaultId: 0,
cancelId: releasePageUrl ? 2 : 1,
});
if (releasePageUrl && result.response === 1) {
await shell.openExternal(releasePageUrl);
return false;
}
return result.response === 0;
}
async function askToInstallUpdate(update: ReleaseUpdate, parentWindow: BrowserWindow | null): Promise<boolean> {
const result = await showMessageBox(parentWindow, {
type: "question",
title: "Actualización descargada",
message: `Scoreko ${update.version} se ha descargado.`,
detail: "Para instalarla se cerrará Scoreko y se abrirá el instalador.",
buttons: ["Instalar y cerrar", "Luego"],
defaultId: 0,
cancelId: 1,
});
return result.response === 0;
}
async function downloadInstaller(update: ReleaseUpdate): Promise<string> {
const safeFileName = sanitizeFileName(update.installer.name);
const downloadDirectory = path.join(app.getPath("temp"), "scoreko-updates");
const targetPath = path.join(downloadDirectory, safeFileName);
fs.mkdirSync(downloadDirectory, { recursive: true });
const response = await fetch(update.installer.downloadUrl);
if (!response.ok || !response.body) {
throw new Error(`Could not download update installer. HTTP ${response.status}.`);
}
await new Promise<void>((resolve, reject) => {
const fileStream = fs.createWriteStream(targetPath);
const responseStream = Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]);
responseStream.on("error", reject);
fileStream.on("error", reject);
fileStream.on("finish", resolve);
responseStream.pipe(fileStream);
});
return targetPath;
}
function readOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function showMessageBox(parentWindow: BrowserWindow | null, options: MessageBoxOptions) {
return parentWindow ? dialog.showMessageBox(parentWindow, options) : dialog.showMessageBox(options);
}
+123
View File
@@ -0,0 +1,123 @@
export type GiteaReleaseAsset = {
name?: unknown;
browser_download_url?: unknown;
size?: unknown;
};
export type GiteaRelease = {
tag_name?: unknown;
name?: unknown;
html_url?: unknown;
assets?: unknown;
};
export type InstallerAsset = {
name: string;
downloadUrl: string;
size?: number;
};
export type ReleaseUpdate = {
version: string;
title: string;
pageUrl?: string;
installer: InstallerAsset;
};
export type UpdateFileConfig = {
enabled?: unknown;
apiUrl?: unknown;
releasePageUrl?: unknown;
assetPattern?: unknown;
};
export function isVersionNewer(candidateVersion: string, currentVersion: string): boolean {
const candidate = normalizeVersion(candidateVersion);
const current = normalizeVersion(currentVersion);
for (let index = 0; index < Math.max(candidate.length, current.length); index += 1) {
const candidatePart = candidate[index] ?? 0;
const currentPart = current[index] ?? 0;
if (candidatePart > currentPart) {
return true;
}
if (candidatePart < currentPart) {
return false;
}
}
return false;
}
export function getReleaseVersion(release: GiteaRelease): string | null {
const tagName = typeof release.tag_name === "string" ? release.tag_name.trim() : "";
return tagName.length > 0 ? tagName.replace(/^v/i, "") : null;
}
export function getReleaseTitle(release: GiteaRelease, version: string): string {
const releaseName = typeof release.name === "string" ? release.name.trim() : "";
return releaseName.length > 0 ? releaseName : `Scoreko ${version}`;
}
export function selectInstallerAsset(release: GiteaRelease, assetPattern: string): InstallerAsset | null {
const assets = Array.isArray(release.assets) ? release.assets : [];
const matcher = new RegExp(assetPattern, "i");
for (const asset of assets as GiteaReleaseAsset[]) {
const name = typeof asset.name === "string" ? asset.name : "";
const downloadUrl = typeof asset.browser_download_url === "string" ? asset.browser_download_url : "";
if (!name || !downloadUrl || !matcher.test(name)) {
continue;
}
return {
name,
downloadUrl,
...(typeof asset.size === "number" ? { size: asset.size } : {}),
};
}
return null;
}
export function buildReleaseUpdate(
release: GiteaRelease,
currentVersion: string,
assetPattern: string,
): ReleaseUpdate | null {
const version = getReleaseVersion(release);
if (!version || !isVersionNewer(version, currentVersion)) {
return null;
}
const installer = selectInstallerAsset(release, assetPattern);
if (!installer) {
return null;
}
const pageUrl = typeof release.html_url === "string" && release.html_url.length > 0 ? release.html_url : undefined;
return {
version,
title: getReleaseTitle(release, version),
pageUrl,
installer,
};
}
export function sanitizeFileName(fileName: string): string {
return fileName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_");
}
function normalizeVersion(version: string): number[] {
return version
.trim()
.replace(/^v/i, "")
.split(/[+-]/)[0]
.split(".")
.map((part) => Number.parseInt(part, 10))
.map((part) => (Number.isFinite(part) ? part : 0));
}