/** * 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 ab */ 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, };