import { createHash } from "node:crypto"; import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises"; import { basename, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const frontendDir = fileURLToPath(new URL("../", import.meta.url)); const packageInfo = JSON.parse(await readFile(resolve(frontendDir, "package.json"), "utf8")); const failures = []; const args = process.argv.slice(2); const values = (name) => { const found = []; for (let index = 0; index < args.length; index += 1) { if (args[index] === name && args[index + 1]) { found.push(args[index + 1]); index += 1; } } return found; }; const value = (name) => values(name)[0]; const fail = (message) => failures.push(message); const assert = (condition, message) => { if (!condition) fail(message); }; const macosArtifactPath = value("--artifact") || process.env.DESKTOP_UPDATE_ARTIFACT; const outputDir = resolve( frontendDir, value("--output-dir") || process.env.DESKTOP_UPDATE_OUTPUT_DIR || "src-tauri/target/release/desktop-update-feed", ); const baseUrlRaw = value("--base-url") || process.env.DESKTOP_UPDATE_BASE_URL; const pubDate = value("--date") || process.env.DESKTOP_UPDATE_PUB_DATE || new Date().toISOString(); const notes = value("--notes") || process.env.DESKTOP_UPDATE_NOTES; const includes = values("--include").map((path) => resolve(frontendDir, path)); const platformArtifactSpecs = values("--platform-artifact"); const platformPattern = /^(?:darwin|windows|linux)-(?:x86_64|aarch64|i686|armv7)$/; assert(Boolean(macosArtifactPath), "--artifact or DESKTOP_UPDATE_ARTIFACT is required for the Universal macOS updater artifact."); assert(Boolean(baseUrlRaw), "--base-url or DESKTOP_UPDATE_BASE_URL is required."); const platformArtifacts = new Map(); if (macosArtifactPath) { const resolvedMacosArtifact = resolve(frontendDir, macosArtifactPath); platformArtifacts.set("darwin-aarch64", resolvedMacosArtifact); platformArtifacts.set("darwin-x86_64", resolvedMacosArtifact); } for (const spec of platformArtifactSpecs) { const separator = spec.indexOf("="); if (separator <= 0 || separator === spec.length - 1) { fail(`--platform-artifact must use -=: ${spec}`); continue; } const platform = spec.slice(0, separator); const path = spec.slice(separator + 1); assert(platformPattern.test(platform), `Unsupported updater platform key: ${platform}`); assert(!platformArtifacts.has(platform), `Updater platform is configured more than once: ${platform}`); if (platformPattern.test(platform) && !platformArtifacts.has(platform)) { platformArtifacts.set(platform, resolve(frontendDir, path)); } } const readRequiredFile = async (path, description) => { try { const data = await readFile(path); assert(data.length > 0, `${description} must not be empty: ${path}`); return data; } catch (error) { fail(`${description} cannot be read: ${path} (${error.message})`); return undefined; } }; const sha256 = async (path) => { const data = await readFile(path); return createHash("sha256").update(data).digest("hex"); }; const normalizedBaseUrl = () => { if (!baseUrlRaw) return undefined; try { const url = new URL(baseUrlRaw.endsWith("/") ? baseUrlRaw : `${baseUrlRaw}/`); assert(url.protocol === "https:", "Desktop update artifact base URL must use HTTPS."); assert(url.username === "" && url.password === "", "Desktop update artifact base URL must not include credentials."); assert(!/[?&]token=/i.test(url.search), "Desktop update artifact base URL must not include token query parameters."); assert(url.pathname.includes(packageInfo.version), `Desktop update artifact base URL must include version ${packageInfo.version}.`); return url; } catch (error) { fail(`Desktop update artifact base URL is invalid: ${baseUrlRaw} (${error.message})`); return undefined; } }; const artifactSignatures = new Map(); const uniqueArtifactPaths = [...new Set(platformArtifacts.values())]; for (const artifactPath of uniqueArtifactPaths) { const signaturePath = `${artifactPath}.sig`; await readRequiredFile(artifactPath, "Updater artifact"); const signature = await readRequiredFile(signaturePath, "Updater artifact signature"); if (signature) { const signatureText = signature.toString("utf8").trim(); assert(signatureText.length > 80, `Updater artifact signature is unexpectedly short: ${signaturePath}`); artifactSignatures.set(artifactPath, signatureText); } } for (const includePath of includes) { try { const metadata = await stat(includePath); assert(metadata.isFile(), `Included release file must be a file: ${includePath}`); } catch (error) { fail(`Included release file cannot be read: ${includePath} (${error.message})`); } } for (const [platform, artifactPath] of platformArtifacts) { if (platform.startsWith("darwin-")) { assert(artifactPath.endsWith(".app.tar.gz"), `${platform} must use a macOS .app.tar.gz updater artifact.`); } if (platform === "windows-x86_64") { assert(artifactPath.endsWith(".nsis.zip"), "windows-x86_64 must use an NSIS .nsis.zip updater artifact."); } } const uploadSources = [ ...uniqueArtifactPaths.flatMap((path) => [path, `${path}.sig`]), ...includes, ]; const sourceByName = new Map(); for (const sourcePath of uploadSources) { const name = basename(sourcePath); const existing = sourceByName.get(name); assert(!existing || existing === sourcePath, `Release files must have unique basenames: ${name}`); sourceByName.set(name, sourcePath); } const baseUrl = normalizedBaseUrl(); if (failures.length === 0 && baseUrl) { await mkdir(outputDir, { recursive: true }); const platforms = {}; for (const [platform, artifactPath] of platformArtifacts) { const artifactUrl = new URL(basename(artifactPath), baseUrl).toString(); assert(!artifactUrl.endsWith("/latest.json"), "Updater artifact URL must not point at latest.json."); assert(!/[?&]token=/i.test(new URL(artifactUrl).search), "Updater artifact URL must not include token query parameters."); platforms[platform] = { signature: artifactSignatures.get(artifactPath), url: artifactUrl, }; } const latest = { version: packageInfo.version, pub_date: pubDate, platforms, }; if (notes) { latest.notes = notes; } const copiedFiles = []; for (const sourcePath of sourceByName.values()) { const destination = resolve(outputDir, basename(sourcePath)); if (sourcePath !== destination) { await copyFile(sourcePath, destination); } copiedFiles.push(destination); } const latestPath = resolve(outputDir, "latest.json"); await writeFile(latestPath, `${JSON.stringify(latest, null, 2)}\n`); copiedFiles.push(latestPath); const checksumLines = []; for (const filePath of copiedFiles) { checksumLines.push(`${await sha256(filePath)} ${basename(filePath)}`); } const checksumPath = resolve(outputDir, "SHA256SUMS.txt"); await writeFile(checksumPath, `${checksumLines.join("\n")}\n`); console.log(`Desktop update feed created in ${outputDir}`); console.log(` - ${copiedFiles.map((path) => basename(path)).join("\n - ")}`); console.log(" - SHA256SUMS.txt"); } if (failures.length > 0) { console.error(`Desktop update feed creation failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`); process.exitCode = 1; }