feat: Implement update management refactor with new dialog and settings handling

This commit is contained in:
2026-05-24 22:31:18 +02:00
parent 54ab1fcb9f
commit c8e2edc0c0
6 changed files with 368 additions and 121 deletions
+46
View File
@@ -0,0 +1,46 @@
import { BrowserWindow, dialog } from "electron";
import type { MessageBoxOptions } from "electron";
import { ReleaseUpdate } from "./update-utils";
export type DownloadUpdateChoice = "download" | "open-release" | "dismiss";
export async function askToDownloadUpdate(
update: ReleaseUpdate,
releasePageUrl: string | undefined,
parentWindow: BrowserWindow | null,
): Promise<DownloadUpdateChoice> {
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) {
return "open-release";
}
return result.response === 0 ? "download" : "dismiss";
}
export 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;
}
function showMessageBox(parentWindow: BrowserWindow | null, options: MessageBoxOptions) {
return parentWindow ? dialog.showMessageBox(parentWindow, options) : dialog.showMessageBox(options);
}
+34
View File
@@ -0,0 +1,34 @@
import fs from "node:fs";
import path from "node:path";
import { Readable } from "node:stream";
import { ReleaseUpdate, sanitizeFileName } from "./update-utils";
type UpdateDownloadConfig = {
tempDirectory: string;
};
export async function downloadInstaller(update: ReleaseUpdate, config: UpdateDownloadConfig): Promise<string> {
const safeFileName = sanitizeFileName(update.installer.name);
const downloadDirectory = path.join(config.tempDirectory, "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;
}
+17 -121
View File
@@ -1,11 +1,10 @@
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 { app, BrowserWindow, shell } from "electron";
import { AppRuntimeConfig } from "../config/runtime-config";
import { buildReleaseUpdate, GiteaRelease, ReleaseUpdate, sanitizeFileName, UpdateFileConfig } from "./update-utils";
import { askToDownloadUpdate, askToInstallUpdate } from "./update-dialogs";
import { downloadInstaller } from "./update-download";
import { loadUpdateSettings, UpdateSettings } from "./update-settings";
import { buildReleaseUpdate, GiteaRelease } from "./update-utils";
type UpdateManagerConfig = {
appConfig: AppRuntimeConfig;
@@ -15,13 +14,6 @@ type UpdateManagerConfig = {
log: (...args: unknown[]) => void;
};
type UpdateSettings = {
enabled: boolean;
apiUrl?: string;
releasePageUrl?: string;
assetPattern: string;
};
export function scheduleUpdateCheck({
appConfig,
rootPath,
@@ -65,16 +57,19 @@ async function checkForUpdates({
return;
}
const shouldDownload = await askToDownloadUpdate(
update,
settings.releasePageUrl ?? update.pageUrl,
getParentWindow(),
);
if (!shouldDownload) {
const releasePageUrl = settings.releasePageUrl ?? update.pageUrl;
const downloadChoice = await askToDownloadUpdate(update, releasePageUrl, getParentWindow());
if (downloadChoice === "open-release") {
await openReleasePage(releasePageUrl);
return;
}
const installerPath = await downloadInstaller(update);
if (downloadChoice !== "download") {
return;
}
const installerPath = await downloadInstaller(update, { tempDirectory: app.getPath("temp") });
const shouldInstall = await askToInstallUpdate(update, getParentWindow());
if (!shouldInstall) {
await shell.showItemInFolder(installerPath);
@@ -93,41 +88,6 @@ async function checkForUpdates({
}
}
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: {
@@ -142,72 +102,8 @@ async function fetchLatestRelease(apiUrl: string): Promise<GiteaRelease> {
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) {
async function openReleasePage(releasePageUrl: string | undefined): Promise<void> {
if (releasePageUrl) {
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);
}
+71
View File
@@ -0,0 +1,71 @@
import fs from "node:fs";
import path from "node:path";
import { AppRuntimeConfig } from "../config/runtime-config";
import { UpdateFileConfig } from "./update-utils";
const DEFAULT_UPDATE_ASSET_PATTERN = "Scoreko-setup-.*\\.exe$";
export type UpdateSettings = {
enabled: boolean;
apiUrl?: string;
releasePageUrl?: string;
assetPattern: string;
};
export 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) || DEFAULT_UPDATE_ASSET_PATTERN,
};
}
export 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 {
const parsedConfig: unknown = JSON.parse(fs.readFileSync(configPath, "utf8"));
return normalizeUpdateFileConfig(parsedConfig);
} catch (error) {
log(`Could not read update config at ${configPath}.`, error);
return {};
}
}
function normalizeUpdateFileConfig(value: unknown): UpdateFileConfig {
if (!isRecord(value)) {
return {};
}
return {
enabled: value.enabled,
apiUrl: value.apiUrl,
releasePageUrl: value.releasePageUrl,
assetPattern: value.assetPattern,
};
}
function readOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}