328 lines
10 KiB
JavaScript
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,
|
|
};
|