Files

328 lines
10 KiB
JavaScript

/**
* Auto-update desde MinIO (apps-versions/IPSExtractor).
* Sin credenciales: lee latest.json y el Setup.exe con GetObject público.
*/
const { app } = require("electron");
const crypto = require("node:crypto");
const fs = require("node:fs");
const fsp = require("node:fs/promises");
const http = require("node:http");
const https = require("node:https");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const { URL } = require("node:url");
const { version: CURRENT_VERSION } = require("../package.json");
const LATEST_JSON_URL =
process.env.IPS_UPDATE_LATEST_URL ||
"https://s3.builker.xyz/apps-versions/IPSExtractor/latest.json";
const FETCH_TIMEOUT_MS = 12_000;
/**
* @typedef {{
* version: string;
* releaseDate?: string;
* releaseDateDisplay?: string;
* fileName: string;
* url: string;
* sha256: string;
* size?: number;
* }} UpdateManifest
*/
/**
* @param {string} a
* @param {string} b
* @returns {number} -1 if a<b, 0 if equal, 1 if a>b
*/
function compareSemver(a, b) {
const pa = String(a).replace(/^v/i, "").split(".").map((x) => Number.parseInt(x, 10) || 0);
const pb = String(b).replace(/^v/i, "").split(".").map((x) => Number.parseInt(x, 10) || 0);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const x = pa[i] ?? 0;
const y = pb[i] ?? 0;
if (x < y) return -1;
if (x > y) return 1;
}
return 0;
}
/**
* @param {string} urlStr
* @param {{ onProgress?: (received: number, total: number) => void, destPath?: string }} [opts]
* @returns {Promise<{ buffer?: Buffer, destPath?: string, headers: import('http').IncomingHttpHeaders }>}
*/
function download(urlStr, opts = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlStr);
const lib = url.protocol === "http:" ? http : https;
const req = lib.get(
url,
{
timeout: FETCH_TIMEOUT_MS,
headers: { "User-Agent": `Flujo-IPS/${CURRENT_VERSION}` },
},
(res) => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
download(res.headers.location, opts).then(resolve, reject);
return;
}
if (res.statusCode !== 200) {
res.resume();
reject(new Error(`HTTP ${res.statusCode} al descargar ${urlStr}`));
return;
}
const total = Number(res.headers["content-length"] || 0);
let received = 0;
if (opts.destPath) {
const out = fs.createWriteStream(opts.destPath);
res.on("data", (chunk) => {
received += chunk.length;
opts.onProgress?.(received, total);
});
res.pipe(out);
out.on("finish", () => resolve({ destPath: opts.destPath, headers: res.headers }));
out.on("error", reject);
res.on("error", reject);
return;
}
const chunks = [];
res.on("data", (chunk) => {
chunks.push(chunk);
received += chunk.length;
opts.onProgress?.(received, total);
});
res.on("end", () => resolve({ buffer: Buffer.concat(chunks), headers: res.headers }));
res.on("error", reject);
},
);
req.on("timeout", () => {
req.destroy();
reject(new Error("Timeout al contactar el servidor de versiones"));
});
req.on("error", reject);
});
}
/**
* @returns {Promise<{ updateAvailable: boolean, currentVersion: string, manifest?: UpdateManifest, error?: string }>}
*/
async function checkForUpdates() {
try {
const { buffer } = await download(LATEST_JSON_URL);
if (!buffer) {
return { updateAvailable: false, currentVersion: CURRENT_VERSION, error: "Respuesta vacía" };
}
/** @type {UpdateManifest} */
const manifest = JSON.parse(buffer.toString("utf8"));
if (!manifest?.version || !manifest?.url || !manifest?.fileName) {
return {
updateAvailable: false,
currentVersion: CURRENT_VERSION,
error: "Manifiesto inválido",
};
}
// Si falta la fecha de DATA.txt en el manifiesto, intentar leerla junto al exe
if (!manifest.releaseDateDisplay) {
try {
const dataUrl = manifest.url.replace(/[^/]+$/, "DATA.txt");
const { buffer: dataBuf } = await download(dataUrl);
if (dataBuf) {
const display = dataBuf.toString("utf8").trim().split(/\r?\n/)[0]?.trim();
if (display) {
manifest.releaseDateDisplay = display;
const iso = display.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (iso && !manifest.releaseDate) {
manifest.releaseDate = `${iso[3]}-${iso[2].padStart(2, "0")}-${iso[1].padStart(2, "0")}`;
}
}
}
} catch {
/* opcional */
}
}
const newer = compareSemver(CURRENT_VERSION, manifest.version) < 0;
return {
updateAvailable: newer,
currentVersion: CURRENT_VERSION,
manifest,
};
} catch (err) {
return {
updateAvailable: false,
currentVersion: CURRENT_VERSION,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* @param {UpdateManifest} manifest
* @param {(payload: { phase: string, received?: number, total?: number, percent?: number }) => void} [onProgress]
*/
async function downloadAndInstall(manifest, onProgress) {
if (!manifest?.url || !manifest?.fileName) {
throw new Error("Manifiesto incompleto");
}
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "ipsextractor-update-"));
const destPath = path.join(tmpDir, path.basename(manifest.fileName));
onProgress?.({ phase: "downloading", received: 0, total: manifest.size || 0, percent: 0 });
await download(manifest.url, {
destPath,
onProgress: (received, total) => {
const t = total || manifest.size || 0;
const percent = t > 0 ? Math.min(100, Math.round((received / t) * 100)) : 0;
onProgress?.({ phase: "downloading", received, total: t, percent });
},
});
onProgress?.({ phase: "verifying" });
if (manifest.sha256) {
const hash = crypto.createHash("sha256");
const fileBuf = await fsp.readFile(destPath);
hash.update(fileBuf);
const digest = hash.digest("hex").toLowerCase();
if (digest !== String(manifest.sha256).toLowerCase()) {
await fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => undefined);
throw new Error("El instalador descargado no coincide con el hash esperado (sha256).");
}
}
onProgress?.({ phase: "installing" });
// NSIS silent install; la app debe cerrarse para que el instalador reemplace archivos.
const child = spawn(destPath, ["/S"], {
detached: true,
stdio: "ignore",
windowsHide: false,
});
child.unref();
setTimeout(() => {
app.quit();
}, 2500);
return { ok: true, destPath };
}
/**
* Fecha de release de la versión instalada, leída desde MinIO
* (latest.json si coincide la versión, si no DATA.txt de esa carpeta).
* @returns {Promise<{ releaseDate?: string, releaseDateDisplay?: string } | null>}
*/
async function getCurrentReleaseMeta() {
const base = LATEST_JSON_URL.replace(/\/latest\.json$/i, "");
try {
const { buffer } = await download(LATEST_JSON_URL);
if (buffer) {
/** @type {UpdateManifest} */
const manifest = JSON.parse(buffer.toString("utf8"));
if (manifest?.version && compareSemver(CURRENT_VERSION, manifest.version) === 0) {
if (!manifest.releaseDateDisplay) {
try {
const dataUrl = `${base}/${CURRENT_VERSION}/DATA.txt`;
const { buffer: dataBuf } = await download(dataUrl);
const display = dataBuf?.toString("utf8").trim().split(/\r?\n/)[0]?.trim();
if (display) {
manifest.releaseDateDisplay = display;
const iso = display.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (iso && !manifest.releaseDate) {
manifest.releaseDate = `${iso[3]}-${iso[2].padStart(2, "0")}-${iso[1].padStart(2, "0")}`;
}
}
} catch {
/* opcional */
}
}
if (manifest.releaseDate || manifest.releaseDateDisplay) {
return {
releaseDate: manifest.releaseDate,
releaseDateDisplay: manifest.releaseDateDisplay,
};
}
}
}
} catch {
/* probar DATA.txt de la versión instalada */
}
try {
const dataUrl = `${base}/${CURRENT_VERSION}/DATA.txt`;
const { buffer: dataBuf } = await download(dataUrl);
const display = dataBuf?.toString("utf8").trim().split(/\r?\n/)[0]?.trim();
if (!display) return null;
const isoMatch = display.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
return {
releaseDateDisplay: display,
releaseDate: isoMatch
? `${isoMatch[3]}-${isoMatch[2].padStart(2, "0")}-${isoMatch[1].padStart(2, "0")}`
: undefined,
};
} catch {
return null;
}
}
/**
* Lista todas las versiones publicadas (versions.json).
* Si no existe, cae a [latest.json].
* @returns {Promise<{ currentVersion: string, versions: UpdateManifest[], error?: string }>}
*/
async function listVersions() {
const versionsUrl = LATEST_JSON_URL.replace(/\/latest\.json$/i, "/versions.json");
try {
const { buffer } = await download(versionsUrl);
if (buffer) {
const parsed = JSON.parse(buffer.toString("utf8"));
const versions = Array.isArray(parsed?.versions)
? parsed.versions.filter((v) => v?.version && v?.url && v?.fileName)
: [];
if (versions.length > 0) {
versions.sort((a, b) => compareSemver(b.version, a.version));
return { currentVersion: CURRENT_VERSION, versions };
}
}
} catch {
/* fallback latest */
}
try {
const { buffer } = await download(LATEST_JSON_URL);
if (!buffer) {
return { currentVersion: CURRENT_VERSION, versions: [], error: "Respuesta vacía" };
}
const manifest = JSON.parse(buffer.toString("utf8"));
if (!manifest?.version || !manifest?.url || !manifest?.fileName) {
return { currentVersion: CURRENT_VERSION, versions: [], error: "Manifiesto inválido" };
}
return { currentVersion: CURRENT_VERSION, versions: [manifest] };
} catch (err) {
return {
currentVersion: CURRENT_VERSION,
versions: [],
error: err instanceof Error ? err.message : String(err),
};
}
}
module.exports = {
CURRENT_VERSION,
LATEST_JSON_URL,
compareSemver,
checkForUpdates,
downloadAndInstall,
getCurrentReleaseMeta,
listVersions,
};